diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml new file mode 100644 index 00000000..69192520 --- /dev/null +++ b/.github/workflows/release-sdk.yml @@ -0,0 +1,237 @@ +name: 'Release SDK' + +on: + workflow_dispatch: + inputs: + version: + description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.' + required: false + type: 'string' + ref: + description: 'The branch or ref (full git sha) to release from.' + required: true + type: 'string' + default: 'main' + dry_run: + description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' + required: true + type: 'boolean' + default: true + create_nightly_release: + description: 'Auto apply the nightly release tag, input version is ignored.' + required: false + type: 'boolean' + default: false + create_preview_release: + description: 'Auto apply the preview release tag, input version is ignored.' + required: false + type: 'boolean' + default: false + force_skip_tests: + description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' + required: false + type: 'boolean' + default: false + +jobs: + release-sdk: + runs-on: 'ubuntu-latest' + environment: + name: 'production-release' + url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}' + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} + permissions: + contents: 'write' + packages: 'write' + id-token: 'write' + issues: 'write' + outputs: + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.event.inputs.ref || github.sha }}' + fetch-depth: 0 + + - name: 'Set booleans for simplified logic' + env: + CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' + CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' + DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' + id: 'vars' + run: |- + is_nightly="false" + if [[ "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then + is_nightly="true" + fi + echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}" + + is_preview="false" + if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then + is_preview="true" + fi + echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" + + is_dry_run="false" + if [[ "${DRY_RUN_INPUT}" == "true" ]]; then + is_dry_run="true" + fi + echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + run: |- + npm ci + + - name: 'Get the version' + id: 'version' + run: | + VERSION_ARGS=() + if [[ "${IS_NIGHTLY}" == "true" ]]; then + VERSION_ARGS+=(--type=nightly) + elif [[ "${IS_PREVIEW}" == "true" ]]; then + VERSION_ARGS+=(--type=preview) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}") + fi + else + VERSION_ARGS+=(--type=stable) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}") + fi + fi + + VERSION_JSON=$(node packages/sdk-typescript/scripts/get-release-version.js "${VERSION_ARGS[@]}") + echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT" + echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT" + echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT" + + echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' + IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' + MANUAL_VERSION: '${{ inputs.version }}' + + - name: 'Run Tests' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + working-directory: 'packages/sdk-typescript' + run: | + npm run test:ci + env: + OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' + OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' + OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' + + - name: 'Build CLI for Integration Tests' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + run: | + npm run build + npm run bundle + + - name: 'Run SDK Integration Tests' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + run: | + npm run test:integration:sdk:sandbox:none + npm run test:integration:sdk:sandbox:docker + env: + OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' + OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' + OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' + + - name: 'Configure Git User' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: 'Create and switch to a release branch' + id: 'release_branch' + env: + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}" + git switch -c "${BRANCH_NAME}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + + - name: 'Update package version' + working-directory: 'packages/sdk-typescript' + env: + RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}' + run: |- + npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version + + - name: 'Commit and Conditionally Push package version' + env: + BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + git add packages/sdk-typescript/package.json + if git diff --staged --quiet; then + echo "No version changes to commit" + else + git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}" + fi + if [[ "${IS_DRY_RUN}" == "false" ]]; then + echo "Pushing release branch to remote..." + git push --set-upstream origin "${BRANCH_NAME}" --follow-tags + else + echo "Dry run enabled. Skipping push." + fi + + - name: 'Build SDK' + working-directory: 'packages/sdk-typescript' + run: |- + npm run build + + - name: 'Configure npm for publishing' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + registry-url: 'https://registry.npmjs.org' + scope: '@qwen-code' + + - name: 'Publish @qwen-code/sdk' + working-directory: 'packages/sdk-typescript' + run: |- + npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }} + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + + - name: 'Create GitHub Release and Tag' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' + run: |- + gh release create "sdk-typescript-${RELEASE_TAG}" \ + --target "$RELEASE_BRANCH" \ + --title "SDK TypeScript Release ${RELEASE_TAG}" \ + --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ + --generate-notes + + - name: 'Create Issue on Failure' + if: |- + ${{ failure() }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}" + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: |- + gh issue create \ + --title "SDK Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \ + --body "The SDK release workflow failed. See the full run for details: ${DETAILS_URL}" diff --git a/.vscode/launch.json b/.vscode/launch.json index d98757fb..bab4f22e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,7 +27,7 @@ "outFiles": [ "${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js" ], - "preLaunchTask": "npm: build: vscode-ide-companion" + "preLaunchTask": "launch: vscode-ide-companion (copy+build)" }, { "name": "Attach", @@ -79,7 +79,6 @@ "--", "-p", "${input:prompt}", - "-y", "--output-format", "stream-json" ], diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 58709bc9..e0ee4730 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -20,6 +20,22 @@ "problemMatcher": [], "label": "npm: build: vscode-ide-companion", "detail": "npm run build -w packages/vscode-ide-companion" + }, + { + "label": "copy: bundled-cli (dev)", + "type": "shell", + "command": "node", + "args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"], + "problemMatcher": [] + }, + { + "label": "launch: vscode-ide-companion (copy+build)", + "dependsOrder": "sequence", + "dependsOn": [ + "copy: bundled-cli (dev)", + "npm: build: vscode-ide-companion" + ], + "problemMatcher": [] } ] } diff --git a/README.md b/README.md index c6230b96..b277d4dd 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ npm install -g . brew install qwen-code ``` +## VS Code Extension + +In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more. + +> ๐Ÿ“ฆ The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md). + ## Quick Start ```bash diff --git a/docs/developers/cli/commands.md b/docs/developers/cli/commands.md index d258bc2d..aa056a43 100644 --- a/docs/developers/cli/commands.md +++ b/docs/developers/cli/commands.md @@ -145,16 +145,6 @@ Slash commands provide meta-level control over the CLI itself. - **`nodesc`** or **`nodescriptions`**: - **Description:** Hide tool descriptions, showing only the tool names. -- **`/quit-confirm`** - - **Description:** Show a confirmation dialog before exiting Qwen Code, allowing you to choose how to handle your current session. - - **Usage:** `/quit-confirm` - - **Features:** - - **Quit immediately:** Exit without saving anything (equivalent to `/quit`) - - **Generate summary and quit:** Create a project summary using `/summary` before exiting - - **Save conversation and quit:** Save the current conversation with an auto-generated tag before exiting - - **Keyboard shortcut:** Press **Ctrl+C** twice to trigger the quit confirmation dialog - - **Note:** This command is automatically triggered when you press Ctrl+C once, providing a safety mechanism to prevent accidental exits. - - **`/quit`** (or **`/exit`**) - **Description:** Exit Qwen Code immediately without any confirmation dialog. diff --git a/docs/developers/cli/configuration-v1.md b/docs/developers/cli/configuration-v1.md index 5127bbe2..2037db8d 100644 --- a/docs/developers/cli/configuration-v1.md +++ b/docs/developers/cli/configuration-v1.md @@ -671,4 +671,4 @@ Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM - **Category:** UI - **Requires Restart:** No - **Example:** `"enableWelcomeBack": false` - - **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details. + - **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command. See the [Welcome Back documentation](./welcome-back.md) for more details. diff --git a/docs/developers/cli/language.md b/docs/developers/cli/language.md index 7fb1e7f0..e4d403f0 100644 --- a/docs/developers/cli/language.md +++ b/docs/developers/cli/language.md @@ -10,19 +10,21 @@ The `/language` command allows you to customize the language settings for both t To change the UI language of Qwen Code, use the `ui` subcommand: ``` -/language ui [zh-CN|en-US] +/language ui [zh-CN|en-US|ru-RU] ``` ### Available UI Languages - **zh-CN**: Simplified Chinese (็ฎ€ไฝ“ไธญๆ–‡) - **en-US**: English +- **ru-RU**: Russian (ะ ัƒััะบะธะน) ### Examples ``` /language ui zh-CN # Set UI language to Simplified Chinese /language ui en-US # Set UI language to English +/language ui ru-RU # Set UI language to Russian ``` ### UI Language Subcommands @@ -31,6 +33,7 @@ You can also use direct subcommands for convenience: - `/language ui zh-CN` or `/language ui zh` or `/language ui ไธญๆ–‡` - `/language ui en-US` or `/language ui en` or `/language ui english` +- `/language ui ru-RU` or `/language ui ru` or `/language ui ั€ัƒััะบะธะน` ## LLM Output Language Settings diff --git a/docs/developers/tools/introduction.md b/docs/developers/tools/introduction.md index 3f60861a..9c732555 100644 --- a/docs/developers/tools/introduction.md +++ b/docs/developers/tools/introduction.md @@ -56,3 +56,7 @@ Qwen Code's built-in tools can be broadly categorized as follows: Additionally, these tools incorporate: - **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the model and your local environment or other services like APIs. + - **[MCP Quick Start Guide](../mcp-quick-start.md)**: Get started with MCP in 5 minutes with practical examples + - **[MCP Example Configurations](../mcp-example-configs.md)**: Ready-to-use configurations for common scenarios + - **[MCP Testing & Validation](../mcp-testing-validation.md)**: Test and validate your MCP server setups +- **[Sandboxing](../sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk. diff --git a/docs/mcp-example-configs.md b/docs/mcp-example-configs.md new file mode 100644 index 00000000..1b8212c0 --- /dev/null +++ b/docs/mcp-example-configs.md @@ -0,0 +1,533 @@ +# MCP Example Configurations + +Ready-to-use MCP server configurations for common scenarios. + +## ๐Ÿ“ Local Development + +### Basic Setup + +```json +{ + "mcpServers": { + "workspace": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "trust": true, + "description": "Full workspace access" + } + } +} +``` + +### Multi-Directory Project + +```json +{ + "mcpServers": { + "frontend": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "./src", + "./public", + "./tests" + ], + "trust": true, + "description": "Frontend development files" + }, + "config": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "./config", + "./.env.example" + ], + "trust": true, + "includeTools": ["read_file", "list_directory"], + "description": "Configuration files (read-only)" + } + } +} +``` + +## ๐Ÿง  Memory & Context + +### Persistent Memory + +```json +{ + "mcpServers": { + "project-memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "trust": true, + "description": "Remember project context across sessions" + } + } +} +``` + +### Combined with Filesystem + +```json +{ + "mcpServers": { + "files": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "trust": true + }, + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "trust": true + } + } +} +``` + +## ๐ŸŒ Remote Servers (HTTP/SSE) + +### HTTP MCP Server + +```json +{ + "mcpServers": { + "remote-api": { + "httpUrl": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer ${API_TOKEN}", + "Content-Type": "application/json" + }, + "timeout": 30000, + "description": "Remote MCP API" + } + } +} +``` + +### SSE Server with OAuth + +```json +{ + "mcpServers": { + "sse-service": { + "url": "https://mcp.example.com/sse", + "oauth": { + "enabled": true, + "scopes": ["read", "write"] + }, + "timeout": 60000, + "description": "SSE server with OAuth" + } + } +} +``` + +## ๐Ÿ Python MCP Servers + +### Simple Python Server + +```json +{ + "mcpServers": { + "python-tools": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "env": { + "PYTHONPATH": "${PWD}", + "DEBUG": "false" + }, + "description": "Custom Python MCP tools" + } + } +} +``` + +### Python with Virtual Environment + +```json +{ + "mcpServers": { + "python-venv": { + "command": "./venv/bin/python", + "args": ["-m", "mcp_server"], + "cwd": "./", + "env": { + "VIRTUAL_ENV": "${PWD}/venv" + }, + "description": "Python server in virtual environment" + } + } +} +``` + +## ๐Ÿณ Docker Containers + +### Basic Docker Server + +```json +{ + "mcpServers": { + "docker-mcp": { + "command": "docker", + "args": ["run", "-i", "--rm", "my-mcp-server:latest"], + "timeout": 45000, + "description": "MCP server in Docker" + } + } +} +``` + +### Docker with Volume Mounts + +```json +{ + "mcpServers": { + "docker-workspace": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "${PWD}:/workspace", + "-w", + "/workspace", + "-e", + "API_KEY", + "mcp-tools:latest" + ], + "env": { + "API_KEY": "${MY_API_KEY}" + }, + "description": "Docker MCP with workspace access" + } + } +} +``` + +## ๐Ÿ›ก๏ธ Security-Focused Configs + +### Read-Only Filesystem + +```json +{ + "mcpServers": { + "readonly-docs": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "./docs", + "./README.md" + ], + "includeTools": ["read_file", "list_directory", "search_files"], + "excludeTools": [ + "write_file", + "create_directory", + "move_file", + "delete_file" + ], + "trust": true, + "description": "Read-only documentation access" + } + } +} +``` + +### Untrusted External Server + +```json +{ + "mcpServers": { + "external-api": { + "httpUrl": "https://external-mcp.example.com/api", + "trust": false, + "timeout": 15000, + "includeTools": ["search", "analyze"], + "description": "External API (requires confirmation)" + } + } +} +``` + +## ๐Ÿ“Š Database Access + +### PostgreSQL MCP Server + +```json +{ + "mcpServers": { + "postgres": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "${DATABASE_URL}" + ], + "env": { + "DATABASE_URL": "$POSTGRES_CONNECTION_STRING" + }, + "timeout": 30000, + "trust": false, + "description": "PostgreSQL database access" + } + } +} +``` + +## ๐Ÿงช Testing & Development + +### Test Environment + +```json +{ + "mcpServers": { + "test-files": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "./tests", + "./fixtures" + ], + "trust": true, + "description": "Test files and fixtures" + }, + "test-memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "trust": true, + "description": "Test session memory" + } + } +} +``` + +### Debug Configuration + +```json +{ + "mcpServers": { + "debug-server": { + "command": "node", + "args": ["--inspect", "mcp-server.js"], + "env": { + "DEBUG": "*", + "LOG_LEVEL": "verbose" + }, + "timeout": 60000, + "description": "MCP server with debugging enabled" + } + } +} +``` + +## ๐Ÿ”„ CI/CD Integration + +### GitHub Actions Environment + +```json +{ + "mcpServers": { + "ci-workspace": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "${GITHUB_WORKSPACE}" + ], + "env": { + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "CI": "true" + }, + "trust": true, + "description": "CI/CD workspace access" + } + } +} +``` + +## ๐ŸŒŸ Advanced Patterns + +### Multiple Servers Same Type + +```json +{ + "mcpServers": { + "project-a": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "../project-a"], + "description": "Project A files" + }, + "project-b": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "../project-b"], + "description": "Project B files" + }, + "shared-memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "description": "Shared knowledge across projects" + } + } +} +``` + +### Conditional Server Selection + +User-level config (`~/.qwen/settings.json`): + +```json +{ + "mcpServers": { + "global-memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "trust": true, + "description": "Global memory across all projects" + } + } +} +``` + +Project-level config (`.qwen/settings.json`): + +```json +{ + "mcpServers": { + "project-files": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "trust": true, + "description": "Project-specific files" + } + } +} +``` + +## ๐Ÿ“ Configuration Validation + +### Check Your Config + +```bash +# List configured servers +qwen mcp list + +# Show server details and schemas +qwen mcp list --schema + +# Test connection +qwen mcp list --descriptions +``` + +### Common Mistakes + +โŒ **Wrong:** + +```json +{ + "mcpServers": { + "server": { + "command": "mcp-server", // Not in PATH + "args": ["./"] + } + } +} +``` + +โœ… **Correct:** + +```json +{ + "mcpServers": { + "server": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "description": "Uses npx to ensure server is available" + } + } +} +``` + +## ๐ŸŽฏ Best Practices + +1. **Use descriptive names** - Make server purposes clear +2. **Set appropriate timeouts** - Match your server's response time +3. **Trust local servers** - Skip confirmation for your own tools +4. **Filter tools** - Use `includeTools`/`excludeTools` for security +5. **Document configs** - Add descriptions for team members +6. **Environment variables** - Keep secrets out of configs +7. **Test independently** - Verify servers work before configuring + +## ๐Ÿ”— Quick Copy-Paste Configs + +### Starter Pack + +```json +{ + "mcpServers": { + "files": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "trust": true + }, + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "trust": true + } + } +} +``` + +### Documentation Project + +```json +{ + "mcpServers": { + "docs": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./docs"], + "includeTools": ["read_file", "list_directory"], + "trust": true + } + } +} +``` + +### Full-Stack Development + +```json +{ + "mcpServers": { + "frontend": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./frontend"], + "trust": true + }, + "backend": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./backend"], + "trust": true + }, + "shared": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./shared"], + "trust": true + }, + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "trust": true + } + } +} +``` + +--- + +**Need help?** Check `qwen mcp --help` or refer to the [complete MCP documentation](./tools/mcp-server.md). diff --git a/docs/mcp-quick-start.md b/docs/mcp-quick-start.md new file mode 100644 index 00000000..1bef3acc --- /dev/null +++ b/docs/mcp-quick-start.md @@ -0,0 +1,424 @@ +# MCP Quick Start Guide - Practical Examples + +This guide provides real-world examples to get you started with Model Context Protocol (MCP) servers in Qwen Code. + +## ๐Ÿš€ Getting Started in 5 Minutes + +### Step 1: Install MCP Servers + +Install official MCP servers from Anthropic: + +```bash +# Filesystem access +npm install -g @modelcontextprotocol/server-filesystem + +# Memory & Knowledge Graph +npm install -g @modelcontextprotocol/server-memory + +# Sequential thinking +npm install -g @modelcontextprotocol/server-sequential-thinking +``` + +### Step 2: Configure Your First MCP Server + +Create or edit `.qwen/settings.json` in your project: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "description": "Access project files" + } + } +} +``` + +### Step 3: Verify Connection + +```bash +qwen mcp list +``` + +You should see: + +``` +โœ“ filesystem: npx -y @modelcontextprotocol/server-filesystem ./ (stdio) - Connected +``` + +## ๐Ÿ“š Practical Examples + +### Example 1: Local Development Assistant + +**Use Case:** Work on a Node.js project with file access and memory. + +**Configuration (`.qwen/settings.json`):** + +```json +{ + "mcpServers": { + "project-files": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "./src", + "./tests", + "./docs" + ], + "description": "Access source code, tests, and documentation", + "trust": true + }, + "project-memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "description": "Remember project decisions and context", + "trust": true + } + } +} +``` + +**Usage:** + +```bash +qwen + +> Remember: This project uses React 18 with TypeScript and follows Airbnb style guide +> List all files in the src directory +> Read src/App.tsx and suggest improvements +``` + +### Example 2: Multi-Repository Development + +**Use Case:** Working across multiple codebases simultaneously. + +**Configuration:** + +```json +{ + "mcpServers": { + "frontend": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "../frontend-app" + ], + "description": "Frontend repository access" + }, + "backend": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "../backend-api" + ], + "description": "Backend repository access" + }, + "shared-memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "description": "Shared knowledge across repositories" + } + } +} +``` + +### Example 3: Documentation-Only Access + +**Use Case:** Safe access to documentation without risking code changes. + +**Configuration:** + +```json +{ + "mcpServers": { + "docs": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "./docs", + "./README.md" + ], + "description": "Read-only documentation access", + "trust": true, + "includeTools": ["read_file", "list_directory"] + } + } +} +``` + +### Example 4: Custom Python MCP Server + +**Use Case:** Integrate custom Python tools via MCP. + +**Server File (`mcp_server.py`):** + +```python +#!/usr/bin/env python3 +import sys +from mcp.server.stdio import stdio_server +from mcp.server import Server +from mcp.types import Tool, TextContent + +server = Server("custom-tools") + +@server.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name="analyze_python_code", + description="Static analysis of Python code", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string"} + }, + "required": ["file_path"] + } + ) + ] + +@server.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + if name == "analyze_python_code": + # Your custom logic here + return [TextContent(type="text", text=f"Analysis of {arguments['file_path']}")] + +async def main(): + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, server.create_initialization_options()) + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) +``` + +**Configuration:** + +```json +{ + "mcpServers": { + "python-tools": { + "command": "python", + "args": ["mcp_server.py"], + "env": { + "PYTHONPATH": "${PWD}" + }, + "description": "Custom Python analysis tools" + } + } +} +``` + +### Example 5: Docker-Based MCP Server + +**Use Case:** Run MCP servers in isolated containers. + +**Configuration:** + +```json +{ + "mcpServers": { + "containerized-tools": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "${PWD}:/workspace", + "-w", + "/workspace", + "my-mcp-server:latest" + ], + "description": "MCP tools running in Docker" + } + } +} +``` + +## ๐Ÿ”ง Configuration Tips + +### Environment Variables + +Use environment variables for sensitive data: + +```json +{ + "mcpServers": { + "api-server": { + "command": "node", + "args": ["api-server.js"], + "env": { + "API_KEY": "${MY_API_KEY}", + "DATABASE_URL": "$DB_CONNECTION" + } + } + } +} +``` + +### Trust Settings + +Trust servers you control to skip confirmation dialogs: + +```json +{ + "mcpServers": { + "trusted-server": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "trust": true + } + } +} +``` + +### Tool Filtering + +Limit which tools are available: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "includeTools": ["read_file", "list_directory"], + "excludeTools": ["write_file", "move_file"] + } + } +} +``` + +## ๐ŸŽฏ Common Use Cases + +### Code Review Assistant + +```json +{ + "mcpServers": { + "codebase": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "description": "Full codebase access for reviews" + }, + "review-memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "description": "Remember review comments and patterns" + } + } +} +``` + +**Usage:** + +```bash +qwen + +> Review the changes in src/components/ +> Remember: We follow the single responsibility principle +> Check if all new components have tests +``` + +### Documentation Generator + +```json +{ + "mcpServers": { + "source": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./src"], + "includeTools": ["read_file", "list_directory"] + }, + "docs-writer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./docs"], + "includeTools": ["write_file", "create_directory"] + } + } +} +``` + +### Learning Assistant + +```json +{ + "mcpServers": { + "tutorials": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "./tutorials", + "./examples" + ], + "trust": true + }, + "learning-progress": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "description": "Track learning progress and concepts" + } + } +} +``` + +## ๐Ÿ› ๏ธ Troubleshooting + +### Server Won't Connect + +1. **Check the command is accessible:** + + ```bash + npx -y @modelcontextprotocol/server-filesystem ./ + ``` + +2. **Verify directory permissions:** + + ```bash + ls -la ./ + ``` + +3. **Check logs:** + ```bash + qwen --debug + ``` + +### No Tools Discovered + +Ensure the server actually provides tools: + +```bash +qwen mcp list --schema +``` + +### Tools Not Executing + +- Check parameter schemas match +- Verify timeout settings (increase if needed) +- Test the server independently first + +## ๐Ÿ“– Further Reading + +- [MCP Server Documentation](./tools/mcp-server.md) - Complete reference +- [Official MCP Specification](https://modelcontextprotocol.io/) - Protocol details +- [MCP Server Examples](https://github.com/modelcontextprotocol/servers) - Community servers + +## ๐ŸŽ“ Next Steps + +1. โœ… Configure your first MCP server +2. โœ… Verify connection with `qwen mcp list` +3. โœ… Try basic file operations +4. โœ… Add memory for persistent context +5. โœ… Explore community MCP servers +6. โœ… Build your own custom server + +--- + +**Pro Tip:** Start with trusted local servers (`trust: true`) for faster iteration, then add confirmation for production use. diff --git a/docs/mcp-testing-validation.md b/docs/mcp-testing-validation.md new file mode 100644 index 00000000..865c71d0 --- /dev/null +++ b/docs/mcp-testing-validation.md @@ -0,0 +1,403 @@ +# MCP Testing & Validation Guide + +This guide helps you test and validate your MCP server configurations. + +## โœ… Quick Validation Checklist + +### 1. Check MCP Servers Are Configured + +```bash +qwen mcp list +``` + +**Expected output:** + +``` +Configured MCP servers: + +โœ“ filesystem: npx -y @modelcontextprotocol/server-filesystem ./ (stdio) - Connected +โœ“ memory: npx -y @modelcontextprotocol/server-memory (stdio) - Connected +``` + +**Status indicators:** + +- โœ“ (green) - Connected successfully +- โœ— (red) - Connection failed or not connected + +### 2. Verify Server Is Installed + +Test the server command directly: + +```bash +# Filesystem server +npx -y @modelcontextprotocol/server-filesystem --help + +# Memory server +npx -y @modelcontextprotocol/server-memory --help + +# Custom server +python mcp_server.py --help +``` + +### 3. Check Configuration File Syntax + +Validate your JSON configuration: + +```bash +# Linux/macOS +cat .qwen/settings.json | jq . + +# Windows PowerShell +Get-Content .qwen/settings.json | ConvertFrom-Json | ConvertTo-Json +``` + +### 4. Test Within Qwen Code Session + +Start an interactive session and check MCP status: + +```bash +qwen + +# Inside the session: +/mcp # Show all MCP servers and tools +/mcp desc # Show tool descriptions +/mcp schema # Show tool parameter schemas +``` + +## ๐Ÿงช Test Cases + +### Test Case 1: Filesystem Server + +**Configuration:** + +```json +{ + "mcpServers": { + "test-fs": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "trust": true + } + } +} +``` + +**Validation:** + +1. List files: Start `qwen` and ask "List all files in this directory" +2. Read file: "Read the README.md file" +3. Verify output contains actual file contents + +**Expected Tools:** + +- `read_file` - Read file contents +- `write_file` - Write to files +- `list_directory` - List directory contents +- `create_directory` - Create directories +- `move_file` - Move/rename files +- `search_files` - Search for files +- `get_file_info` - Get file metadata + +### Test Case 2: Memory Server + +**Configuration:** + +```json +{ + "mcpServers": { + "test-memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "trust": true + } + } +} +``` + +**Validation:** + +1. Store information: "Remember that this project uses React 18" +2. Query: "What JavaScript framework does this project use?" +3. Verify it recalls the information from step 1 + +**Expected Tools:** + +- `create_entities` - Create knowledge entities +- `create_relations` - Create relationships between entities +- `add_observations` - Add observations to entities +- `delete_entities` - Remove entities +- `delete_observations` - Remove observations +- `delete_relations` - Remove relationships +- `read_graph` - Read entire knowledge graph +- `search_nodes` - Search for specific nodes +- `open_nodes` - Open specific nodes by name + +### Test Case 3: Multiple Servers + +**Configuration:** + +```json +{ + "mcpServers": { + "files": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "trust": true + }, + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "trust": true + } + } +} +``` + +**Validation:** + +1. Check both servers are connected: `qwen mcp list` +2. Use filesystem tool: "List all JavaScript files" +3. Use memory tool: "Remember that we prefer TypeScript" +4. Verify both tools work simultaneously + +### Test Case 4: Tool Filtering + +**Configuration:** + +```json +{ + "mcpServers": { + "readonly-fs": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "includeTools": ["read_file", "list_directory"], + "trust": true + } + } +} +``` + +**Validation:** + +1. Start qwen session +2. Run `/mcp desc` to list available tools +3. Verify only `read_file` and `list_directory` are present +4. Verify `write_file`, `create_directory`, etc. are NOT available + +### Test Case 5: Untrusted Server Confirmation + +**Configuration:** + +```json +{ + "mcpServers": { + "untrusted": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], + "trust": false + } + } +} +``` + +**Validation:** + +1. Ask qwen to read a file +2. Confirmation dialog should appear before execution +3. Options should include: + - Proceed once + - Always allow this tool + - Always allow this server + - Cancel + +## ๐Ÿ” Debugging Failed Connections + +### Issue: Server Shows "Disconnected" + +**Diagnostic steps:** + +1. **Test command manually:** + + ```bash + npx -y @modelcontextprotocol/server-filesystem ./ + ``` + +2. **Check for errors:** + + ```bash + qwen --debug + ``` + +3. **Verify paths are correct:** + + ```bash + # Check if directory exists + ls ./ + + # Check if command is in PATH + which npx # Linux/macOS + where npx # Windows + ``` + +4. **Check permissions:** + + ```bash + # Verify read/execute permissions + ls -la ./ + ``` + +5. **Review environment variables:** + ```bash + echo $PATH + echo $PYTHONPATH # For Python servers + ``` + +### Issue: No Tools Discovered + +**Diagnostic steps:** + +1. **Verify server implements MCP protocol:** + + ```bash + # For stdio servers, test input/output manually + echo '{"jsonrpc": "2.0", "method": "initialize", "id": 1}' | npx -y @modelcontextprotocol/server-filesystem ./ + ``` + +2. **Check server logs:** + + ```bash + # Some servers log to stderr + qwen --debug 2>&1 | grep MCP + ``` + +3. **Verify server version:** + ```bash + npm list -g @modelcontextprotocol/server-filesystem + ``` + +### Issue: Tools Fail to Execute + +**Diagnostic steps:** + +1. **Check parameter format:** + - Ensure parameters match the expected schema + - Verify JSON encoding is correct + +2. **Increase timeout:** + + ```json + { + "mcpServers": { + "slow-server": { + "command": "...", + "timeout": 60000 + } + } + } + ``` + +3. **Check server implementation:** + - Verify the server actually implements the tool + - Test the tool independently if possible + +## ๐Ÿ“Š Validation Results + +### Expected Server Connection Times + +| Server Type | Typical Connection Time | Timeout Recommendation | +| ------------- | ----------------------- | ---------------------- | +| Filesystem | < 1 second | 10-30 seconds | +| Memory | < 1 second | 10-30 seconds | +| HTTP/SSE | 1-3 seconds | 30-60 seconds | +| Custom Python | 2-5 seconds | 30-60 seconds | +| Docker | 5-10 seconds | 60-120 seconds | + +### Tool Execution Times + +| Tool Type | Typical Duration | Timeout Recommendation | +| ----------------- | ---------------- | ---------------------- | +| Read file | < 100ms | 5-10 seconds | +| List directory | < 500ms | 10-15 seconds | +| Search files | 1-5 seconds | 30-60 seconds | +| Memory operations | < 1 second | 10-30 seconds | +| API calls | 1-10 seconds | 30-120 seconds | + +## ๐ŸŽฏ Success Criteria + +Your MCP configuration is working correctly if: + +โœ… `qwen mcp list` shows all servers as "Connected" +โœ… `/mcp` command in qwen session displays tools +โœ… Tool executions complete without errors +โœ… Confirmation dialogs appear for untrusted servers (if `trust: false`) +โœ… Tool filtering works as expected (include/exclude) +โœ… Environment variables are properly substituted +โœ… Timeouts are appropriate for your server's response time + +## ๐Ÿš€ Performance Tips + +1. **Use `trust: true` for local servers** to skip confirmation dialogs +2. **Set appropriate timeouts** - too low causes failures, too high slows down errors +3. **Filter tools** - Only enable tools you actually need +4. **Test servers independently** before configuring in qwen +5. **Use `--debug` flag** during initial setup +6. **Monitor resource usage** for long-running or resource-intensive servers + +## ๐Ÿ“ Validation Script Example + +Create a test script to automate validation: + +```bash +#!/bin/bash +# validate-mcp.sh + +echo "Testing MCP configuration..." + +# Test 1: Check config file exists +if [ ! -f .qwen/settings.json ]; then + echo "โŒ Missing .qwen/settings.json" + exit 1 +fi +echo "โœ… Config file exists" + +# Test 2: Validate JSON syntax +if ! cat .qwen/settings.json | jq . > /dev/null 2>&1; then + echo "โŒ Invalid JSON in settings.json" + exit 1 +fi +echo "โœ… Valid JSON syntax" + +# Test 3: Check servers are configured +SERVER_COUNT=$(cat .qwen/settings.json | jq '.mcpServers | length') +if [ "$SERVER_COUNT" -eq 0 ]; then + echo "โŒ No MCP servers configured" + exit 1 +fi +echo "โœ… $SERVER_COUNT MCP server(s) configured" + +# Test 4: Check connection status +qwen mcp list | grep -q "Connected" +if [ $? -eq 0 ]; then + echo "โœ… At least one server is connected" +else + echo "โŒ No servers connected" + exit 1 +fi + +echo "" +echo "โœ… All validation checks passed!" +``` + +## ๐Ÿ“š Next Steps + +After validation: + +1. **Start using MCP tools** in your workflow +2. **Document your custom configurations** for team members +3. **Share your successful configs** with the community +4. **Monitor performance** and adjust timeouts as needed +5. **Explore more MCP servers** from the community + +--- + +**Having issues?** Check the [MCP troubleshooting guide](./tools/mcp-server.md#troubleshooting) or open an issue on GitHub. diff --git a/eslint.config.js b/eslint.config.js index 63c13d48..26ec8edf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,7 @@ export default tseslint.config( 'bundle/**', 'package/bundle/**', '.integration-tests/**', + 'packages/**/.integration-test/**', 'dist/**', ], }, @@ -74,6 +75,8 @@ export default tseslint.config( }, }, rules: { + // We use TypeScript for React components; prop-types are unnecessary + 'react/prop-types': 'off', // General Best Practice Rules (subset adapted for flat config) '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 'arrow-body-style': ['error', 'as-needed'], @@ -110,10 +113,14 @@ export default tseslint.config( { allow: [ 'react-dom/test-utils', + 'react-dom/client', 'memfs/lib/volume.js', 'yargs/**', 'msw/node', - '**/generated/**' + '**/generated/**', + './styles/tailwind.css', + './styles/App.css', + './styles/style.css' ], }, ], @@ -150,7 +157,7 @@ export default tseslint.config( }, }, { - files: ['packages/*/src/**/*.test.{ts,tsx}'], + files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'], plugins: { vitest, }, @@ -158,11 +165,19 @@ export default tseslint.config( ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, // extra settings for scripts that we run directly with node { - files: ['./scripts/**/*.js', 'esbuild.config.js'], + files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], languageOptions: { globals: { ...globals.node, @@ -229,7 +244,7 @@ export default tseslint.config( prettierConfig, // extra settings for scripts that we run directly with node { - files: ['./integration-tests/**/*.js'], + files: ['./integration-tests/**/*.{js,ts,tsx}'], languageOptions: { globals: { ...globals.node, diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index b098e025..31e32da7 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -13,8 +13,6 @@ import { TestRig } from './test-helper.js'; const REQUEST_TIMEOUT_MS = 60_000; const INITIAL_PROMPT = 'Create a quick note (smoke test).'; -const RESUME_PROMPT = 'Continue the note after reload.'; -const LIST_SIZE = 5; const IS_SANDBOX = process.env['GEMINI_SANDBOX'] && process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false'; @@ -25,6 +23,14 @@ type PendingRequest = { timeout: NodeJS.Timeout; }; +type UsageMetadata = { + promptTokens?: number | null; + completionTokens?: number | null; + thoughtsTokens?: number | null; + totalTokens?: number | null; + cachedTokens?: number | null; +}; + type SessionUpdateNotification = { sessionId?: string; update?: { @@ -39,6 +45,9 @@ type SessionUpdateNotification = { text?: string; }; modeId?: string; + _meta?: { + usage?: UsageMetadata; + }; }; }; @@ -86,10 +95,14 @@ function setupAcpTest( const permissionHandler = options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' })); - const agent = spawn('node', [rig.bundlePath, '--experimental-acp'], { - cwd: rig.testDir!, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const agent = spawn( + 'node', + [rig.bundlePath, '--experimental-acp', '--no-chat-recording'], + { + cwd: rig.testDir!, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); agent.stderr?.on('data', (chunk) => { stderr.push(chunk.toString()); @@ -253,11 +266,11 @@ function setupAcpTest( } (IS_SANDBOX ? describe.skip : describe)('acp integration', () => { - it('creates, lists, loads, and resumes a session', async () => { + it('basic smoke test', async () => { const rig = new TestRig(); rig.setup('acp load session'); - const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig); + const { sendRequest, cleanup, stderr } = setupAcpTest(rig); try { const initResult = await sendRequest('initialize', { @@ -283,34 +296,6 @@ function setupAcpTest( prompt: [{ type: 'text', text: INITIAL_PROMPT }], }); expect(promptResult).toBeDefined(); - - await delay(500); - - const listResult = (await sendRequest('session/list', { - cwd: rig.testDir!, - size: LIST_SIZE, - })) as { items?: Array<{ sessionId: string }> }; - - expect(Array.isArray(listResult.items)).toBe(true); - expect(listResult.items?.length ?? 0).toBeGreaterThan(0); - - const sessionToLoad = listResult.items![0].sessionId; - await sendRequest('session/load', { - cwd: rig.testDir!, - sessionId: sessionToLoad, - mcpServers: [], - }); - - const resumeResult = await sendRequest('session/prompt', { - sessionId: sessionToLoad, - prompt: [{ type: 'text', text: RESUME_PROMPT }], - }); - expect(resumeResult).toBeDefined(); - - const sessionsWithUpdates = sessionUpdates - .map((update) => update.sessionId) - .filter(Boolean); - expect(sessionsWithUpdates).toContain(sessionToLoad); } catch (e) { if (stderr.length) { console.error('Agent stderr:', stderr.join('')); @@ -587,4 +572,52 @@ function setupAcpTest( await cleanup(); } }); + + it('receives usage metadata in agent_message_chunk updates', async () => { + const rig = new TestRig(); + rig.setup('acp usage metadata'); + + const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig); + + try { + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }); + await sendRequest('authenticate', { methodId: 'openai' }); + + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + + await sendRequest('session/prompt', { + sessionId: newSession.sessionId, + prompt: [{ type: 'text', text: 'Say "hello".' }], + }); + + await delay(500); + + // Find updates with usage metadata + const updatesWithUsage = sessionUpdates.filter( + (u) => + u.update?.sessionUpdate === 'agent_message_chunk' && + u.update?._meta?.usage, + ); + + expect(updatesWithUsage.length).toBeGreaterThan(0); + + const usage = updatesWithUsage[0].update?._meta?.usage; + expect(usage).toBeDefined(); + expect( + typeof usage?.promptTokens === 'number' || + typeof usage?.totalTokens === 'number', + ).toBe(true); + } catch (e) { + if (stderr.length) console.error('Agent stderr:', stderr.join('')); + throw e; + } finally { + await cleanup(); + } + }); }); diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 77105af2..a8a9877f 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -30,6 +30,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); const integrationTestsDir = join(rootDir, '.integration-tests'); let runDir = ''; // Make runDir accessible in teardown +let sdkE2eRunDir = ''; // SDK E2E test run directory const memoryFilePath = join( os.homedir(), @@ -48,14 +49,36 @@ export async function setup() { // File doesn't exist, which is fine. } + // Setup for CLI integration tests runDir = join(integrationTestsDir, `${Date.now()}`); await mkdir(runDir, { recursive: true }); + // Setup for SDK E2E tests (separate directory with prefix) + sdkE2eRunDir = join(integrationTestsDir, `sdk-e2e-${Date.now()}`); + await mkdir(sdkE2eRunDir, { recursive: true }); + // Clean up old test runs, but keep the latest few for debugging try { const testRuns = await readdir(integrationTestsDir); - if (testRuns.length > 5) { - const oldRuns = testRuns.sort().slice(0, testRuns.length - 5); + + // Clean up old CLI integration test runs (without sdk-e2e- prefix) + const cliTestRuns = testRuns.filter((run) => !run.startsWith('sdk-e2e-')); + if (cliTestRuns.length > 5) { + const oldRuns = cliTestRuns.sort().slice(0, cliTestRuns.length - 5); + await Promise.all( + oldRuns.map((oldRun) => + rm(join(integrationTestsDir, oldRun), { + recursive: true, + force: true, + }), + ), + ); + } + + // Clean up old SDK E2E test runs (with sdk-e2e- prefix) + const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-')); + if (sdkTestRuns.length > 5) { + const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5); await Promise.all( oldRuns.map((oldRun) => rm(join(integrationTestsDir, oldRun), { @@ -69,24 +92,37 @@ export async function setup() { console.error('Error cleaning up old test runs:', e); } + // Environment variables for CLI integration tests process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log'); + // Environment variables for SDK E2E tests + process.env['E2E_TEST_FILE_DIR'] = sdkE2eRunDir; + process.env['TEST_CLI_PATH'] = join(rootDir, 'dist/cli.js'); + if (process.env['KEEP_OUTPUT']) { console.log(`Keeping output for test run in: ${runDir}`); + console.log(`Keeping output for SDK E2E test run in: ${sdkE2eRunDir}`); } process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; console.log(`\nIntegration test output directory: ${runDir}`); + console.log(`SDK E2E test output directory: ${sdkE2eRunDir}`); + console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); } export async function teardown() { - // Cleanup the test run directory unless KEEP_OUTPUT is set + // Cleanup the CLI test run directory unless KEEP_OUTPUT is set if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { await rm(runDir, { recursive: true, force: true }); } + // Cleanup the SDK E2E test run directory unless KEEP_OUTPUT is set + if (process.env['KEEP_OUTPUT'] !== 'true' && sdkE2eRunDir) { + await rm(sdkE2eRunDir, { recursive: true, force: true }); + } + if (originalMemoryContent !== null) { await mkdir(dirname(memoryFilePath), { recursive: true }); await writeFile(memoryFilePath, originalMemoryContent, 'utf-8'); diff --git a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts new file mode 100644 index 00000000..93005d4b --- /dev/null +++ b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts @@ -0,0 +1,486 @@ +/** + * E2E tests based on abort-and-lifecycle.ts example + * Tests AbortController integration and process lifecycle management + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + AbortError, + isAbortError, + isSDKAssistantMessage, + type TextBlock, + type ContentBlock, +} from '@qwen-code/sdk'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('AbortController and Process Lifecycle (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('abort-and-lifecycle'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('Basic AbortController Usage', () => { + it('should support AbortController cancellation', async () => { + const controller = new AbortController(); + + // Abort after 5 seconds + setTimeout(() => { + controller.abort(); + }, 5000); + + const q = query({ + prompt: 'Write a very long story about TypeScript programming', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 100); + + // Should receive some content before abort + expect(text.length).toBeGreaterThan(0); + } + } + + // Should not reach here - query should be aborted + expect(false).toBe(true); + } catch (error) { + expect(isAbortError(error)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle abort during query execution', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + let receivedFirstMessage = false; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + if (!receivedFirstMessage) { + // Abort immediately after receiving first assistant message + receivedFirstMessage = true; + controller.abort(); + } + } + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + // Should have received at least one message before abort + expect(receivedFirstMessage).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle abort immediately after query starts', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a very long essay', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Abort immediately after query initialization + setTimeout(() => { + controller.abort(); + }, 200); + + try { + for await (const _message of q) { + // May or may not receive messages before abort + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Process Lifecycle Monitoring', () => { + it('should handle normal process completion', async () => { + const q = query({ + prompt: 'Why do we choose to go to the moon?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let completedSuccessfully = false; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 100); + expect(text.length).toBeGreaterThan(0); + } + } + + completedSuccessfully = true; + } catch (error) { + // Should not throw for normal completion + expect(false).toBe(true); + } finally { + await q.close(); + expect(completedSuccessfully).toBe(true); + } + }); + + it('should handle process cleanup after error', async () => { + const q = query({ + prompt: 'Hello world', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 50); + expect(text.length).toBeGreaterThan(0); + } + } + } catch (error) { + // Expected to potentially have errors + } finally { + // Should cleanup successfully even after error + await q.close(); + expect(true).toBe(true); // Cleanup completed + } + }); + }); + + describe('Input Stream Control', () => { + it('should support endInput() method', async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let receivedResponse = false; + let endInputCalled = false; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message) && !endInputCalled) { + const textBlocks = message.message.content.filter( + (block: ContentBlock): block is TextBlock => + block.type === 'text', + ); + const text = textBlocks.map((b: TextBlock) => b.text).join(''); + + expect(text.length).toBeGreaterThan(0); + receivedResponse = true; + + // End input after receiving first response + q.endInput(); + endInputCalled = true; + } + } + + expect(receivedResponse).toBe(true); + expect(endInputCalled).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling and Recovery', () => { + it('should handle invalid executable path', async () => { + try { + const q = query({ + prompt: 'Hello world', + options: { + pathToQwenExecutable: '/nonexistent/path/to/cli', + debug: false, + }, + }); + + // Should not reach here - query() should throw immediately + for await (const _message of q) { + // Should not reach here + } + + // Should not reach here + expect(false).toBe(true); + } catch (error) { + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toBeDefined(); + expect((error as Error).message).toContain( + 'Invalid pathToQwenExecutable', + ); + } + }); + + it('should throw AbortError with correct properties', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Explain the concept of async programming', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Abort after allowing query to start + setTimeout(() => controller.abort(), 1000); + + try { + for await (const _message of q) { + // May receive some messages before abort + } + } catch (error) { + // Verify error type and helper functions + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBeDefined(); + } finally { + await q.close(); + } + }); + }); + + describe('Debugging with stderr callback', () => { + it('should capture stderr messages when debug is enabled', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Why do we choose to go to the moon?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 50); + expect(text.length).toBeGreaterThan(0); + } + } + } finally { + await q.close(); + expect(stderrMessages.length).toBeGreaterThan(0); + } + }); + + it('should not capture stderr when debug is disabled', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + } finally { + await q.close(); + // Should have minimal or no stderr output when debug is false + expect(stderrMessages.length).toBeLessThan(10); + } + }); + }); + + describe('Abort with Cleanup', () => { + it('should cleanup properly after abort', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a very long essay about programming', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Abort immediately + setTimeout(() => controller.abort(), 100); + + try { + for await (const _message of q) { + // May receive some messages before abort + } + } catch (error) { + if (error instanceof AbortError) { + expect(true).toBe(true); // Expected abort error + } else { + throw error; // Unexpected error + } + } finally { + await q.close(); + expect(true).toBe(true); // Cleanup completed after abort + } + }); + + it('should handle multiple abort calls gracefully', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Count to 100', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Multiple abort calls + setTimeout(() => controller.abort(), 100); + setTimeout(() => controller.abort(), 200); + setTimeout(() => controller.abort(), 300); + + try { + for await (const _message of q) { + // Should be interrupted + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Resource Management Edge Cases', () => { + it('should handle close() called multiple times', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + // Start the query + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Close multiple times + await q.close(); + await q.close(); + await q.close(); + + // Should not throw + expect(true).toBe(true); + }); + + it('should handle abort after close', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Start and close immediately + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + await q.close(); + + // Abort after close + controller.abort(); + + // Should not throw + expect(true).toBe(true); + }); + }); +}); diff --git a/integration-tests/sdk-typescript/configuration-options.test.ts b/integration-tests/sdk-typescript/configuration-options.test.ts new file mode 100644 index 00000000..ca218248 --- /dev/null +++ b/integration-tests/sdk-typescript/configuration-options.test.ts @@ -0,0 +1,636 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for SDK configuration options: + * - logLevel: Controls SDK internal logging verbosity + * - env: Environment variables passed to CLI process + * - authType: Authentication type for AI service + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKAssistantMessage, + isSDKSystemMessage, + type SDKMessage, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + extractText, + createSharedTestOptions, + assertSuccessfulCompletion, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('Configuration Options (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('configuration-options'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('logLevel Option', () => { + it('should respect logLevel: debug and capture detailed logs', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 1 + 1? Just answer the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'debug', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Debug level should produce verbose logging + expect(stderrMessages.length).toBeGreaterThan(0); + + // Debug logs should contain detailed information like [DEBUG] + const hasDebugLogs = stderrMessages.some( + (msg) => msg.includes('[DEBUG]') || msg.includes('debug'), + ); + expect(hasDebugLogs).toBe(true); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should respect logLevel: info and filter out debug messages', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 2 + 2? Just answer the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'info', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Info level should filter out debug messages + // Check that we don't have [DEBUG] level messages from the SDK logger + const sdkDebugLogs = stderrMessages.filter( + (msg) => + msg.includes('[DEBUG]') && msg.includes('[ProcessTransport]'), + ); + expect(sdkDebugLogs.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should respect logLevel: warn and only show warnings and errors', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'warn', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Warn level should filter out info and debug messages from SDK + const sdkInfoOrDebugLogs = stderrMessages.filter( + (msg) => + (msg.includes('[DEBUG]') || msg.includes('[INFO]')) && + (msg.includes('[ProcessTransport]') || + msg.includes('[createQuery]') || + msg.includes('[Query]')), + ); + expect(sdkInfoOrDebugLogs.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should respect logLevel: error and only show error messages', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello world', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'error', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Error level should filter out all non-error messages from SDK + const sdkNonErrorLogs = stderrMessages.filter( + (msg) => + (msg.includes('[DEBUG]') || + msg.includes('[INFO]') || + msg.includes('[WARN]')) && + (msg.includes('[ProcessTransport]') || + msg.includes('[createQuery]') || + msg.includes('[Query]')), + ); + expect(sdkNonErrorLogs.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should use logLevel over debug flag when both are provided', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 3 + 3?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: true, // Would normally enable debug logging + logLevel: 'error', // But logLevel should take precedence + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // logLevel: error should suppress debug/info/warn even with debug: true + const sdkNonErrorLogs = stderrMessages.filter( + (msg) => + (msg.includes('[DEBUG]') || + msg.includes('[INFO]') || + msg.includes('[WARN]')) && + (msg.includes('[ProcessTransport]') || + msg.includes('[createQuery]') || + msg.includes('[Query]')), + ); + expect(sdkNonErrorLogs.length).toBe(0); + } finally { + await q.close(); + } + }); + }); + + describe('env Option', () => { + it('should pass custom environment variables to CLI process', async () => { + const q = query({ + prompt: 'What is 1 + 1? Just the number please.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + CUSTOM_TEST_VAR: 'test_value_12345', + ANOTHER_VAR: 'another_value', + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // The query should complete successfully with custom env vars + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should allow overriding existing environment variables', async () => { + // Store original value for comparison + const originalPath = process.env['PATH']; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + // Override an existing env var (not PATH as it might break things) + MY_TEST_OVERRIDE: 'overridden_value', + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Query should complete successfully + assertSuccessfulCompletion(messages); + + // Verify original process env is not modified + expect(process.env['PATH']).toBe(originalPath); + } finally { + await q.close(); + } + }); + + it('should work with empty env object', async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: {}, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should support setting model-related environment variables', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + // Common model-related env vars that CLI might respect + OPENAI_API_KEY: process.env['OPENAI_API_KEY'] || 'test-key', + }, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should complete (may succeed or fail based on API key validity) + expect(messages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should not leak env vars between query instances', async () => { + // First query with specific env var + const q1 = query({ + prompt: 'Say one', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + ISOLATED_VAR_1: 'value_1', + }, + debug: false, + }, + }); + + try { + for await (const _message of q1) { + // Consume messages + } + } finally { + await q1.close(); + } + + // Second query with different env var + const q2 = query({ + prompt: 'Say two', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + ISOLATED_VAR_2: 'value_2', + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q2) { + messages.push(message); + } + + // Second query should complete successfully + assertSuccessfulCompletion(messages); + + // Verify process.env is not polluted by either query + expect(process.env['ISOLATED_VAR_1']).toBeUndefined(); + expect(process.env['ISOLATED_VAR_2']).toBeUndefined(); + } finally { + await q2.close(); + } + }); + }); + + describe('authType Option', () => { + it('should accept authType: openai', async () => { + const q = query({ + prompt: 'What is 1 + 1? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + authType: 'openai', + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Query should complete with openai auth type + assertSuccessfulCompletion(messages); + + // Verify we got an assistant response + const assistantMessages = messages.filter(isSDKAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + // Skip - qwen-oauth requires user interaction which is not possible in CI environments + it.skip('should accept authType: qwen-oauth', async () => { + // Note: qwen-oauth requires credentials in ~/.qwen and user interaction + // Without credentials, the auth process will timeout waiting for user + // This test verifies the option is accepted and passed correctly to CLI + + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + authType: 'qwen-oauth', + debug: true, + logLevel: 'debug', + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + // Use a timeout to avoid hanging when credentials are not configured + const timeoutPromise = new Promise<'timeout'>((resolve) => + setTimeout(() => resolve('timeout'), 20000), + ); + + const collectMessages = async () => { + for await (const message of q) { + messages.push(message); + } + return 'completed'; + }; + + const result = await Promise.race([collectMessages(), timeoutPromise]); + + if (result === 'timeout') { + // Timeout is expected when OAuth credentials are not configured + // Verify that CLI was spawned with correct --auth-type argument + const hasAuthTypeArg = stderrMessages.some((msg) => + msg.includes('--auth-type'), + ); + expect(hasAuthTypeArg).toBe(true); + } else { + // If credentials exist and auth completed, verify we got messages + expect(messages.length).toBeGreaterThan(0); + } + } finally { + await q.close(); + } + }); + + it('should use default auth when authType is not specified', async () => { + const q = query({ + prompt: 'What is 2 + 2? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + // authType not specified - should use default + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Query should complete with default auth + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should properly pass authType to CLI process', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Say hi', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + authType: 'openai', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // There should be spawn log containing auth-type + const hasAuthTypeArg = stderrMessages.some((msg) => + msg.includes('--auth-type'), + ); + expect(hasAuthTypeArg).toBe(true); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + }); + + describe('Combined Options', () => { + it('should work with logLevel, env, and authType together', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 3 + 3? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'debug', + env: { + COMBINED_TEST_VAR: 'combined_value', + }, + authType: 'openai', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // All three options should work together + expect(stderrMessages.length).toBeGreaterThan(0); // logLevel: debug produces logs + expect(assistantText).toMatch(/6/); // Query should work + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should maintain system message consistency with all options', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'info', + env: { + SYSTEM_MSG_TEST: 'test', + }, + authType: 'openai', + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should have system init message + const systemMessages = messages.filter(isSDKSystemMessage); + const initMessage = systemMessages.find((m) => m.subtype === 'init'); + + expect(initMessage).toBeDefined(); + expect(initMessage!.session_id).toBeDefined(); + expect(initMessage!.tools).toBeDefined(); + expect(initMessage!.permission_mode).toBeDefined(); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/mcp-server.test.ts b/integration-tests/sdk-typescript/mcp-server.test.ts new file mode 100644 index 00000000..9b3f2193 --- /dev/null +++ b/integration-tests/sdk-typescript/mcp-server.test.ts @@ -0,0 +1,405 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for MCP (Model Context Protocol) server integration via SDK + * Tests that the SDK can properly interact with MCP servers configured in qwen-code + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKSystemMessage, + isSDKUserMessage, + type SDKMessage, + type ToolUseBlock, + type SDKSystemMessage, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + createMCPServer, + extractText, + findToolUseBlocks, + createSharedTestOptions, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = { + ...createSharedTestOptions(), + permissionMode: 'yolo' as const, +}; + +describe('MCP Server Integration (E2E)', () => { + let helper: SDKTestHelper; + let serverScriptPath: string; + let testDir: string; + + beforeEach(async () => { + // Create isolated test environment using SDKTestHelper + helper = new SDKTestHelper(); + testDir = await helper.setup('mcp-server-integration'); + + // Create MCP server using the helper utility + const mcpServer = await createMCPServer(helper, 'math', 'test-math-server'); + serverScriptPath = mcpServer.scriptPath; + }); + + afterEach(async () => { + // Cleanup test directory + await helper.cleanup(); + }); + + describe('Basic MCP Tool Usage', () => { + it('should use MCP add tool to add two numbers', async () => { + const q = query({ + prompt: + 'Use the add tool to calculate 5 + 10. Just give me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'add'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer + expect(assistantText).toMatch(/15/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + if (isSDKResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should use MCP multiply tool to multiply two numbers', async () => { + const q = query({ + prompt: + 'Use the multiply tool to calculate 6 * 7. Just give me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'multiply'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer + expect(assistantText).toMatch(/42/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('MCP Server Discovery', () => { + it('should list MCP servers in system init message', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + let systemMessage: SDKSystemMessage | null = null; + + try { + for await (const message of q) { + if (isSDKSystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + break; + } + } + + // Validate MCP server is listed + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + + // Find our test server + const testServer = systemMessage!.mcp_servers?.find( + (server) => server.name === 'test-math-server', + ); + expect(testServer).toBeDefined(); + + // Note: tools are not exposed in the mcp_servers array in system message + // They are available through the MCP protocol but not in the init message + } finally { + await q.close(); + } + }); + }); + + describe('Complex MCP Operations', () => { + it('should chain multiple MCP tool calls', async () => { + const q = query({ + prompt: + 'First use add to calculate 10 + 5, then multiply the result by 2. Give me the final answer.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + const toolCalls: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); + toolUseBlocks.forEach((block) => { + toolCalls.push(block.name); + }); + assistantText += extractText(message.message.content); + } + } + + // Validate both tools were called + expect(toolCalls).toContain('add'); + expect(toolCalls).toContain('multiply'); + + // Validate result: (10 + 5) * 2 = 30 + expect(assistantText).toMatch(/30/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle multiple calls to the same MCP tool', async () => { + const q = query({ + prompt: + 'Use the add tool twice: first add 1 + 2, then add 3 + 4. Tell me both results.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + const addToolCalls: ToolUseBlock[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'add'); + addToolCalls.push(...toolUseBlocks); + assistantText += extractText(message.message.content); + } + } + + // Validate add tool was called at least twice + expect(addToolCalls.length).toBeGreaterThanOrEqual(2); + + // Validate results contain expected answers: 3 and 7 + expect(assistantText).toMatch(/3/); + expect(assistantText).toMatch(/7/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('MCP Tool Message Flow', () => { + it('should receive proper message sequence for MCP tool usage', async () => { + const q = query({ + prompt: 'Use add to calculate 2 + 3', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messageTypes: string[] = []; + let foundToolUse = false; + let foundToolResult = false; + + try { + for await (const message of q) { + messageTypes.push(message.type); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + expect(toolUseBlocks[0].name).toBe('add'); + expect(toolUseBlocks[0].input).toBeDefined(); + } + } + + if (isSDKUserMessage(message)) { + const content = message.message.content; + const contentArray = Array.isArray(content) + ? content + : [{ type: 'text', text: content }]; + const toolResultBlock = contentArray.find( + (block) => block.type === 'tool_result', + ); + if (toolResultBlock) { + foundToolResult = true; + } + } + } + + // Validate message flow + expect(foundToolUse).toBe(true); + expect(foundToolResult).toBe(true); + expect(messageTypes).toContain('system'); + expect(messageTypes).toContain('assistant'); + expect(messageTypes).toContain('user'); + expect(messageTypes).toContain('result'); + + // Result should be last message + expect(messageTypes[messageTypes.length - 1]).toBe('result'); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling', () => { + it('should handle gracefully when MCP tool is not available', async () => { + const q = query({ + prompt: 'Use the subtract tool to calculate 10 - 5', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Should complete without crashing + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + + // Assistant should indicate tool is not available or provide alternative + expect(assistantText.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/multi-turn.test.ts b/integration-tests/sdk-typescript/multi-turn.test.ts new file mode 100644 index 00000000..c1b96cc7 --- /dev/null +++ b/integration-tests/sdk-typescript/multi-turn.test.ts @@ -0,0 +1,559 @@ +/** + * E2E tests based on multi-turn.ts example + * Tests multi-turn conversation functionality with real CLI + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, + type SDKUserMessage, + type SDKAssistantMessage, + type TextBlock, + type ContentBlock, + type SDKMessage, + type ControlMessage, + type ToolUseBlock, +} from '@qwen-code/sdk'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +/** + * Determine the message type using protocol type guards + */ +function getMessageType(message: SDKMessage | ControlMessage): string { + if (isSDKUserMessage(message)) { + return '๐Ÿง‘ USER'; + } else if (isSDKAssistantMessage(message)) { + return '๐Ÿค– ASSISTANT'; + } else if (isSDKSystemMessage(message)) { + return `๐Ÿ–ฅ๏ธ SYSTEM(${message.subtype})`; + } else if (isSDKResultMessage(message)) { + return `โœ… RESULT(${message.subtype})`; + } else if (isSDKPartialAssistantMessage(message)) { + return 'โณ STREAM_EVENT'; + } else if (isControlRequest(message)) { + return `๐ŸŽฎ CONTROL_REQUEST(${message.request.subtype})`; + } else if (isControlResponse(message)) { + return `๐Ÿ“ญ CONTROL_RESPONSE(${message.response.subtype})`; + } else if (isControlCancel(message)) { + return '๐Ÿ›‘ CONTROL_CANCEL'; + } else { + return 'โ“ UNKNOWN'; + } +} + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +describe('Multi-Turn Conversations (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('multi-turn'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('AsyncIterable Prompt Support', () => { + it('should handle multi-turn conversation using AsyncIterable prompt', async () => { + // Create multi-turn conversation generator + async function* createMultiTurnConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 1 + 1?', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 2 + 2?', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 3 + 3?', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + + // Create multi-turn query using AsyncIterable prompt + const q = query({ + prompt: createMultiTurnConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + const assistantMessages: SDKAssistantMessage[] = []; + const assistantTexts: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantMessages.push(message); + const text = extractText(message.message.content); + assistantTexts.push(text); + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(3); + + // Validate content of responses + expect(assistantTexts[0]).toMatch(/2/); + expect(assistantTexts[1]).toMatch(/4/); + expect(assistantTexts[2]).toMatch(/6/); + } finally { + await q.close(); + } + }); + + it('should maintain session context across turns', async () => { + async function* createContextualConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: + 'Suppose we have 3 rabbits and 4 carrots. How many animals are there?', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'How many animals are there? Only output the number', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + + const q = query({ + prompt: createContextualConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const assistantMessages: SDKAssistantMessage[] = []; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + assistantMessages.push(message); + } + } + + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + + // The second response should reference the color blue + const secondResponse = extractText( + assistantMessages[1].message.content, + ); + expect(secondResponse.toLowerCase()).toContain('3'); + } finally { + await q.close(); + } + }); + }); + + describe('Tool Usage in Multi-Turn', () => { + it('should handle tool usage across multiple turns', async () => { + async function* createToolConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Create a file named test.txt with content "hello"', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Now read the test.txt file', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + + const q = query({ + prompt: createToolConversation(), + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let toolUseCount = 0; + const assistantMessages: SDKAssistantMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantMessages.push(message); + const hasToolUseBlock = message.message.content.some( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (hasToolUseBlock) { + toolUseCount++; + } + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(toolUseCount).toBeGreaterThan(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + + // Validate second response mentions the file content + const secondResponse = extractText( + assistantMessages[assistantMessages.length - 1].message.content, + ); + expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/); + } finally { + await q.close(); + } + }); + }); + + describe('Message Flow and Sequencing', () => { + it('should process messages in correct sequence', async () => { + async function* createSequentialConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'First question: What is 1 + 1?', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Second question: What is 2 + 2?', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + + const q = query({ + prompt: createSequentialConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messageSequence: string[] = []; + const assistantResponses: string[] = []; + + try { + for await (const message of q) { + const messageType = getMessageType(message); + messageSequence.push(messageType); + + if (isSDKAssistantMessage(message)) { + const text = extractText(message.message.content); + assistantResponses.push(text); + } + } + + expect(messageSequence.length).toBeGreaterThan(0); + expect(assistantResponses.length).toBeGreaterThanOrEqual(2); + + // Should end with result + expect(messageSequence[messageSequence.length - 1]).toContain('RESULT'); + + // Should have assistant responses + expect(messageSequence.some((type) => type.includes('ASSISTANT'))).toBe( + true, + ); + } finally { + await q.close(); + } + }); + + it('should handle conversation completion correctly', async () => { + async function* createSimpleConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Hello', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Goodbye', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + + const q = query({ + prompt: createSimpleConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let completedNaturally = false; + let messageCount = 0; + + try { + for await (const message of q) { + messageCount++; + + if (isSDKResultMessage(message)) { + completedNaturally = true; + expect(message.subtype).toBe('success'); + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling in Multi-Turn', () => { + it('should handle empty conversation gracefully', async () => { + async function* createEmptyConversation(): AsyncIterable { + // Generator that yields nothing + /* eslint-disable no-constant-condition */ + if (false) { + yield {} as SDKUserMessage; // Unreachable, but satisfies TypeScript + } + } + + const q = query({ + prompt: createEmptyConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should handle empty conversation without crashing + expect(true).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle conversation with delays', async () => { + async function* createDelayedConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'First message', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + // Longer delay to test patience + await new Promise((resolve) => setTimeout(resolve, 500)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Second message after delay', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + + const q = query({ + prompt: createDelayedConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const assistantMessages: SDKAssistantMessage[] = []; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + assistantMessages.push(message); + } + } + + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + }); + + describe('Partial Messages in Multi-Turn', () => { + it('should receive partial messages when includePartialMessages is enabled', async () => { + async function* createMultiTurnConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 1 + 1?', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 2 + 2?', + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + + const q = query({ + prompt: createMultiTurnConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + includePartialMessages: true, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let partialMessageCount = 0; + let assistantMessageCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKPartialAssistantMessage(message)) { + partialMessageCount++; + } + + if (isSDKAssistantMessage(message)) { + assistantMessageCount++; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(partialMessageCount).toBeGreaterThan(0); + expect(assistantMessageCount).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts new file mode 100644 index 00000000..e8d201e6 --- /dev/null +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -0,0 +1,1249 @@ +/** + * E2E tests for permission control features: + * - canUseTool callback parameter + * - setPermissionMode API + */ + +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + afterEach, +} from 'vitest'; +import { + query, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKUserMessage, + type SDKMessage, + type SDKUserMessage, + type ToolUseBlock, + type ContentBlock, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + createSharedTestOptions, + hasAnyToolResults, + hasSuccessfulToolResults, + hasErrorToolResults, + findSystemMessage, + findToolCalls, +} from './test-helper.js'; + +const TEST_TIMEOUT = 30000; +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +/** + * Factory function that creates a streaming input with a control point. + * After the first message is yielded, the generator waits for a resume signal, + * allowing the test code to call query instance methods like setPermissionMode. + */ +function createStreamingInputWithControlPoint( + firstMessage: string, + secondMessage: string, +): { + generator: AsyncIterable; + resume: () => void; +} { + let resumeResolve: (() => void) | null = null; + const resumePromise = new Promise((resolve) => { + resumeResolve = resolve; + }); + + const generator = (async function* () { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: firstMessage, + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + await resumePromise; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: secondMessage, + }, + parent_tool_use_id: null, + } as SDKUserMessage; + })(); + + const resume = () => { + if (resumeResolve) { + resumeResolve(); + } + }; + + return { generator, resume }; +} + +describe('Permission Control (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeAll(() => { + //process.env['DEBUG'] = '1'; + }); + + afterAll(() => { + delete process.env['DEBUG']; + }); + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('permission-control'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('canUseTool callback parameter', () => { + it('should invoke canUseTool callback when tool is requested', async () => { + const toolCalls: Array<{ + toolName: string; + input: Record; + }> = []; + + const q = query({ + prompt: 'Write a js hello world to file.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + canUseTool: async (toolName, input) => { + toolCalls.push({ toolName, input }); + return { + behavior: 'deny', + message: 'Tool execution denied by user.', + }; + }, + }, + }); + + try { + let hasToolUse = false; + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock) { + hasToolUse = true; + } + } + } + + expect(hasToolUse).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); + expect(toolCalls[0].toolName).toBeDefined(); + expect(toolCalls[0].input).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should allow tool execution when canUseTool returns allow', async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named hello.txt with content "world"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasToolResult = false; + for await (const message of q) { + if (isSDKUserMessage(message)) { + if ( + Array.isArray(message.message.content) && + message.message.content.some( + (block) => block.type === 'tool_result', + ) + ) { + hasToolResult = true; + } + } + } + + expect(callbackInvoked).toBe(true); + expect(hasToolResult).toBe(true); + } finally { + await q.close(); + } + }); + + it('should deny tool execution when canUseTool returns deny', async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test.txt', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + canUseTool: async () => { + callbackInvoked = true; + return { + behavior: 'deny', + message: 'Tool execution denied by test', + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(callbackInvoked).toBe(true); + // Tool use might still appear, but execution should be denied + // The exact behavior depends on CLI implementation + } finally { + await q.close(); + } + }); + + it('should pass suggestions to canUseTool callback', async () => { + let receivedSuggestions: unknown = null; + + const q = query({ + prompt: 'Create a file named data.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input, options) => { + receivedSuggestions = options?.suggestions; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // Suggestions may be null or an array, depending on CLI implementation + expect(receivedSuggestions !== undefined).toBe(true); + } finally { + await q.close(); + } + }); + + it('should pass abort signal to canUseTool callback', async () => { + let receivedSignal: AbortSignal | undefined = undefined; + + const q = query({ + prompt: 'Create a file named signal.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input, options) => { + receivedSignal = options?.signal; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(receivedSignal).toBeDefined(); + expect(receivedSignal).toBeInstanceOf(AbortSignal); + } finally { + await q.close(); + } + }); + + it('should default to deny when canUseTool is not provided', async () => { + const q = query({ + prompt: 'Create a file named default.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + // canUseTool not provided + }, + }); + + try { + // When canUseTool is not provided, tools should be denied by default + // The exact behavior depends on CLI implementation + for await (const _message of q) { + // Consume all messages + } + // Test passes if no errors occur + expect(true).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('setPermissionMode API', () => { + it('should change permission mode from default to yolo', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 1 + 1?', + 'What is 2 + 2?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + debug: true, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 40000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('yolo'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 40000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should change permission mode from yolo to plan', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 3 + 3?', + 'What is 4 + 4?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('plan'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should change permission mode to auto-edit', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 5 + 5?', + 'What is 6 + 6?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 15000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('auto-edit'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should throw error when setPermissionMode is called on closed query', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + timeout: { + /** + * We use a short control request timeout and + * wait till the time exceeded to test if + * an immediate close() will raise an query close + * error and no other uncaught timeout error + */ + controlRequest: 5000, + }, + }, + }); + + await q.close(); + + await expect(q.setPermissionMode('yolo')).rejects.toThrow( + 'Query is closed', + ); + + await new Promise((resolve) => setTimeout(resolve, 8000)); + }, 10_000); + }); + + describe('canUseTool and setPermissionMode integration', () => { + it('should work together - canUseTool callback with dynamic permission mode change', async () => { + const toolCalls: Array<{ + toolName: string; + input: Record; + }> = []; + + const { generator, resume } = createStreamingInputWithControlPoint( + 'Create a file named first.txt', + 'Create a file named second.txt', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input) => { + toolCalls.push({ toolName, input }); + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isSDKResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + TEST_TIMEOUT, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); + + await q.setPermissionMode('yolo'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + TEST_TIMEOUT, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('ApprovalMode behavior tests', () => { + describe('default mode', () => { + it( + 'should auto-deny tools requiring confirmation without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-default-deny.txt with content "hello"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + // No canUseTool callback provided + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // In default mode without canUseTool, tools should be denied + expect(hasAnyToolResults(messages)).toBe(true); + expect(hasErrorToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow tools when canUseTool returns allow', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: + 'Create a file named test-default-allow.txt with content "world"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(callbackInvoked).toBe(true); + expect(hasSuccessfulToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute read-only tools without confirmation', + async () => { + const q = query({ + prompt: 'List files in the current directory', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + // No canUseTool callback - read-only tools should still work + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasAnyToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('yolo mode', () => { + it( + 'should auto-approve all tools without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-yolo.txt with content "yolo mode"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: testDir, + // No canUseTool callback - tools should still execute + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasSuccessfulToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not invoke canUseTool callback in yolo mode', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-yolo-no-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasAnyToolResults(messages)).toBe(true); + // canUseTool should not be invoked in yolo mode + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute dangerous commands without confirmation', + async () => { + const q = query({ + prompt: 'Run command: echo "dangerous operation"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasAnyToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('plan mode', () => { + // Write tools that should never be called in plan mode + const WRITE_TOOLS = [ + 'edit', + 'write_file', + 'run_shell_command', + 'delete_file', + 'move_file', + ]; + + // Read tools that should be allowed in plan mode + const READ_TOOLS = [ + 'read_file', + 'read_many_files', + 'grep_search', + 'glob', + 'list_directory', + 'web_search', + 'web_fetch', + ]; + + it( + 'should have permission_mode set to plan in system message', + async () => { + const q = query({ + prompt: 'List files in the current directory', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Find the init system message + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.permission_mode).toBe('plan'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not call any write tools in plan mode', + async () => { + // Create a test file so the model has something to reference + await helper.createFile( + 'test-plan-file.txt', + 'This is test content for plan mode verification.', + ); + + const q = query({ + prompt: + 'Read the file test-plan-file.txt and suggest how to improve its content.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Find all tool calls and verify none are write tools + const allToolCalls = findToolCalls(messages); + const writeToolCalls = allToolCalls.filter((tc) => + WRITE_TOOLS.includes(tc.toolUse.name), + ); + + // No write tools should be called in plan mode + expect(writeToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow read-only tools without restrictions', + async () => { + // Create test files for the model to read + await helper.createFile('test-read-1.txt', 'Content of file 1'); + await helper.createFile('test-read-2.txt', 'Content of file 2'); + + const q = query({ + prompt: + 'Read the contents of test-read-1.txt and test-read-2.txt files, then list files in the current directory.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Find all tool calls + const allToolCalls = findToolCalls(messages); + + // Verify read tools were called (at least one) + const readToolCalls = allToolCalls.filter((tc) => + READ_TOOLS.includes(tc.toolUse.name), + ); + expect(readToolCalls.length).toBeGreaterThan(0); + + // Verify tool results are successful (not blocked) + expect(hasSuccessfulToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not invoke canUseTool callback in plan mode since no permission approval is expected', + async () => { + let callbackInvoked = false; + + // Create a test file for reading + await helper.createFile( + 'test-plan-callback.txt', + 'Content for callback test', + ); + + const q = query({ + prompt: 'Read the file test-plan-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Read tools should work without invoking canUseTool + // In plan mode, no permission approval is expected from user + expect(hasSuccessfulToolResults(messages)).toBe(true); + + // canUseTool should not be invoked in plan mode + // since plan mode is for research only, no permission interaction needed + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should only output research and plan as text, no actual changes', + async () => { + // Create a test file + const originalContent = 'Original content for plan mode test'; + await helper.createFile('test-no-changes.txt', originalContent); + + const q = query({ + prompt: + 'Read test-no-changes.txt and plan how you would modify it to add a header. Do not actually make any changes.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Verify the file was not modified + const fileContent = await helper.readFile('test-no-changes.txt'); + expect(fileContent).toBe(originalContent); + + // Verify no write tools were called + const allToolCalls = findToolCalls(messages); + const writeToolCalls = allToolCalls.filter((tc) => + WRITE_TOOLS.includes(tc.toolUse.name), + ); + expect(writeToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('auto-edit mode', () => { + it( + 'should auto-approve write/edit tools without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-auto-edit.txt with content "auto-edit test"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + cwd: testDir, + // No canUseTool callback - write/edit tools should still execute + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // auto-edit mode should auto-approve write/edit tools + expect(hasSuccessfulToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not invoke canUseTool callback for write/edit tools', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-auto-edit-no-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // auto-edit mode should auto-approve write/edit tools without invoking callback + expect(hasSuccessfulToolResults(messages)).toBe(true); + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute read-only tools without confirmation', + async () => { + // Create a test file in the test directory for the model to read + await helper.createFile( + 'test-read-file.txt', + 'This is a test file for read-only tool verification.', + ); + + const q = query({ + prompt: 'Read the contents of test-read-file.txt file', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'auto-edit', + // No canUseTool callback - read-only tools should still work + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasAnyToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('mode comparison tests', () => { + it.skip( + 'should demonstrate different behaviors across all modes for write operations', + async () => { + const modes: Array<'default' | 'auto-edit' | 'yolo'> = [ + 'default', + 'auto-edit', + 'yolo', + ]; + const results: Record = {}; + + for (const mode of modes) { + const q = query({ + prompt: `Create a file named test-${mode}.txt`, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: mode, + cwd: testDir, + canUseTool: + mode === 'yolo' || mode === 'auto-edit' + ? undefined + : async (toolName, input) => { + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + results[mode] = hasSuccessfulToolResults(messages); + } finally { + await q.close(); + } + } + + // Verify expected behaviors + expect(results['default']).toBe(true); // Allowed via canUseTool + // expect(results['plan']).toBe(false); // Blocked by plan mode + expect(results['auto-edit']).toBe(true); // Auto-approved for write/edit tools + expect(results['yolo']).toBe(true); // Auto-approved for all tools + }, + TEST_TIMEOUT * 4, + ); + }); + }); +}); diff --git a/integration-tests/sdk-typescript/sdk-mcp-server.test.ts b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts new file mode 100644 index 00000000..1ce8658e --- /dev/null +++ b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts @@ -0,0 +1,456 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for SDK-embedded MCP servers + * + * Tests that the SDK can create and manage MCP servers running in the SDK process + * using the tool() and createSdkMcpServer() APIs. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { z } from 'zod'; +import { + query, + tool, + createSdkMcpServer, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKSystemMessage, + type SDKMessage, + type SDKSystemMessage, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + extractText, + findToolUseBlocks, + createSharedTestOptions, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = { + ...createSharedTestOptions(), + permissionMode: 'yolo' as const, +}; + +describe('SDK MCP Server Integration (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('sdk-mcp-server-integration'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('Basic SDK MCP Tool Usage', () => { + it('should use SDK MCP tool to perform a simple calculation', async () => { + // Define a simple calculator tool using the tool() API with Zod schema + const calculatorTool = tool( + 'calculate_sum', + 'Calculate the sum of two numbers', + z.object({ + a: z.number().describe('First number'), + b: z.number().describe('Second number'), + }).shape, + async (args) => ({ + content: [{ type: 'text', text: String(args.a + args.b) }], + }), + ); + + // Create SDK MCP server with the tool + const serverConfig = createSdkMcpServer({ + name: 'sdk-calculator', + version: '1.0.0', + tools: [calculatorTool], + }); + + const q = query({ + prompt: + 'Use the calculate_sum tool to add 25 and 17. Output the result of tool only.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + mcpServers: { + 'sdk-calculator': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'calculate_sum'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer: 25 + 17 = 42 + expect(assistantText).toMatch(/42/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + if (isSDKResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should use SDK MCP tool with string operations', async () => { + // Define a string manipulation tool with Zod schema + const stringTool = tool( + 'reverse_string', + 'Reverse a string', + { + text: z.string().describe('The text to reverse'), + }, + async (args) => ({ + content: [ + { type: 'text', text: args.text.split('').reverse().join('') }, + ], + }), + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-string-utils', + version: '1.0.0', + tools: [stringTool], + }); + + const q = query({ + prompt: `Use the 'reverse_string' tool to process the word "hello world". Output the tool result only.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + mcpServers: { + 'sdk-string-utils': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'reverse_string'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains reversed string: "olleh" + expect(assistantText.toLowerCase()).toMatch(/olleh/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Multiple SDK MCP Tools', () => { + it('should use multiple tools from the same SDK MCP server', async () => { + // Define the Zod schema shape for two numbers + const twoNumbersSchema = { + a: z.number().describe('First number'), + b: z.number().describe('Second number'), + }; + + // Define multiple tools + const addTool = tool( + 'sdk_add', + 'Add two numbers', + twoNumbersSchema, + async (args) => ({ + content: [{ type: 'text', text: String(args.a + args.b) }], + }), + ); + + const multiplyTool = tool( + 'sdk_multiply', + 'Multiply two numbers', + twoNumbersSchema, + async (args) => ({ + content: [{ type: 'text', text: String(args.a * args.b) }], + }), + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-math', + version: '1.0.0', + tools: [addTool, multiplyTool], + }); + + const q = query({ + prompt: + 'First use sdk_add to calculate 10 + 5, then use sdk_multiply to multiply the result by 3. Give me the final answer.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'sdk-math': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + const toolCalls: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); + toolUseBlocks.forEach((block) => { + toolCalls.push(block.name); + }); + assistantText += extractText(message.message.content); + } + } + + // Validate both tools were called + expect(toolCalls).toContain('sdk_add'); + expect(toolCalls).toContain('sdk_multiply'); + + // Validate result: (10 + 5) * 3 = 45 + expect(assistantText).toMatch(/45/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('SDK MCP Server Discovery', () => { + it('should list SDK MCP servers in system init message', async () => { + // Define echo tool with Zod schema + const echoTool = tool( + 'echo', + 'Echo a message', + { + message: z.string().describe('Message to echo'), + }, + async (args) => ({ + content: [{ type: 'text', text: args.message }], + }), + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-echo', + version: '1.0.0', + tools: [echoTool], + }); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'sdk-echo': serverConfig, + }, + }, + }); + + let systemMessage: SDKSystemMessage | null = null; + + try { + for await (const message of q) { + if (isSDKSystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + break; + } + } + + // Validate MCP server is listed + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + + // Find our SDK MCP server + const sdkServer = systemMessage!.mcp_servers?.find( + (server) => server.name === 'sdk-echo', + ); + expect(sdkServer).toBeDefined(); + } finally { + await q.close(); + } + }); + }); + + describe('SDK MCP Tool Error Handling', () => { + it('should handle tool errors gracefully', async () => { + // Define a tool that throws an error with Zod schema + const errorTool = tool( + 'maybe_fail', + 'A tool that may fail based on input', + { + shouldFail: z.boolean().describe('If true, the tool will fail'), + }, + async (args) => { + if (args.shouldFail) { + throw new Error('Tool intentionally failed'); + } + return { content: [{ type: 'text', text: 'Success!' }] }; + }, + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-error-test', + version: '1.0.0', + tools: [errorTool], + }); + + const q = query({ + prompt: + 'Use the maybe_fail tool with shouldFail set to true. Tell me what happens.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'sdk-error-test': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'maybe_fail'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + } + } + + // Tool should be called + expect(foundToolUse).toBe(true); + + // Query should complete (even with tool error) + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Async Tool Handlers', () => { + it('should handle async tool handlers with delays', async () => { + // Define a tool with async delay using Zod schema + const delayedTool = tool( + 'delayed_response', + 'Returns a value after a delay', + { + delay: z.number().describe('Delay in milliseconds (max 100)'), + value: z.string().describe('Value to return'), + }, + async (args) => { + // Cap delay at 100ms for test performance + const actualDelay = Math.min(args.delay, 100); + await new Promise((resolve) => setTimeout(resolve, actualDelay)); + return { + content: [{ type: 'text', text: `Delayed result: ${args.value}` }], + }; + }, + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-async', + version: '1.0.0', + tools: [delayedTool], + }); + + const q = query({ + prompt: + 'Use the delayed_response tool with delay=50 and value="test_async". Tell me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'sdk-async': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks( + message, + 'delayed_response', + ); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains the delayed response + expect(assistantText.toLowerCase()).toMatch(/test_async/i); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/single-turn.test.ts b/integration-tests/sdk-typescript/single-turn.test.ts new file mode 100644 index 00000000..3608e619 --- /dev/null +++ b/integration-tests/sdk-typescript/single-turn.test.ts @@ -0,0 +1,528 @@ +/** + * E2E tests for single-turn query execution + * Tests basic query patterns with simple prompts and clear output expectations + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + type SDKMessage, + type SDKSystemMessage, + type SDKAssistantMessage, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + extractText, + createSharedTestOptions, + assertSuccessfulCompletion, + collectMessagesByType, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('Single-Turn Query (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('single-turn'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('Simple Text Queries', () => { + it('should answer basic arithmetic question', async () => { + const q = query({ + prompt: 'What is 2 + 2? Just give me the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: true, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate we got messages + expect(messages.length).toBeGreaterThan(0); + + // Validate assistant response content + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText).toMatch(/4/); + + // Validate message flow ends with success + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should answer simple factual question', async () => { + const q = query({ + prompt: 'What is the capital of France? One word answer.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate content + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText.toLowerCase()).toContain('paris'); + + // Validate completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should handle greeting and self-description', async () => { + const q = query({ + prompt: 'Say hello and tell me your name in one sentence.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate content contains greeting + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/); + + // Validate message types + const assistantMessages = collectMessagesByType( + messages, + isSDKAssistantMessage, + ); + expect(assistantMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); + + describe('System Initialization', () => { + it('should receive system message with initialization info', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let systemMessage: SDKSystemMessage | null = null; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKSystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate system message exists and has required fields + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.type).toBe('system'); + expect(systemMessage!.subtype).toBe('init'); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.cwd).toBeDefined(); + expect(systemMessage!.tools).toBeDefined(); + expect(Array.isArray(systemMessage!.tools)).toBe(true); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + expect(systemMessage!.model).toBeDefined(); + expect(systemMessage!.permission_mode).toBeDefined(); + expect(systemMessage!.qwen_code_version).toBeDefined(); + + // Validate system message appears early in sequence + const systemMessageIndex = messages.findIndex( + (msg) => isSDKSystemMessage(msg) && msg.subtype === 'init', + ); + expect(systemMessageIndex).toBeGreaterThanOrEqual(0); + expect(systemMessageIndex).toBeLessThan(3); + } finally { + await q.close(); + } + }); + + it('should maintain session ID consistency', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let systemMessage: SDKSystemMessage | null = null; + const sessionId = q.getSessionId(); + + try { + for await (const message of q) { + if (isSDKSystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate session IDs are consistent + expect(sessionId).toBeDefined(); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + } finally { + await q.close(); + } + }); + }); + + describe('Message Flow', () => { + it('should follow expected message sequence', async () => { + const q = query({ + prompt: 'Say hi', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messageTypes: string[] = []; + + try { + for await (const message of q) { + messageTypes.push(message.type); + } + + // Validate message sequence + expect(messageTypes.length).toBeGreaterThan(0); + expect(messageTypes).toContain('assistant'); + expect(messageTypes[messageTypes.length - 1]).toBe('result'); + } finally { + await q.close(); + } + }); + + it('should complete iteration naturally', async () => { + const q = query({ + prompt: 'Say goodbye', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let completedNaturally = false; + let messageCount = 0; + + try { + for await (const message of q) { + messageCount++; + + if (isSDKResultMessage(message)) { + completedNaturally = true; + expect(message.subtype).toBe('success'); + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Configuration Options', () => { + it('should respect debug option and capture stderr', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // Debug mode should produce stderr output + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should respect cwd option', async () => { + const q = query({ + prompt: 'What is 1 + 1?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let hasResponse = false; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + hasResponse = true; + } + } + + expect(hasResponse).toBe(true); + } finally { + await q.close(); + } + }); + + it('should receive partial messages when includePartialMessages is enabled', async () => { + const q = query({ + prompt: 'Count from 1 to 5', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + includePartialMessages: true, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let partialMessageCount = 0; + let assistantMessageCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKPartialAssistantMessage(message)) { + partialMessageCount++; + } + + if (isSDKAssistantMessage(message)) { + assistantMessageCount++; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(partialMessageCount).toBeGreaterThan(0); + expect(assistantMessageCount).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); + + describe('Message Type Recognition', () => { + it('should correctly identify all message types', async () => { + const q = query({ + prompt: 'What is 5 + 5?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate type guards work correctly + const assistantMessages = collectMessagesByType( + messages, + isSDKAssistantMessage, + ); + const resultMessages = collectMessagesByType( + messages, + isSDKResultMessage, + ); + const systemMessages = collectMessagesByType( + messages, + isSDKSystemMessage, + ); + + expect(assistantMessages.length).toBeGreaterThan(0); + expect(resultMessages.length).toBeGreaterThan(0); + expect(systemMessages.length).toBeGreaterThan(0); + + // Validate assistant message structure + const firstAssistant = assistantMessages[0]; + expect(firstAssistant.message.content).toBeDefined(); + expect(Array.isArray(firstAssistant.message.content)).toBe(true); + + // Validate result message structure + const resultMessage = resultMessages[0]; + expect(resultMessage.subtype).toBe('success'); + } finally { + await q.close(); + } + }); + + it('should extract text content from assistant messages', async () => { + const q = query({ + prompt: 'Count from 1 to 3', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let assistantMessage: SDKAssistantMessage | null = null; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + assistantMessage = message; + } + } + + expect(assistantMessage).not.toBeNull(); + expect(assistantMessage!.message.content).toBeDefined(); + + // Validate content contains expected numbers + const text = extractText(assistantMessage!.message.content); + expect(text.length).toBeGreaterThan(0); + expect(text).toMatch(/1/); + expect(text).toMatch(/2/); + expect(text).toMatch(/3/); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling', () => { + it('should throw if CLI not found', async () => { + try { + const q = query({ + prompt: 'Hello', + options: { + pathToQwenExecutable: '/nonexistent/path/to/cli', + debug: false, + }, + }); + + for await (const _message of q) { + // Should not reach here + } + + expect(false).toBe(true); // Should have thrown + } catch (error) { + expect(error).toBeDefined(); + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toContain( + 'Invalid pathToQwenExecutable', + ); + } + }); + }); + + describe('Resource Management', () => { + it('should cleanup subprocess on close()', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + // Start and immediately close + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Should close without error + await q.close(); + expect(true).toBe(true); // Cleanup completed + }); + + it('should handle close() called multiple times', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + // Start the query + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Close multiple times + await q.close(); + await q.close(); + await q.close(); + + // Should not throw + expect(true).toBe(true); + }); + }); +}); diff --git a/integration-tests/sdk-typescript/subagents.test.ts b/integration-tests/sdk-typescript/subagents.test.ts new file mode 100644 index 00000000..c327c96e --- /dev/null +++ b/integration-tests/sdk-typescript/subagents.test.ts @@ -0,0 +1,614 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for subagent configuration and execution + * Tests subagent delegation and task completion + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKAssistantMessage, + type SDKMessage, + type SubagentConfig, + type ContentBlock, + type ToolUseBlock, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + extractText, + createSharedTestOptions, + findToolUseBlocks, + assertSuccessfulCompletion, + findSystemMessage, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('Subagents (E2E)', () => { + let helper: SDKTestHelper; + let testWorkDir: string; + + beforeEach(async () => { + // Create isolated test environment using SDKTestHelper + helper = new SDKTestHelper(); + testWorkDir = await helper.setup('subagent-tests'); + + // Create a simple test file for subagent to work with + await helper.createFile('test.txt', 'Hello from test file\n'); + }); + + afterEach(async () => { + // Cleanup test directory + await helper.cleanup(); + }); + + describe('Subagent Configuration', () => { + it('should accept session-level subagent configuration', async () => { + const simpleSubagent: SubagentConfig = { + name: 'simple-greeter', + description: 'A simple subagent that responds to greetings', + systemPrompt: + 'You are a friendly greeter. When given a task, respond with a cheerful greeting.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello, let simple-greeter to say hi back to me.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleSubagent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate system message includes the subagent + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('simple-greeter'); + + // Validate successful completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should accept multiple subagent configurations', async () => { + const greeterAgent: SubagentConfig = { + name: 'greeter', + description: 'Responds to greetings', + systemPrompt: 'You are a friendly greeter.', + level: 'session', + }; + + const mathAgent: SubagentConfig = { + name: 'math-helper', + description: 'Helps with math problems', + systemPrompt: 'You are a math expert. Solve math problems clearly.', + level: 'session', + }; + + const q = query({ + prompt: 'What is 5 + 5?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [greeterAgent, mathAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate both subagents are registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('greeter'); + expect(systemMessage!.agents).toContain('math-helper'); + expect(systemMessage!.agents!.length).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + + it('should handle subagent with custom model config', async () => { + const customModelAgent: SubagentConfig = { + name: 'custom-model-agent', + description: 'Agent with custom model configuration', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + modelConfig: { + temp: 0.7, + top_p: 0.9, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [customModelAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('custom-model-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with run config', async () => { + const limitedAgent: SubagentConfig = { + name: 'limited-agent', + description: 'Agent with execution limits', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + runConfig: { + max_turns: 5, + max_time_minutes: 1, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [limitedAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('limited-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with specific tools', async () => { + const toolRestrictedAgent: SubagentConfig = { + name: 'read-only-agent', + description: 'Agent that can only read files', + systemPrompt: + 'You are a file reading assistant. Read files when asked.', + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [toolRestrictedAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('read-only-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Execution', () => { + it('should delegate task to subagent when appropriate', async () => { + const fileReaderAgent: SubagentConfig = { + name: 'file-reader', + description: 'Reads and reports file contents', + systemPrompt: `You are a file reading assistant. When given a task to read a file, use the read_file tool to read it and report its contents back. Be concise in your response.`, + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const testFile = helper.getPath('test.txt'); + const q = query({ + prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [fileReaderAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: SDKMessage[] = []; + let foundTaskTool = false; + let taskToolUseId: string | null = null; + let foundSubagentToolCall = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + // Check for task tool use in content blocks (main agent calling subagent) + const taskToolBlocks = findToolUseBlocks(message, 'task'); + if (taskToolBlocks.length > 0) { + foundTaskTool = true; + taskToolUseId = taskToolBlocks[0].id; + } + + // Check if this message is from a subagent (has parent_tool_use_id) + if (message.parent_tool_use_id !== null) { + // This is a subagent message + const subagentToolBlocks = findToolUseBlocks(message); + if (subagentToolBlocks.length > 0) { + foundSubagentToolCall = true; + // Verify parent_tool_use_id matches the task tool use id + expect(message.parent_tool_use_id).toBe(taskToolUseId); + } + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent delegation) + expect(foundTaskTool).toBe(true); + expect(taskToolUseId).not.toBeNull(); + + // Validate subagent actually made tool calls with proper parent_tool_use_id + expect(foundSubagentToolCall).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }, 60000); // Increase timeout for subagent execution + + it('should complete simple task with subagent', async () => { + const simpleTaskAgent: SubagentConfig = { + name: 'simple-calculator', + description: 'Performs simple arithmetic calculations', + systemPrompt: + 'You are a calculator. When given a math problem, solve it and provide just the answer.', + level: 'session', + }; + + const q = query({ + prompt: 'Use the simple-calculator subagent to calculate 15 + 27.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleTaskAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: SDKMessage[] = []; + let foundTaskTool = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + // Check for task tool use (main agent delegating to subagent) + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use' && block.name === 'task', + ); + if (toolUseBlock) { + foundTaskTool = true; + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent was called) + expect(foundTaskTool).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }, 60000); + + it('should verify subagent execution with comprehensive parent_tool_use_id checks', async () => { + const comprehensiveAgent: SubagentConfig = { + name: 'comprehensive-agent', + description: 'Agent for comprehensive testing', + systemPrompt: + 'You are a helpful assistant. When asked to list files, use the list_directory tool.', + level: 'session', + tools: ['list_directory', 'read_file'], + }; + + const q = query({ + prompt: `Use the comprehensive-agent subagent to list the files in ${testWorkDir}.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [comprehensiveAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: SDKMessage[] = []; + let taskToolUseId: string | null = null; + const subagentToolCalls: ToolUseBlock[] = []; + const mainAgentToolCalls: ToolUseBlock[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + // Collect all tool use blocks + const toolUseBlocks = message.message.content.filter( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + + for (const toolUse of toolUseBlocks) { + if (toolUse.name === 'task') { + // This is the main agent calling the subagent + taskToolUseId = toolUse.id; + mainAgentToolCalls.push(toolUse); + } + + // If this message has parent_tool_use_id, it's from a subagent + if (message.parent_tool_use_id !== null) { + subagentToolCalls.push(toolUse); + } + } + } + } + + // Criterion 1: When a subagent is called, there must be a 'task' tool being called + expect(taskToolUseId).not.toBeNull(); + expect(mainAgentToolCalls.length).toBeGreaterThan(0); + expect(mainAgentToolCalls.some((tc) => tc.name === 'task')).toBe(true); + + // Criterion 2: A tool call from a subagent is identified by a non-null parent_tool_use_id + // All subagent tool calls should have parent_tool_use_id set to the task tool's id + expect(subagentToolCalls.length).toBeGreaterThan(0); + + // Verify all subagent messages have the correct parent_tool_use_id + const subagentMessages = messages.filter( + (msg): msg is SDKMessage & { parent_tool_use_id: string } => + isSDKAssistantMessage(msg) && msg.parent_tool_use_id !== null, + ); + + expect(subagentMessages.length).toBeGreaterThan(0); + for (const subagentMsg of subagentMessages) { + expect(subagentMsg.parent_tool_use_id).toBe(taskToolUseId); + } + + // Verify no main agent tool calls (except task) have parent_tool_use_id + const mainAgentMessages = messages.filter( + (msg): msg is SDKMessage => + isSDKAssistantMessage(msg) && msg.parent_tool_use_id === null, + ); + + for (const mainMsg of mainAgentMessages) { + if (isSDKAssistantMessage(mainMsg)) { + // Main agent messages should not have parent_tool_use_id + expect(mainMsg.parent_tool_use_id).toBeNull(); + } + } + + // Validate successful completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }, 60000); + }); + + describe('Subagent Error Handling', () => { + it('should handle empty subagent array', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should still work with empty agents array + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should handle subagent with minimal configuration', async () => { + const minimalAgent: SubagentConfig = { + name: 'minimal-agent', + description: 'Minimal configuration agent', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [minimalAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate minimal agent is registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('minimal-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Integration', () => { + it('should work with other SDK options', async () => { + const testAgent: SubagentConfig = { + name: 'test-agent', + description: 'Test agent for integration', + systemPrompt: 'You are a test assistant.', + level: 'session', + }; + + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [testAgent], + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + permissionMode: 'default', + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate subagent works with debug mode + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('test-agent'); + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should maintain session consistency with subagents', async () => { + const sessionAgent: SubagentConfig = { + name: 'session-agent', + description: 'Agent for session testing', + systemPrompt: 'You are a session test assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [sessionAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate session consistency + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + expect(systemMessage!.agents).toContain('session-agent'); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/system-control.test.ts b/integration-tests/sdk-typescript/system-control.test.ts new file mode 100644 index 00000000..0b0a74d3 --- /dev/null +++ b/integration-tests/sdk-typescript/system-control.test.ts @@ -0,0 +1,317 @@ +/** + * E2E tests for system controller features: + * - setModel API for dynamic model switching + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKAssistantMessage, + isSDKSystemMessage, + type SDKUserMessage, +} from '@qwen-code/sdk'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +/** + * Factory function that creates a streaming input with a control point. + * After the first message is yielded, the generator waits for a resume signal, + * allowing the test code to call query instance methods like setModel. + * + * @param firstMessage - The first user message to send + * @param secondMessage - The second user message to send after control operations + * @returns Object containing the async generator and a resume function + */ +function createStreamingInputWithControlPoint( + firstMessage: string, + secondMessage: string, +): { + generator: AsyncIterable; + resume: () => void; +} { + let resumeResolve: (() => void) | null = null; + const resumePromise = new Promise((resolve) => { + resumeResolve = resolve; + }); + + const generator = (async function* () { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: firstMessage, + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + await resumePromise; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: secondMessage, + }, + parent_tool_use_id: null, + } as SDKUserMessage; + })(); + + const resume = () => { + if (resumeResolve) { + resumeResolve(); + } + }; + + return { generator, resume }; +} + +describe('System Control (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('system-control'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('setModel API', () => { + it('should change model dynamically during streaming input', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'Tell me the model name.', + 'Tell me the model name now again.', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + model: 'qwen3-max', + debug: false, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + const systemMessages: Array<{ model?: string }> = []; + + // Consume messages in a single loop + (async () => { + for await (const message of q) { + if (isSDKSystemMessage(message)) { + systemMessages.push({ model: message.model }); + } + if (isSDKAssistantMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + // Wait for first response + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 15000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + // Perform control operation: set model + await q.setModel('qwen3-vl-plus'); + + // Resume the input stream + resume(); + + // Wait for second response + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + + // Verify system messages - model should change from qwen3-max to qwen3-vl-plus + expect(systemMessages.length).toBeGreaterThanOrEqual(2); + expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']); + expect(systemMessages[1].model).toBe('qwen3-vl-plus'); + } finally { + await q.close(); + } + }); + + it('should handle multiple model changes in sequence', async () => { + const sessionId = crypto.randomUUID(); + let resumeResolve1: (() => void) | null = null; + let resumeResolve2: (() => void) | null = null; + const resumePromise1 = new Promise((resolve) => { + resumeResolve1 = resolve; + }); + const resumePromise2 = new Promise((resolve) => { + resumeResolve2 = resolve; + }); + + const generator = (async function* () { + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'First message' }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + await resumePromise1; + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'Second message' }, + parent_tool_use_id: null, + } as SDKUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + await resumePromise2; + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'Third message' }, + parent_tool_use_id: null, + } as SDKUserMessage; + })(); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + model: 'qwen3-max', + debug: false, + }, + }); + + try { + const systemMessages: Array<{ model?: string }> = []; + let responseCount = 0; + const resolvers: Array<() => void> = []; + const responsePromises = [ + new Promise((resolve) => resolvers.push(resolve)), + new Promise((resolve) => resolvers.push(resolve)), + new Promise((resolve) => resolvers.push(resolve)), + ]; + + (async () => { + for await (const message of q) { + if (isSDKSystemMessage(message)) { + systemMessages.push({ model: message.model }); + } + if (isSDKAssistantMessage(message)) { + if (responseCount < resolvers.length) { + resolvers[responseCount]?.(); + responseCount++; + } + } + } + })(); + + // Wait for first response + await Promise.race([ + responsePromises[0], + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout 1')), 10000), + ), + ]); + + // First model change + await q.setModel('qwen3-turbo'); + resumeResolve1!(); + + // Wait for second response + await Promise.race([ + responsePromises[1], + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout 2')), 10000), + ), + ]); + + // Second model change + await q.setModel('qwen3-vl-plus'); + resumeResolve2!(); + + // Wait for third response + await Promise.race([ + responsePromises[2], + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout 3')), 10000), + ), + ]); + + // Verify we received system messages for each model + expect(systemMessages.length).toBeGreaterThanOrEqual(3); + expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']); + expect(systemMessages[1].model).toBe('qwen3-turbo'); + expect(systemMessages[2].model).toBe('qwen3-vl-plus'); + } finally { + await q.close(); + } + }); + + it('should throw error when setModel is called on closed query', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + model: 'qwen3-max', + }, + }); + + await q.close(); + + await expect(q.setModel('qwen3-turbo')).rejects.toThrow( + 'Query is closed', + ); + }); + }); +}); diff --git a/integration-tests/sdk-typescript/test-helper.ts b/integration-tests/sdk-typescript/test-helper.ts new file mode 100644 index 00000000..d7efc026 --- /dev/null +++ b/integration-tests/sdk-typescript/test-helper.ts @@ -0,0 +1,981 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SDK E2E Test Helper + * Provides utilities for SDK e2e tests including test isolation, + * file management, MCP server setup, and common test utilities. + */ + +import { mkdir, writeFile, readFile, rm, chmod } from 'node:fs/promises'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import type { + SDKMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKUserMessage, + ContentBlock, + TextBlock, + ToolUseBlock, +} from '@qwen-code/sdk'; +import { + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, +} from '@qwen-code/sdk'; + +// ============================================================================ +// Core Test Helper Class +// ============================================================================ + +export interface SDKTestHelperOptions { + /** + * Optional settings for .qwen/settings.json + */ + settings?: Record; + /** + * Whether to create .qwen/settings.json + */ + createQwenConfig?: boolean; +} + +/** + * Helper class for SDK E2E tests + * Provides isolated test environments for each test case + */ +export class SDKTestHelper { + testDir: string | null = null; + testName?: string; + private baseDir: string; + + constructor() { + this.baseDir = process.env['E2E_TEST_FILE_DIR']!; + if (!this.baseDir) { + throw new Error('E2E_TEST_FILE_DIR environment variable not set'); + } + } + + /** + * Setup an isolated test directory for a specific test + */ + async setup( + testName: string, + options: SDKTestHelperOptions = {}, + ): Promise { + this.testName = testName; + const sanitizedName = this.sanitizeTestName(testName); + this.testDir = join(this.baseDir, sanitizedName); + + await mkdir(this.testDir, { recursive: true }); + + // Optionally create .qwen/settings.json for CLI configuration + if (options.createQwenConfig !== false) { + const qwenDir = join(this.testDir, '.qwen'); + await mkdir(qwenDir, { recursive: true }); + + const optionsSettings = options.settings ?? {}; + const generalSettings = + typeof optionsSettings['general'] === 'object' && + optionsSettings['general'] !== null + ? (optionsSettings['general'] as Record) + : {}; + + const settings = { + ...optionsSettings, + telemetry: { + enabled: false, // SDK tests don't need telemetry + }, + general: { + ...generalSettings, + chatRecording: false, // SDK tests don't need chat recording + }, + }; + + await writeFile( + join(qwenDir, 'settings.json'), + JSON.stringify(settings, null, 2), + 'utf-8', + ); + } + + return this.testDir; + } + + /** + * Create a file in the test directory + */ + async createFile(fileName: string, content: string): Promise { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const filePath = join(this.testDir, fileName); + await writeFile(filePath, content, 'utf-8'); + return filePath; + } + + /** + * Read a file from the test directory + */ + async readFile(fileName: string): Promise { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const filePath = join(this.testDir, fileName); + return await readFile(filePath, 'utf-8'); + } + + /** + * Create a subdirectory in the test directory + */ + async mkdir(dirName: string): Promise { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const dirPath = join(this.testDir, dirName); + await mkdir(dirPath, { recursive: true }); + return dirPath; + } + + /** + * Check if a file exists in the test directory + */ + fileExists(fileName: string): boolean { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const filePath = join(this.testDir, fileName); + return existsSync(filePath); + } + + /** + * Get the full path to a file in the test directory + */ + getPath(fileName: string): string { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + return join(this.testDir, fileName); + } + + /** + * Cleanup test directory + */ + async cleanup(): Promise { + if (this.testDir && process.env['KEEP_OUTPUT'] !== 'true') { + try { + await rm(this.testDir, { recursive: true, force: true }); + } catch (error) { + if (process.env['VERBOSE'] === 'true') { + console.warn('Cleanup warning:', (error as Error).message); + } + } + } + } + + /** + * Sanitize test name to create valid directory name + */ + private sanitizeTestName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .substring(0, 100); // Limit length + } +} + +// ============================================================================ +// MCP Server Utilities +// ============================================================================ + +export interface MCPServerConfig { + command: string; + args: string[]; +} + +export interface MCPServerResult { + scriptPath: string; + config: MCPServerConfig; +} + +/** + * Built-in MCP server template: Math server with add and multiply tools + */ +const MCP_MATH_SERVER_SCRIPT = `#!/usr/bin/env node +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const readline = require('readline'); +const fs = require('fs'); + +// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) +const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true'; +function debug(msg) { + if (debugEnabled) { + fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); + } +} + +debug('MCP server starting...'); + +// Simple JSON-RPC implementation for MCP +class SimpleJSONRPC { + constructor() { + this.handlers = new Map(); + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + this.rl.on('line', (line) => { + debug(\`Received line: \${line}\`); + try { + const message = JSON.parse(line); + debug(\`Parsed message: \${JSON.stringify(message)}\`); + this.handleMessage(message); + } catch (e) { + debug(\`Parse error: \${e.message}\`); + } + }); + } + + send(message) { + const msgStr = JSON.stringify(message); + debug(\`Sending message: \${msgStr}\`); + process.stdout.write(msgStr + '\\n'); + } + + async handleMessage(message) { + if (message.method && this.handlers.has(message.method)) { + try { + const result = await this.handlers.get(message.method)(message.params || {}); + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + result + }); + } + } catch (error) { + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: error.message + } + }); + } + } + } else if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32601, + message: 'Method not found' + } + }); + } + } + + on(method, handler) { + this.handlers.set(method, handler); + } +} + +// Create MCP server +const rpc = new SimpleJSONRPC(); + +// Handle initialize +rpc.on('initialize', async (params) => { + debug('Handling initialize request'); + return { + protocolVersion: '2024-11-05', + capabilities: { + tools: {} + }, + serverInfo: { + name: 'test-math-server', + version: '1.0.0' + } + }; +}); + +// Handle tools/list +rpc.on('tools/list', async () => { + debug('Handling tools/list request'); + return { + tools: [ + { + name: 'add', + description: 'Add two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + }, + { + name: 'multiply', + description: 'Multiply two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + } + ] + }; +}); + +// Handle tools/call +rpc.on('tools/call', async (params) => { + debug(\`Handling tools/call request for tool: \${params.name}\`); + + if (params.name === 'add') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a + b) + }] + }; + } + + if (params.name === 'multiply') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a * b) + }] + }; + } + + throw new Error('Unknown tool: ' + params.name); +}); + +// Send initialization notification +rpc.send({ + jsonrpc: '2.0', + method: 'initialized' +}); +`; + +/** + * Create an MCP server script in the test directory + * @param helper - SDKTestHelper instance + * @param type - Type of MCP server ('math' or provide custom script) + * @param serverName - Name of the MCP server (default: 'test-math-server') + * @param customScript - Custom MCP server script (if type is not 'math') + * @returns Object with scriptPath and config + */ +export async function createMCPServer( + helper: SDKTestHelper, + type: 'math' | 'custom' = 'math', + serverName: string = 'test-math-server', + customScript?: string, +): Promise { + if (!helper.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + + const script = type === 'math' ? MCP_MATH_SERVER_SCRIPT : customScript; + if (!script) { + throw new Error('Custom script required when type is "custom"'); + } + + const scriptPath = join(helper.testDir, `${serverName}.cjs`); + await writeFile(scriptPath, script, 'utf-8'); + + // Make script executable on Unix-like systems + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755); + } + + return { + scriptPath, + config: { + command: 'node', + args: [scriptPath], + }, + }; +} + +// ============================================================================ +// Message & Content Utilities +// ============================================================================ + +/** + * Extract text from ContentBlock array + */ +export function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +/** + * Collect messages by type + */ +export function collectMessagesByType( + messages: SDKMessage[], + predicate: (msg: SDKMessage) => msg is T, +): T[] { + return messages.filter(predicate); +} + +/** + * Find tool use blocks in a message + */ +export function findToolUseBlocks( + message: SDKAssistantMessage, + toolName?: string, +): ToolUseBlock[] { + const toolUseBlocks = message.message.content.filter( + (block): block is ToolUseBlock => block.type === 'tool_use', + ); + + if (toolName) { + return toolUseBlocks.filter((block) => block.name === toolName); + } + + return toolUseBlocks; +} + +/** + * Extract all assistant text from messages + */ +export function getAssistantText(messages: SDKMessage[]): string { + return messages + .filter(isSDKAssistantMessage) + .map((msg) => extractText(msg.message.content)) + .join(''); +} + +/** + * Find system message with optional subtype filter + */ +export function findSystemMessage( + messages: SDKMessage[], + subtype?: string, +): SDKSystemMessage | null { + const systemMessages = messages.filter(isSDKSystemMessage); + + if (subtype) { + return systemMessages.find((msg) => msg.subtype === subtype) || null; + } + + return systemMessages[0] || null; +} + +/** + * Find all tool calls in messages + */ +export function findToolCalls( + messages: SDKMessage[], + toolName?: string, +): Array<{ message: SDKAssistantMessage; toolUse: ToolUseBlock }> { + const results: Array<{ + message: SDKAssistantMessage; + toolUse: ToolUseBlock; + }> = []; + + for (const message of messages) { + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, toolName); + for (const toolUse of toolUseBlocks) { + results.push({ message, toolUse }); + } + } + } + + return results; +} + +/** + * Find tool result for a specific tool use ID + */ +export function findToolResult( + messages: SDKMessage[], + toolUseId: string, +): { content: string; isError: boolean } | null { + for (const message of messages) { + if (message.type === 'user' && 'message' in message) { + const userMsg = message as SDKUserMessage; + const content = userMsg.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if ( + block.type === 'tool_result' && + (block as { tool_use_id?: string }).tool_use_id === toolUseId + ) { + const resultBlock = block as { + content?: string | ContentBlock[]; + is_error?: boolean; + }; + let resultContent = ''; + if (typeof resultBlock.content === 'string') { + resultContent = resultBlock.content; + } else if (Array.isArray(resultBlock.content)) { + resultContent = resultBlock.content + .filter((b): b is TextBlock => b.type === 'text') + .map((b) => b.text) + .join(''); + } + return { + content: resultContent, + isError: resultBlock.is_error ?? false, + }; + } + } + } + } + } + return null; +} + +/** + * Find all tool results for a specific tool name + */ +export function findToolResults( + messages: SDKMessage[], + toolName: string, +): Array<{ toolUseId: string; content: string; isError: boolean }> { + const results: Array<{ + toolUseId: string; + content: string; + isError: boolean; + }> = []; + + // First find all tool calls for this tool + const toolCalls = findToolCalls(messages, toolName); + + // Then find the result for each tool call + for (const { toolUse } of toolCalls) { + const result = findToolResult(messages, toolUse.id); + if (result) { + results.push({ + toolUseId: toolUse.id, + content: result.content, + isError: result.isError, + }); + } + } + + return results; +} + +/** + * Find all tool result blocks from messages (without requiring tool name) + */ +export function findAllToolResultBlocks( + messages: SDKMessage[], +): Array<{ toolUseId: string; content: string; isError: boolean }> { + const results: Array<{ + toolUseId: string; + content: string; + isError: boolean; + }> = []; + + for (const message of messages) { + if (message.type === 'user' && 'message' in message) { + const userMsg = message as SDKUserMessage; + const content = userMsg.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'tool_result' && 'tool_use_id' in block) { + const resultBlock = block as { + tool_use_id: string; + content?: string | ContentBlock[]; + is_error?: boolean; + }; + let resultContent = ''; + if (typeof resultBlock.content === 'string') { + resultContent = resultBlock.content; + } else if (Array.isArray(resultBlock.content)) { + resultContent = (resultBlock.content as ContentBlock[]) + .filter((b): b is TextBlock => b.type === 'text') + .map((b) => b.text) + .join(''); + } + results.push({ + toolUseId: resultBlock.tool_use_id, + content: resultContent, + isError: resultBlock.is_error ?? false, + }); + } + } + } + } + } + + return results; +} + +/** + * Check if any tool results exist in messages + */ +export function hasAnyToolResults(messages: SDKMessage[]): boolean { + return findAllToolResultBlocks(messages).length > 0; +} + +/** + * Check if any successful (non-error) tool results exist + */ +export function hasSuccessfulToolResults(messages: SDKMessage[]): boolean { + return findAllToolResultBlocks(messages).some((r) => !r.isError); +} + +/** + * Check if any error tool results exist + */ +export function hasErrorToolResults(messages: SDKMessage[]): boolean { + return findAllToolResultBlocks(messages).some((r) => r.isError); +} + +// ============================================================================ +// Streaming Input Utilities +// ============================================================================ + +/** + * Create a simple streaming input from an array of message contents + */ +export async function* createStreamingInput( + messageContents: string[], + sessionId?: string, +): AsyncIterable { + const sid = sessionId || crypto.randomUUID(); + + for (const content of messageContents) { + yield { + type: 'user', + session_id: sid, + message: { + role: 'user', + content: content, + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + // Small delay between messages + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +/** + * Create a controlled streaming input with pause/resume capability + */ +export function createControlledStreamingInput( + messageContents: string[], + sessionId?: string, +): { + generator: AsyncIterable; + resume: () => void; + resumeAll: () => void; +} { + const sid = sessionId || crypto.randomUUID(); + const resumeResolvers: Array<() => void> = []; + const resumePromises: Array> = []; + + // Create a resume promise for each message after the first + for (let i = 1; i < messageContents.length; i++) { + const promise = new Promise((resolve) => { + resumeResolvers.push(resolve); + }); + resumePromises.push(promise); + } + + const generator = (async function* () { + // Yield first message immediately + yield { + type: 'user', + session_id: sid, + message: { + role: 'user', + content: messageContents[0], + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + // For subsequent messages, wait for resume + for (let i = 1; i < messageContents.length; i++) { + await new Promise((resolve) => setTimeout(resolve, 200)); + await resumePromises[i - 1]; + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sid, + message: { + role: 'user', + content: messageContents[i], + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + })(); + + let currentResumeIndex = 0; + + return { + generator, + resume: () => { + if (currentResumeIndex < resumeResolvers.length) { + resumeResolvers[currentResumeIndex](); + currentResumeIndex++; + } + }, + resumeAll: () => { + resumeResolvers.forEach((resolve) => resolve()); + currentResumeIndex = resumeResolvers.length; + }, + }; +} + +// ============================================================================ +// Assertion Utilities +// ============================================================================ + +/** + * Assert that messages follow expected type sequence + */ +export function assertMessageSequence( + messages: SDKMessage[], + expectedTypes: string[], +): void { + const actualTypes = messages.map((msg) => msg.type); + + if (actualTypes.length < expectedTypes.length) { + throw new Error( + `Expected at least ${expectedTypes.length} messages, got ${actualTypes.length}`, + ); + } + + for (let i = 0; i < expectedTypes.length; i++) { + if (actualTypes[i] !== expectedTypes[i]) { + throw new Error( + `Expected message ${i} to be type '${expectedTypes[i]}', got '${actualTypes[i]}'`, + ); + } + } +} + +/** + * Assert that a specific tool was called + */ +export function assertToolCalled( + messages: SDKMessage[], + toolName: string, +): void { + const toolCalls = findToolCalls(messages, toolName); + + if (toolCalls.length === 0) { + const allToolCalls = findToolCalls(messages); + const allToolNames = allToolCalls.map((tc) => tc.toolUse.name); + throw new Error( + `Expected tool '${toolName}' to be called. Found tools: ${allToolNames.length > 0 ? allToolNames.join(', ') : 'none'}`, + ); + } +} + +/** + * Assert that the conversation completed successfully + */ +export function assertSuccessfulCompletion(messages: SDKMessage[]): void { + const lastMessage = messages[messages.length - 1]; + + if (!isSDKResultMessage(lastMessage)) { + throw new Error( + `Expected last message to be a result message, got '${lastMessage.type}'`, + ); + } + + if (lastMessage.subtype !== 'success') { + throw new Error( + `Expected successful completion, got result subtype '${lastMessage.subtype}'`, + ); + } +} + +/** + * Wait for a condition to be true with timeout + */ +export async function waitFor( + predicate: () => boolean | Promise, + options: { + timeout?: number; + interval?: number; + errorMessage?: string; + } = {}, +): Promise { + const { + timeout = 5000, + interval = 100, + errorMessage = 'Condition not met within timeout', + } = options; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const result = await predicate(); + if (result) { + return; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error(errorMessage); +} + +// ============================================================================ +// Debug and Validation Utilities +// ============================================================================ + +/** + * Validate model output and warn about unexpected content + * Inspired by integration-tests test-helper + */ +export function validateModelOutput( + result: string, + expectedContent: string | (string | RegExp)[] | null = null, + testName = '', +): boolean { + // First, check if there's any output at all + if (!result || result.trim().length === 0) { + throw new Error('Expected model to return some output'); + } + + // If expectedContent is provided, check for it and warn if missing + if (expectedContent) { + const contents = Array.isArray(expectedContent) + ? expectedContent + : [expectedContent]; + const missingContent = contents.filter((content) => { + if (typeof content === 'string') { + return !result.toLowerCase().includes(content.toLowerCase()); + } else if (content instanceof RegExp) { + return !content.test(result); + } + return false; + }); + + if (missingContent.length > 0) { + console.warn( + `Warning: Model did not include expected content in response: ${missingContent.join(', ')}.`, + 'This is not ideal but not a test failure.', + ); + console.warn( + 'The tool was called successfully, which is the main requirement.', + ); + return false; + } else if (process.env['VERBOSE'] === 'true') { + console.log(`${testName}: Model output validated successfully.`); + } + return true; + } + + return true; +} + +/** + * Print debug information when tests fail + */ +export function printDebugInfo( + messages: SDKMessage[], + context: Record = {}, +): void { + console.error('Test failed - Debug info:'); + console.error('Message count:', messages.length); + + // Print message types + const messageTypes = messages.map((m) => m.type); + console.error('Message types:', messageTypes.join(', ')); + + // Print assistant text + const assistantText = getAssistantText(messages); + console.error( + 'Assistant text (first 500 chars):', + assistantText.substring(0, 500), + ); + if (assistantText.length > 500) { + console.error( + 'Assistant text (last 500 chars):', + assistantText.substring(assistantText.length - 500), + ); + } + + // Print tool calls + const toolCalls = findToolCalls(messages); + console.error( + 'Tool calls found:', + toolCalls.map((tc) => tc.toolUse.name), + ); + + // Print any additional context provided + Object.entries(context).forEach(([key, value]) => { + console.error(`${key}:`, value); + }); +} + +/** + * Create detailed error message for tool call expectations + */ +export function createToolCallErrorMessage( + expectedTools: string | string[], + foundTools: string[], + messages: SDKMessage[], +): string { + const expectedStr = Array.isArray(expectedTools) + ? expectedTools.join(' or ') + : expectedTools; + + const assistantText = getAssistantText(messages); + const preview = assistantText + ? assistantText.substring(0, 200) + '...' + : 'no output'; + + return ( + `Expected to find ${expectedStr} tool call(s). ` + + `Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` + + `Output preview: ${preview}` + ); +} + +// ============================================================================ +// Shared Test Options Helper +// ============================================================================ + +/** + * Create shared test options with CLI path + */ +export function createSharedTestOptions( + overrides: Record = {}, +) { + const TEST_CLI_PATH = process.env['TEST_CLI_PATH']; + if (!TEST_CLI_PATH) { + throw new Error('TEST_CLI_PATH environment variable not set'); + } + + return { + pathToQwenExecutable: TEST_CLI_PATH, + ...overrides, + }; +} diff --git a/integration-tests/sdk-typescript/tool-control.test.ts b/integration-tests/sdk-typescript/tool-control.test.ts new file mode 100644 index 00000000..549f820c --- /dev/null +++ b/integration-tests/sdk-typescript/tool-control.test.ts @@ -0,0 +1,742 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for tool control parameters: + * - coreTools: Limit available tools to a specific set + * - excludeTools: Block specific tools from execution + * - allowedTools: Auto-approve specific tools without confirmation + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { query, isSDKAssistantMessage, type SDKMessage } from '@qwen-code/sdk'; +import { + SDKTestHelper, + extractText, + findToolCalls, + findToolResults, + assertSuccessfulCompletion, + createSharedTestOptions, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); +const TEST_TIMEOUT = 60000; + +describe('Tool Control Parameters (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('tool-control'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('coreTools parameter', () => { + it( + 'should only allow specified tools when coreTools is set', + async () => { + // Create a test file + await helper.createFile('test.txt', 'original content'); + + const q = query({ + prompt: + 'Read the file test.txt and then write "modified" to test.txt. Finally, list the directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Only allow read_file and write_file, exclude list_directory + coreTools: ['read_file', 'write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should have read_file and write_file calls + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // Should NOT have list_directory since it's not in coreTools + expect(toolNames).not.toContain('list_directory'); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with minimal tool set', + async () => { + const q = query({ + prompt: 'What is 2 + 2? Just answer with the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + // Only allow thinking, no file operations + coreTools: [], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Should answer without any tool calls + expect(assistantText).toMatch(/4/); + + // Should have no tool calls + const toolCalls = findToolCalls(messages); + expect(toolCalls.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('excludeTools parameter', () => { + it( + 'should block excluded tools from execution', + async () => { + await helper.createFile('test.txt', 'test content'); + + const q = query({ + prompt: + 'Read test.txt and then write empty content to it to clear it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + coreTools: ['read_file', 'write_file'], + // Block all write_file tool + excludeTools: ['write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should be able to read the file + expect(toolNames).toContain('read_file'); + + // The excluded tools should have been called but returned permission declined + // Check if write_file was attempted and got permission denied + const writeFileResults = findToolResults(messages, 'write_file'); + if (writeFileResults.length > 0) { + // Tool was called but should have permission declined message + for (const result of writeFileResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + + // File content should remain unchanged (because write was denied) + const content = await helper.readFile('test.txt'); + expect(content).toBe('test content'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should block multiple excluded tools', + async () => { + await helper.createFile('test.txt', 'test content'); + + const q = query({ + prompt: 'Read test.txt, list the directory, and run "echo hello".', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Block multiple tools + excludeTools: ['list_directory', 'run_shell_command'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should be able to read + expect(toolNames).toContain('read_file'); + + // Excluded tools should have been attempted but returned permission declined + const listDirResults = findToolResults(messages, 'list_directory'); + if (listDirResults.length > 0) { + for (const result of listDirResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + + const shellResults = findToolResults(messages, 'run_shell_command'); + if (shellResults.length > 0) { + for (const result of shellResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should block all shell commands when run_shell_command is excluded', + async () => { + const q = query({ + prompt: 'Run "echo hello" and "ls -la" commands.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Block all shell commands - excludeTools blocks entire tools + excludeTools: ['run_shell_command'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // All shell commands should have permission declined + const shellResults = findToolResults(messages, 'run_shell_command'); + for (const result of shellResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'excludeTools should take priority over allowedTools', + async () => { + await helper.createFile('test.txt', 'test content'); + + const q = query({ + prompt: + 'Clear the content of test.txt by writing empty string to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Conflicting settings: exclude takes priority + excludeTools: ['write_file'], + allowedTools: ['write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // write_file should have been attempted but returned permission declined + const writeFileResults = findToolResults(messages, 'write_file'); + if (writeFileResults.length > 0) { + // Tool was called but should have permission declined message (exclude takes priority) + for (const result of writeFileResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + + // File content should remain unchanged (because write was denied) + const content = await helper.readFile('test.txt'); + expect(content).toBe('test content'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('allowedTools parameter', () => { + it( + 'should auto-approve allowed tools without canUseTool callback', + async () => { + await helper.createFile('test.txt', 'original'); + + let canUseToolCalled = false; + + const q = query({ + prompt: 'Read test.txt and write "modified" to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['read_file', 'write_file'], + // Allow write_file without confirmation + allowedTools: ['read_file', 'write_file'], + canUseTool: async (_toolName) => { + canUseToolCalled = true; + return { behavior: 'deny', message: 'Should not be called' }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should have executed the tools + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // canUseTool should NOT have been called (tools are in allowedTools) + expect(canUseToolCalled).toBe(false); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow specific shell commands with pattern matching', + async () => { + const q = query({ + prompt: 'Run "echo hello" and "ls -la" commands.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Allow specific shell commands + allowedTools: ['ShellTool(echo )', 'ShellTool(ls )'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const shellCalls = toolCalls.filter( + (tc) => tc.toolUse.name === 'run_shell_command', + ); + + // Should have executed shell commands + expect(shellCalls.length).toBeGreaterThan(0); + + // All shell commands should be echo or ls + for (const call of shellCalls) { + const input = call.toolUse.input as { command?: string }; + if (input.command) { + expect(input.command).toMatch(/^(echo |ls )/); + } + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should fall back to canUseTool for non-allowed tools', + async () => { + await helper.createFile('test.txt', 'test'); + + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: 'Read test.txt and append an empty line to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Only allow read_file, list_directory should trigger canUseTool + coreTools: ['read_file', 'write_file'], + allowedTools: ['read_file'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + return { + behavior: 'allow', + updatedInput: {}, + }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Both tools should have been executed + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // canUseTool should have been called for write_file (not in allowedTools) + // but NOT for read_file (in allowedTools) + expect(canUseToolCalls).toContain('write_file'); + expect(canUseToolCalls).not.toContain('read_file'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with permissionMode: auto-edit', + async () => { + await helper.createFile('test.txt', 'test'); + + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: 'Read test.txt, write "new" to it, and list the directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'auto-edit', + // Allow list_directory in addition to auto-approved edit tools + allowedTools: ['list_directory'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + return { + behavior: 'deny', + message: 'Should not be called', + }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // All tools should have been executed + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + expect(toolNames).toContain('list_directory'); + + // canUseTool should NOT have been called + // (edit tools auto-approved, list_directory in allowedTools) + expect(canUseToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Combined tool control scenarios', () => { + it( + 'should work with coreTools + allowedTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: 'Read test.txt and write "modified" to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Limit to specific tools + coreTools: ['read_file', 'write_file', 'list_directory'], + // Auto-approve write operations + allowedTools: ['write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should use allowed tools from coreTools + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // Should NOT use tools outside coreTools + expect(toolNames).not.toContain('run_shell_command'); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with coreTools + excludeTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: + 'Read test.txt, write "new content" to it, and list directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Allow file operations + coreTools: ['read_file', 'write_file', 'edit', 'list_directory'], + // But exclude edit + excludeTools: ['edit'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should use non-excluded tools from coreTools + expect(toolNames).toContain('read_file'); + + // Should NOT use excluded tool + expect(toolNames).not.toContain('edit'); + + // File should still exist + expect(helper.fileExists('test.txt')).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with all three parameters together', + async () => { + await helper.createFile('test.txt', 'test'); + + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: + 'Read test.txt, write "modified" to it, and list the directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Limit available tools + coreTools: ['read_file', 'write_file', 'list_directory', 'edit'], + // Block edit + excludeTools: ['edit'], + // Auto-approve write + allowedTools: ['write_file'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + return { + behavior: 'allow', + updatedInput: {}, + }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should use allowed tools + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // Should NOT use excluded tool + expect(toolNames).not.toContain('edit'); + + // canUseTool should be called for tools not in allowedTools + // but should NOT be called for write_file (in allowedTools) + expect(canUseToolCalls).not.toContain('write_file'); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Edge cases and error handling', () => { + it( + 'should handle non-existent tool names in excludeTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: 'Read test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Non-existent tool names should be ignored + excludeTools: ['non_existent_tool', 'another_fake_tool'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should work normally + expect(toolNames).toContain('read_file'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle non-existent tool names in allowedTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: 'Read test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Non-existent tool names should be ignored + allowedTools: ['non_existent_tool', 'read_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should work normally + expect(toolNames).toContain('read_file'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); +}); diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/simple-mcp-server.test.ts index d8b6268d..d58bd982 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/simple-mcp-server.test.ts @@ -213,7 +213,7 @@ describe('simple-mcp-server', () => { it('should add two numbers', async () => { // Test directory is already set up in before hook // Just run the command - MCP server config is in settings.json - const output = await rig.run('add 5 and 10'); + const output = await rig.run('add 5 and 10, use tool if you can.'); const foundToolCall = await rig.waitForToolCall('add'); diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 0fe658c5..a08b3df5 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -218,8 +218,8 @@ export class TestRig { process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'; const command = isNpmReleaseTest ? 'qwen' : 'node'; const initialArgs = isNpmReleaseTest - ? extraInitialArgs - : [this.bundlePath, ...extraInitialArgs]; + ? ['--no-chat-recording', ...extraInitialArgs] + : [this.bundlePath, '--no-chat-recording', ...extraInitialArgs]; return { command, initialArgs }; } diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index 295741e1..0cd24f82 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -2,7 +2,11 @@ "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, - "allowJs": true + "allowJs": true, + "baseUrl": ".", + "paths": { + "@qwen-code/sdk": ["../packages/sdk-typescript/dist/index.d.ts"] + } }, "include": ["**/*.ts"], "references": [{ "path": "../packages/core" }] diff --git a/integration-tests/vitest.config.ts b/integration-tests/vitest.config.ts index c8b79ad6..9be72f50 100644 --- a/integration-tests/vitest.config.ts +++ b/integration-tests/vitest.config.ts @@ -1,12 +1,15 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { defineConfig } from 'vitest/config'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const timeoutMinutes = Number(process.env.TB_TIMEOUT_MINUTES || '5'); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const timeoutMinutes = Number(process.env['TB_TIMEOUT_MINUTES'] || '5'); const testTimeoutMs = timeoutMinutes * 60 * 1000; export default defineConfig({ @@ -25,4 +28,13 @@ export default defineConfig({ }, }, }, + resolve: { + alias: { + // Use built SDK bundle for e2e tests + '@qwen-code/sdk': resolve( + __dirname, + '../packages/sdk-typescript/dist/index.mjs', + ), + }, + }, }); diff --git a/package-lock.json b/package-lock.json index 9139e377..9a2909b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.5.0", "workspaces": [ "packages/*" ], @@ -35,7 +35,7 @@ "eslint-plugin-license-header": "^0.8.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "glob": "^10.4.5", + "glob": "^10.5.0", "globals": "^16.0.0", "google-artifactregistry-auth": "^3.4.0", "husky": "^9.1.7", @@ -108,6 +108,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1287,6 +1300,22 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1301,6 +1330,14 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", @@ -2645,6 +2682,354 @@ "node": ">=14" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2772,6 +3157,10 @@ "resolved": "packages/test-utils", "link": true }, + "node_modules/@qwen-code/sdk": { + "resolved": "packages/sdk-typescript", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz", @@ -3377,9 +3766,9 @@ "license": "MIT" }, "node_modules/@textlint/linter-formatter/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -3634,6 +4023,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "node_modules/@types/marked": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", @@ -3641,6 +4048,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3694,6 +4108,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qrcode-terminal": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", @@ -4165,6 +4586,13 @@ "node": ">=20.0.0" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -4530,15 +4958,15 @@ ] }, "node_modules/@vscode/vsce/node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -4554,11 +4982,11 @@ } }, "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -4734,6 +5162,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -4881,6 +5322,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -5025,11 +5487,17 @@ "streamx": "^2.15.0" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -5093,6 +5561,16 @@ "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -5299,6 +5777,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5374,6 +5890,16 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bignumber.js": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", @@ -5383,6 +5909,19 @@ "node": "*" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/binaryextensions": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", @@ -5550,6 +6089,40 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -5695,6 +6268,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -5833,6 +6437,24 @@ "entities": "^6.0.0" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -6197,6 +6819,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -6427,6 +7056,19 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -6744,6 +7386,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -6753,6 +7402,46 @@ "node": ">=0.3.1" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6866,6 +7555,23 @@ "url": "https://dotenvx.com" } }, + "node_modules/dts-bundle-generator": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz", + "integrity": "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "typescript": ">=5.0.2", + "yargs": "^17.6.0" + }, + "bin": { + "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6918,6 +7624,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", + "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -7713,6 +8426,72 @@ "node": ">=20.0.0" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -8148,6 +8927,20 @@ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", "license": "MIT" }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -8190,6 +8983,13 @@ "node": ">= 10.0.0" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8302,6 +9102,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -8339,6 +9149,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8379,9 +9202,9 @@ "optional": true }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -8903,6 +9726,16 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -8972,6 +9805,15 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9035,6 +9877,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -9420,6 +10274,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -10031,6 +10898,16 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -10039,9 +10916,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -10237,13 +11114,13 @@ } }, "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "dev": true, "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -10275,12 +11152,12 @@ } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -10433,11 +11310,17 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dev": true, "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -10589,6 +11472,23 @@ "node": ">=4" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10853,7 +11753,6 @@ "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -10892,7 +11791,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true, "license": "MIT" }, "node_modules/media-typer": { @@ -10944,6 +11842,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -11071,6 +11976,19 @@ "license": "MIT", "optional": true }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, "node_modules/mnemonist": { "version": "0.40.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", @@ -11177,6 +12095,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nan": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", @@ -11322,6 +12252,13 @@ "nan": "^2.17.0" } }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, "node_modules/node-sarif-builder": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.2.0.tgz", @@ -11360,6 +12297,16 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", @@ -11641,6 +12588,35 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -11670,6 +12646,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12131,6 +13117,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -12255,6 +13251,16 @@ "node": ">=4" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -12264,6 +13270,18 @@ "node": ">=16.20.0" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -12313,6 +13331,140 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -12509,7 +13661,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12757,6 +13908,26 @@ "node": ">=0.8" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-package-json-fast": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", @@ -12887,6 +14058,22 @@ "node": ">=10" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13096,6 +14283,45 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", @@ -13285,6 +14511,29 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.94.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz", + "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -14091,6 +15340,19 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -14131,6 +15393,39 @@ "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -14297,11 +15592,100 @@ "node": ">=8" } }, - "node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/tar": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -14314,9 +15698,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", "optional": true, @@ -14444,6 +15828,29 @@ "url": "https://bevry.me/fund" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/thingies": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", @@ -14681,6 +16088,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -14759,6 +16173,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -14928,6 +16352,12 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "dev": true, "license": "MIT" }, @@ -15003,6 +16433,37 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/update-notifier": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", @@ -16040,7 +17501,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", @@ -16054,7 +17515,7 @@ "dotenv": "^17.1.0", "extract-zip": "^2.0.1", "fzf": "^0.5.2", - "glob": "^10.4.5", + "glob": "^10.5.0", "highlight.js": "^11.11.1", "ink": "^6.2.3", "ink-gradient": "^3.0.0", @@ -16070,7 +17531,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", - "tar": "^7.5.1", + "tar": "^7.5.2", "undici": "^7.10.0", "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", @@ -16155,7 +17616,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.4.0", + "version": "0.5.0", "hasInstallScript": true, "dependencies": { "@google/genai": "1.16.0", @@ -16182,7 +17643,7 @@ "fast-uri": "^3.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", - "glob": "^10.4.5", + "glob": "^10.5.0", "google-auth-library": "^9.11.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", @@ -16294,9 +17755,2438 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "packages/sdk-typescript": { + "name": "@qwen-code/sdk", + "version": "0.5.0", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitest/coverage-v8": "^1.6.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.12", + "eslint": "^8.57.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "packages/sdk-typescript/node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "packages/sdk-typescript/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "packages/sdk-typescript/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "packages/sdk-typescript/node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/sdk-typescript/node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/sdk-typescript/node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "packages/sdk-typescript/node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "packages/sdk-typescript/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "packages/sdk-typescript/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "packages/sdk-typescript/node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "packages/sdk-typescript/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "packages/sdk-typescript/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/sdk-typescript/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "packages/sdk-typescript/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "packages/sdk-typescript/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "packages/sdk-typescript/node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sdk-typescript/node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sdk-typescript/node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "packages/sdk-typescript/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.4.0", + "version": "0.5.0", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -16308,25 +20198,37 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.4.0", + "version": "0.5.0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", "cors": "^2.8.5", "express": "^5.1.0", + "markdown-it": "^14.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "semver": "^7.7.2", "zod": "^3.25.76" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/markdown-it": "^14.1.2", "@types/node": "20.x", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/semver": "^7.7.1", "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.6.0", + "autoprefixer": "^10.4.22", "esbuild": "^0.25.3", "eslint": "^9.25.1", + "eslint-plugin-react-hooks": "^5.2.0", "npm-run-all2": "^8.0.2", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", "typescript": "^5.8.3", "vitest": "^3.2.4" }, @@ -16334,6 +20236,34 @@ "vscode": "^1.99.0" } }, + "packages/vscode-ide-companion/node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "packages/vscode-ide-companion/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "packages/vscode-ide-companion/node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "packages/vscode-ide-companion/node_modules/@types/vscode": { "version": "1.99.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz", @@ -16353,6 +20283,13 @@ "node": ">= 0.6" } }, + "packages/vscode-ide-companion/node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "packages/vscode-ide-companion/node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -16427,6 +20364,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "packages/vscode-ide-companion/node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "packages/vscode-ide-companion/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "packages/vscode-ide-companion/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "packages/vscode-ide-companion/node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", diff --git a/package.json b/package.json index a8b6857f..661afeda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.5.0", "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.4.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0" }, "scripts": { "start": "cross-env node scripts/start.js", @@ -37,6 +37,10 @@ "test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests", "test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests", "test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests", + "test:integration:sdk:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests sdk-typescript", + "test:integration:sdk:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript", + "test:integration:cli:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", + "test:integration:cli:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", "test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests", "test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'", "test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'", @@ -89,7 +93,7 @@ "eslint-plugin-license-header": "^0.8.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "glob": "^10.4.5", + "glob": "^10.5.0", "globals": "^16.0.0", "google-artifactregistry-auth": "^3.4.0", "husky": "^9.1.7", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5ad51028..685c6e90 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.5.0", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0" }, "dependencies": { "@google/genai": "1.16.0", @@ -47,7 +47,7 @@ "diff": "^7.0.0", "dotenv": "^17.1.0", "fzf": "^0.5.2", - "glob": "^10.4.5", + "glob": "^10.5.0", "highlight.js": "^11.11.1", "ink": "^6.2.3", "ink-gradient": "^3.0.0", @@ -63,7 +63,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", - "tar": "^7.5.1", + "tar": "^7.5.2", "undici": "^7.10.0", "extract-zip": "^2.0.1", "update-notifier": "^7.3.1", diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 2ef78bbd..84ba5ff5 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -88,6 +88,16 @@ export class AgentSideConnection implements Client { ); } + /** + * Streams authentication updates (e.g. Qwen OAuth authUri) to the client. + */ + async authenticateUpdate(params: schema.AuthenticateUpdate): Promise { + return await this.#connection.sendNotification( + schema.CLIENT_METHODS.authenticate_update, + params, + ); + } + /** * Request permission before running a tool * @@ -241,9 +251,11 @@ class Connection { ).toResult(); } + let errorName; let details; if (error instanceof Error) { + errorName = error.name; details = error.message; } else if ( typeof error === 'object' && @@ -254,6 +266,10 @@ class Connection { details = error.message; } + if (errorName === 'TokenManagerError') { + return RequestError.authRequired(details).toResult(); + } + return RequestError.internalError(details).toResult(); } } @@ -357,6 +373,7 @@ export interface Client { params: schema.RequestPermissionRequest, ): Promise; sessionUpdate(params: schema.SessionNotification): Promise; + authenticateUpdate(params: schema.AuthenticateUpdate): Promise; writeTextFile( params: schema.WriteTextFileRequest, ): Promise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index fc3c4ccc..91ce53cb 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -6,15 +6,19 @@ import type { ReadableStream, WritableStream } from 'node:stream/web'; -import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core'; import { APPROVAL_MODE_INFO, APPROVAL_MODES, AuthType, clearCachedCredentialFile, + QwenOAuth2Event, + qwenOAuth2Events, MCPServerConfig, SessionService, buildApiHistoryFromConversation, + type Config, + type ConversationRecord, + type DeviceAuthorizationData, } from '@qwen-code/qwen-code-core'; import type { ApprovalModeValue } from './schema.js'; import * as acp from './acp.js'; @@ -123,13 +127,33 @@ class GeminiAgent { async authenticate({ methodId }: acp.AuthenticateRequest): Promise { const method = z.nativeEnum(AuthType).parse(methodId); + let authUri: string | undefined; + const authUriHandler = (deviceAuth: DeviceAuthorizationData) => { + authUri = deviceAuth.verification_uri_complete; + // Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking). + void this.client.authenticateUpdate({ _meta: { authUri } }); + }; + + if (method === AuthType.QWEN_OAUTH) { + qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler); + } + await clearCachedCredentialFile(); - await this.config.refreshAuth(method); - this.settings.setValue( - SettingScope.User, - 'security.auth.selectedType', - method, - ); + try { + await this.config.refreshAuth(method); + this.settings.setValue( + SettingScope.User, + 'security.auth.selectedType', + method, + ); + } finally { + // Ensure we don't leak listeners if auth fails early. + if (method === AuthType.QWEN_OAUTH) { + qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler); + } + } + + return; } async newSession({ @@ -268,14 +292,17 @@ class GeminiAgent { private async ensureAuthenticated(config: Config): Promise { const selectedType = this.settings.merged.security?.auth?.selectedType; if (!selectedType) { - throw acp.RequestError.authRequired(); + throw acp.RequestError.authRequired('No Selected Type'); } try { - await config.refreshAuth(selectedType); + // Use true for the second argument to ensure only cached credentials are used + await config.refreshAuth(selectedType, true); } catch (e) { console.error(`Authentication failed: ${e}`); - throw acp.RequestError.authRequired(); + throw acp.RequestError.authRequired( + 'Authentication failed: ' + (e as Error).message, + ); } } diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 8f21c74c..a557c519 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -20,6 +20,7 @@ export const AGENT_METHODS = { export const CLIENT_METHODS = { fs_read_text_file: 'fs/read_text_file', fs_write_text_file: 'fs/write_text_file', + authenticate_update: 'authenticate/update', session_request_permission: 'session/request_permission', session_update: 'session/update', }; @@ -57,8 +58,6 @@ export type CancelNotification = z.infer; export type AuthenticateRequest = z.infer; -export type AuthenticateResponse = z.infer; - export type NewSessionResponse = z.infer; export type LoadSessionResponse = z.infer; @@ -247,7 +246,13 @@ export const authenticateRequestSchema = z.object({ methodId: z.string(), }); -export const authenticateResponseSchema = z.null(); +export const authenticateUpdateSchema = z.object({ + _meta: z.object({ + authUri: z.string(), + }), +}); + +export type AuthenticateUpdate = z.infer; export const newSessionResponseSchema = z.object({ sessionId: z.string(), @@ -316,6 +321,23 @@ export const annotationsSchema = z.object({ priority: z.number().optional().nullable(), }); +export const usageSchema = z.object({ + promptTokens: z.number().optional().nullable(), + completionTokens: z.number().optional().nullable(), + thoughtsTokens: z.number().optional().nullable(), + totalTokens: z.number().optional().nullable(), + cachedTokens: z.number().optional().nullable(), +}); + +export type Usage = z.infer; + +export const sessionUpdateMetaSchema = z.object({ + usage: usageSchema.optional().nullable(), + durationMs: z.number().optional().nullable(), +}); + +export type SessionUpdateMeta = z.infer; + export const requestPermissionResponseSchema = z.object({ outcome: requestPermissionOutcomeSchema, }); @@ -500,10 +522,12 @@ export const sessionUpdateSchema = z.union([ z.object({ content: contentBlockSchema, sessionUpdate: z.literal('agent_message_chunk'), + _meta: sessionUpdateMetaSchema.optional().nullable(), }), z.object({ content: contentBlockSchema, sessionUpdate: z.literal('agent_thought_chunk'), + _meta: sessionUpdateMetaSchema.optional().nullable(), }), z.object({ content: z.array(toolCallContentSchema).optional(), @@ -536,7 +560,6 @@ export const sessionUpdateSchema = z.union([ export const agentResponseSchema = z.union([ initializeResponseSchema, - authenticateResponseSchema, newSessionResponseSchema, loadSessionResponseSchema, promptResponseSchema, diff --git a/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts new file mode 100644 index 00000000..70ccfc2d --- /dev/null +++ b/packages/cli/src/acp-integration/service/filesystem.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { FileSystemService } from '@qwen-code/qwen-code-core'; +import { AcpFileSystemService } from './filesystem.js'; + +const createFallback = (): FileSystemService => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + findFiles: vi.fn().mockReturnValue([]), +}); + +describe('AcpFileSystemService', () => { + describe('readTextFile ENOENT handling', () => { + it('parses path from ACP ENOENT message (quoted)', async () => { + const client = { + readTextFile: vi + .fn() + .mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }), + } as unknown as import('../acp.js').Client; + + const svc = new AcpFileSystemService( + client, + 'session-1', + { readTextFile: true, writeTextFile: true }, + createFallback(), + ); + + await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({ + code: 'ENOENT', + path: '/remote/file.txt', + }); + }); + + it('falls back to requested path when none provided', async () => { + const client = { + readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }), + } as unknown as import('../acp.js').Client; + + const svc = new AcpFileSystemService( + client, + 'session-2', + { readTextFile: true, writeTextFile: true }, + createFallback(), + ); + + await expect( + svc.readTextFile('/fallback/path.txt'), + ).rejects.toMatchObject({ + code: 'ENOENT', + path: '/fallback/path.txt', + }); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index c7db7235..7bcaee2d 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService { limit: null, }); + if (response.content.startsWith('ERROR: ENOENT:')) { + // Treat ACP error strings as structured ENOENT errors without + // assuming a specific platform format. + const match = /^ERROR:\s*ENOENT:\s*(?.*)$/i.exec(response.content); + const err = new Error(response.content) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + err.errno = -2; + const rawPath = match?.groups?.['path']?.trim(); + err['path'] = rawPath + ? rawPath.replace(/^['"]|['"]$/g, '') || filePath + : filePath; + throw err; + } + return response.content; } diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts index 83451592..c9cf65fb 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts @@ -411,4 +411,48 @@ describe('HistoryReplayer', () => { ]); }); }); + + describe('usage metadata replay', () => { + it('should emit usage metadata after assistant message content', async () => { + const record: ChatRecord = { + uuid: 'assistant-uuid', + parentUuid: 'user-uuid', + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + cwd: '/test', + version: '1.0.0', + message: { + role: 'model', + parts: [{ text: 'Hello!' }], + }, + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + totalTokenCount: 150, + }, + }; + + await replayer.replay([record]); + + expect(sendUpdateSpy).toHaveBeenCalledTimes(2); + expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello!' }, + }); + expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + usage: { + promptTokens: 100, + completionTokens: 50, + thoughtsTokens: undefined, + totalTokens: 150, + cachedTokens: undefined, + }, + }, + }); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 53a1ed8a..0ecbccb9 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ChatRecord } from '@qwen-code/qwen-code-core'; -import type { Content } from '@google/genai'; +import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core'; +import type { + Content, + GenerateContentResponseUsageMetadata, +} from '@google/genai'; import type { SessionContext } from './types.js'; import { MessageEmitter } from './emitters/MessageEmitter.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; @@ -52,6 +55,9 @@ export class HistoryReplayer { if (record.message) { await this.replayContent(record.message, 'assistant'); } + if (record.usageMetadata) { + await this.replayUsageMetadata(record.usageMetadata); + } break; case 'tool_result': @@ -88,11 +94,22 @@ export class HistoryReplayer { toolName: functionName, callId, args: part.functionCall.args as Record, + status: 'in_progress', }); } } } + /** + * Replays usage metadata. + * @param usageMetadata - The usage metadata to replay + */ + private async replayUsageMetadata( + usageMetadata: GenerateContentResponseUsageMetadata, + ): Promise { + await this.messageEmitter.emitUsageMetadata(usageMetadata); + } + /** * Replays a tool result record. */ @@ -118,6 +135,54 @@ export class HistoryReplayer { // Note: args aren't stored in tool_result records by default args: undefined, }); + + // Special handling: Task tool execution summary contains token usage + const { resultDisplay } = result ?? {}; + if ( + !!resultDisplay && + typeof resultDisplay === 'object' && + 'type' in resultDisplay && + (resultDisplay as { type?: unknown }).type === 'task_execution' + ) { + await this.emitTaskUsageFromResultDisplay( + resultDisplay as TaskResultDisplay, + ); + } + } + + /** + * Emits token usage from a TaskResultDisplay execution summary, if present. + */ + private async emitTaskUsageFromResultDisplay( + resultDisplay: TaskResultDisplay, + ): Promise { + const summary = resultDisplay.executionSummary; + if (!summary) { + return; + } + + const usageMetadata: GenerateContentResponseUsageMetadata = {}; + + if (Number.isFinite(summary.inputTokens)) { + usageMetadata.promptTokenCount = summary.inputTokens; + } + if (Number.isFinite(summary.outputTokens)) { + usageMetadata.candidatesTokenCount = summary.outputTokens; + } + if (Number.isFinite(summary.thoughtTokens)) { + usageMetadata.thoughtsTokenCount = summary.thoughtTokens; + } + if (Number.isFinite(summary.cachedTokens)) { + usageMetadata.cachedContentTokenCount = summary.cachedTokens; + } + if (Number.isFinite(summary.totalTokens)) { + usageMetadata.totalTokenCount = summary.totalTokens; + } + + // Only emit if we captured at least one token metric + if (Object.keys(usageMetadata).length > 0) { + await this.messageEmitter.emitUsageMetadata(usageMetadata); + } } /** diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index b4d79433..1d90ed20 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Content, FunctionCall, Part } from '@google/genai'; +import type { + Content, + FunctionCall, + GenerateContentResponseUsageMetadata, + Part, +} from '@google/genai'; import type { Config, GeminiChat, @@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js'; import { HistoryReplayer } from './HistoryReplayer.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { PlanEmitter } from './emitters/PlanEmitter.js'; +import { MessageEmitter } from './emitters/MessageEmitter.js'; import { SubAgentTracker } from './SubAgentTracker.js'; /** @@ -79,6 +85,7 @@ export class Session implements SessionContext { private readonly historyReplayer: HistoryReplayer; private readonly toolCallEmitter: ToolCallEmitter; private readonly planEmitter: PlanEmitter; + private readonly messageEmitter: MessageEmitter; // Implement SessionContext interface readonly sessionId: string; @@ -96,6 +103,7 @@ export class Session implements SessionContext { this.toolCallEmitter = new ToolCallEmitter(this); this.planEmitter = new PlanEmitter(this); this.historyReplayer = new HistoryReplayer(this); + this.messageEmitter = new MessageEmitter(this); } getId(): string { @@ -192,6 +200,8 @@ export class Session implements SessionContext { } const functionCalls: FunctionCall[] = []; + let usageMetadata: GenerateContentResponseUsageMetadata | null = null; + const streamStartTime = Date.now(); try { const responseStream = await chat.sendMessageStream( @@ -222,20 +232,18 @@ export class Session implements SessionContext { continue; } - const content: acp.ContentBlock = { - type: 'text', - text: part.text, - }; - - this.sendUpdate({ - sessionUpdate: part.thought - ? 'agent_thought_chunk' - : 'agent_message_chunk', - content, - }); + this.messageEmitter.emitMessage( + part.text, + 'assistant', + part.thought, + ); } } + if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { + usageMetadata = resp.value.usageMetadata; + } + if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { functionCalls.push(...resp.value.functionCalls); } @@ -251,6 +259,15 @@ export class Session implements SessionContext { throw error; } + if (usageMetadata) { + const durationMs = Date.now() - streamStartTime; + await this.messageEmitter.emitUsageMetadata( + usageMetadata, + '', + durationMs, + ); + } + if (functionCalls.length > 0) { const toolResponseParts: Part[] = []; @@ -444,7 +461,9 @@ export class Session implements SessionContext { } const confirmationDetails = - await invocation.shouldConfirmExecute(abortSignal); + this.config.getApprovalMode() !== ApprovalMode.YOLO + ? await invocation.shouldConfirmExecute(abortSignal) + : false; if (confirmationDetails) { const content: acp.ToolCallContent[] = []; @@ -522,6 +541,7 @@ export class Session implements SessionContext { callId, toolName: fc.name, args, + status: 'in_progress', }; await this.toolCallEmitter.emitStart(startParams); } diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts index 074c8162..f2bb7cc5 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts @@ -208,7 +208,7 @@ describe('SubAgentTracker', () => { expect.objectContaining({ sessionUpdate: 'tool_call', toolCallId: 'call-123', - status: 'in_progress', + status: 'pending', title: 'read_file', content: [], locations: [], diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts index c6c83292..1e745b92 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -9,6 +9,7 @@ import type { SubAgentToolCallEvent, SubAgentToolResultEvent, SubAgentApprovalRequestEvent, + SubAgentUsageEvent, ToolCallConfirmationDetails, AnyDeclarativeTool, AnyToolInvocation, @@ -20,6 +21,7 @@ import { import { z } from 'zod'; import type { SessionContext } from './types.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; +import { MessageEmitter } from './emitters/MessageEmitter.js'; import type * as acp from '../acp.js'; /** @@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [ */ export class SubAgentTracker { private readonly toolCallEmitter: ToolCallEmitter; + private readonly messageEmitter: MessageEmitter; private readonly toolStates = new Map< string, { @@ -76,6 +79,7 @@ export class SubAgentTracker { private readonly client: acp.Client, ) { this.toolCallEmitter = new ToolCallEmitter(ctx); + this.messageEmitter = new MessageEmitter(ctx); } /** @@ -92,16 +96,19 @@ export class SubAgentTracker { const onToolCall = this.createToolCallHandler(abortSignal); const onToolResult = this.createToolResultHandler(abortSignal); const onApproval = this.createApprovalHandler(abortSignal); + const onUsageMetadata = this.createUsageMetadataHandler(abortSignal); eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); + eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata); return [ () => { eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); + eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata); // Clean up any remaining states this.toolStates.clear(); }, @@ -252,6 +259,20 @@ export class SubAgentTracker { }; } + /** + * Creates a handler for usage metadata events. + */ + private createUsageMetadataHandler( + abortSignal: AbortSignal, + ): (...args: unknown[]) => void { + return (...args: unknown[]) => { + const event = args[0] as SubAgentUsageEvent; + if (abortSignal.aborted) return; + + this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs); + }; + } + /** * Converts confirmation details to permission options for the client. */ diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts index 52a41a48..d0b1ae87 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -148,4 +148,59 @@ describe('MessageEmitter', () => { }); }); }); + + describe('emitUsageMetadata', () => { + it('should emit agent_message_chunk with _meta.usage containing token counts', async () => { + const usageMetadata = { + promptTokenCount: 100, + candidatesTokenCount: 50, + thoughtsTokenCount: 25, + totalTokenCount: 175, + cachedContentTokenCount: 10, + }; + + await emitter.emitUsageMetadata(usageMetadata); + + expect(sendUpdateSpy).toHaveBeenCalledWith({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + usage: { + promptTokens: 100, + completionTokens: 50, + thoughtsTokens: 25, + totalTokens: 175, + cachedTokens: 10, + }, + }, + }); + }); + + it('should include durationMs in _meta when provided', async () => { + const usageMetadata = { + promptTokenCount: 10, + candidatesTokenCount: 5, + thoughtsTokenCount: 2, + totalTokenCount: 17, + cachedContentTokenCount: 1, + }; + + await emitter.emitUsageMetadata(usageMetadata, 'done', 1234); + + expect(sendUpdateSpy).toHaveBeenCalledWith({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'done' }, + _meta: { + usage: { + promptTokens: 10, + completionTokens: 5, + thoughtsTokens: 2, + totalTokens: 17, + cachedTokens: 1, + }, + durationMs: 1234, + }, + }); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index 9ac8943a..39cdf6a7 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { GenerateContentResponseUsageMetadata } from '@google/genai'; +import type { Usage } from '../../schema.js'; import { BaseEmitter } from './BaseEmitter.js'; /** @@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter { }); } + /** + * Emits an agent thought chunk. + */ + async emitAgentThought(text: string): Promise { + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text }, + }); + } + /** * Emits an agent message chunk. */ @@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter { } /** - * Emits an agent thought chunk. + * Emits usage metadata. */ - async emitAgentThought(text: string): Promise { + async emitUsageMetadata( + usageMetadata: GenerateContentResponseUsageMetadata, + text: string = '', + durationMs?: number, + ): Promise { + const usage: Usage = { + promptTokens: usageMetadata.promptTokenCount, + completionTokens: usageMetadata.candidatesTokenCount, + thoughtsTokens: usageMetadata.thoughtsTokenCount, + totalTokens: usageMetadata.totalTokenCount, + cachedTokens: usageMetadata.cachedContentTokenCount, + }; + + const meta = + typeof durationMs === 'number' ? { usage, durationMs } : { usage }; + await this.sendUpdate({ - sessionUpdate: 'agent_thought_chunk', + sessionUpdate: 'agent_message_chunk', content: { type: 'text', text }, + _meta: meta, }); } diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts index 52e13399..4616b859 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts @@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'tool_call', toolCallId: 'call-123', - status: 'in_progress', + status: 'pending', title: 'unknown_tool', // Falls back to tool name content: [], locations: [], @@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'tool_call', toolCallId: 'call-456', - status: 'in_progress', + status: 'pending', title: 'edit_file: Test tool description', content: [], locations: [{ path: '/test/file.ts', line: 10 }], @@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'tool_call', toolCallId: 'call-fail', - status: 'in_progress', + status: 'pending', title: 'failing_tool', // Fallback to tool name content: [], locations: [], // Fallback to empty @@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => { type: 'content', content: { type: 'text', - text: '{"output":"test output"}', + text: 'test output', }, }, ], @@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => { content: [ { type: 'content', - content: { type: 'text', text: '{"output":"Function output"}' }, + content: { type: 'text', text: 'Function output' }, }, ], rawOutput: 'raw result', diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index 4c25570a..9859ed78 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter { await this.sendUpdate({ sessionUpdate: 'tool_call', toolCallId: params.callId, - status: 'in_progress', + status: params.status || 'pending', title, content: [], locations, @@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter { // Handle functionResponse parts - stringify the response if ('functionResponse' in part && part.functionResponse) { try { - const responseText = JSON.stringify(part.functionResponse.response); + const resp = part.functionResponse.response as Record< + string, + unknown + >; + const outputField = resp['output']; + const errorField = resp['error']; + const responseText = + typeof outputField === 'string' + ? outputField + : typeof errorField === 'string' + ? errorField + : JSON.stringify(resp); result.push({ type: 'content', content: { type: 'text', text: responseText }, diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts index 0c8f60a0..7812fb03 100644 --- a/packages/cli/src/acp-integration/session/types.ts +++ b/packages/cli/src/acp-integration/session/types.ts @@ -35,6 +35,8 @@ export interface ToolCallStartParams { callId: string; /** Arguments passed to the tool */ args?: Record; + /** Status of the tool call */ + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; } /** diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3162638f..99d0c0ed 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -6,6 +6,7 @@ import { ApprovalMode, + AuthType, Config, DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, @@ -129,10 +130,20 @@ export interface CliArgs { inputFormat?: string | undefined; outputFormat: string | undefined; includePartialMessages?: boolean; + /** + * If chat recording is disabled, the chat history would not be recorded, + * so --continue and --resume would not take effect. + */ + chatRecording: boolean | undefined; /** Resume the most recent session for the current project */ continue: boolean | undefined; /** Resume a specific session by its ID */ resume: string | undefined; + maxSessionTurns: number | undefined; + coreTools: string[] | undefined; + excludeTools: string[] | undefined; + authType: string | undefined; + channel: string | undefined; } function normalizeOutputFormat( @@ -227,6 +238,11 @@ export async function parseArguments(settings: Settings): Promise { 'proxy', 'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.', ) + .option('chat-recording', { + type: 'boolean', + description: + 'Enable chat recording to disk. If false, chat history is not saved and --continue/--resume will not work.', + }) .command('$0 [query..]', 'Launch Qwen Code CLI', (yargsInstance: Argv) => yargsInstance .positional('query', { @@ -292,6 +308,11 @@ export async function parseArguments(settings: Settings): Promise { type: 'boolean', description: 'Starts the agent in ACP mode', }) + .option('channel', { + type: 'string', + choices: ['VSCode', 'ACP', 'SDK', 'CI'], + description: 'Channel identifier (VSCode, ACP, SDK, CI)', + }) .option('allowed-mcp-server-names', { type: 'array', string: true, @@ -411,6 +432,36 @@ export async function parseArguments(settings: Settings): Promise { description: 'Resume a specific session by its ID. Use without an ID to show session picker.', }) + .option('max-session-turns', { + type: 'number', + description: 'Maximum number of session turns', + }) + .option('core-tools', { + type: 'array', + string: true, + description: 'Core tool paths', + coerce: (tools: string[]) => + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('exclude-tools', { + type: 'array', + string: true, + description: 'Tools to exclude', + coerce: (tools: string[]) => + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('allowed-tools', { + type: 'array', + string: true, + description: 'Tools to allow, will bypass confirmation', + coerce: (tools: string[]) => + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('auth-type', { + type: 'string', + choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH], + description: 'Authentication type', + }) .deprecateOption( 'show-memory-usage', 'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.', @@ -524,6 +575,12 @@ export async function parseArguments(settings: Settings): Promise { // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument + + // Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP + if (result['experimentalAcp'] && !result['channel']) { + (result as Record)['channel'] = 'ACP'; + } + return result as unknown as CliArgs; } @@ -745,8 +802,14 @@ export async function loadCliConfig( interactive = false; } // In non-interactive mode, exclude tools that require a prompt. + // However, if stream-json input is used, control can be requested via JSON messages, + // so tools should not be excluded in that case. const extraExcludes: string[] = []; - if (!interactive && !argv.experimentalAcp) { + if ( + !interactive && + !argv.experimentalAcp && + inputFormat !== InputFormat.STREAM_JSON + ) { switch (approvalMode) { case ApprovalMode.PLAN: case ApprovalMode.DEFAULT: @@ -770,6 +833,7 @@ export async function loadCliConfig( settings, activeExtensions, extraExcludes.length > 0 ? extraExcludes : undefined, + argv.excludeTools, ); const blockedMcpServers: Array<{ name: string; extensionName: string }> = []; @@ -850,7 +914,7 @@ export async function loadCliConfig( debugMode, question, fullContext: argv.allFiles || false, - coreTools: settings.tools?.core || undefined, + coreTools: argv.coreTools || settings.tools?.core || undefined, allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, @@ -883,13 +947,16 @@ export async function loadCliConfig( model: resolvedModel, extensionContextFilePaths, sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, - maxSessionTurns: settings.model?.maxSessionTurns ?? -1, + maxSessionTurns: + argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], - authType: settings.security?.auth?.selectedType, + authType: + (argv.authType as AuthType | undefined) || + settings.security?.auth?.selectedType, inputFormat, outputFormat, includePartialMessages, @@ -938,6 +1005,12 @@ export async function loadCliConfig( output: { format: outputSettingsFormat, }, + channel: argv.channel, + // Precedence: explicit CLI flag > settings file > default(true). + // NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will + // always be true and the settings file can never disable recording. + chatRecording: + argv.chatRecording ?? settings.general?.chatRecording ?? true, }); } @@ -997,8 +1070,10 @@ function mergeExcludeTools( settings: Settings, extensions: Extension[], extraExcludes?: string[] | undefined, + cliExcludeTools?: string[] | undefined, ): string[] { const allExcludeTools = new Set([ + ...(cliExcludeTools || []), ...(settings.tools?.exclude || []), ...(extraExcludes || []), ]); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d95f4dbb..767e196e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -191,8 +191,29 @@ const SETTINGS_SCHEMA = { { value: 'auto', label: 'Auto (detect from system)' }, { value: 'en', label: 'English' }, { value: 'zh', label: 'ไธญๆ–‡ (Chinese)' }, + { value: 'ru', label: 'ะ ัƒััะบะธะน (Russian)' }, ], }, + terminalBell: { + type: 'boolean', + label: 'Terminal Bell', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Play terminal bell sound when response completes or needs approval.', + showInDialog: true, + }, + chatRecording: { + type: 'boolean', + label: 'Chat Recording', + category: 'General', + requiresRestart: true, + default: true, + description: + 'Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.', + showInDialog: false, + }, }, }, output: { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index a9bc3d9e..7bb78aaf 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -481,6 +481,12 @@ describe('gemini.tsx main function kitty protocol', () => { includePartialMessages: undefined, continue: undefined, resume: undefined, + coreTools: undefined, + excludeTools: undefined, + authType: undefined, + maxSessionTurns: undefined, + channel: undefined, + chatRecording: undefined, }); await main(); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 310ef6b7..18f191bc 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -276,8 +276,11 @@ export async function main() { process.exit(1); } } + // For stream-json mode, don't read stdin here - it should be forwarded to the sandbox + // and consumed by StreamJsonInputReader inside the container + const inputFormat = argv.inputFormat as string | undefined; let stdinData = ''; - if (!process.stdin.isTTY) { + if (!process.stdin.isTTY && inputFormat !== 'stream-json') { stdinData = await readStdin(); } @@ -383,7 +386,18 @@ export async function main() { setMaxSizedBoxDebugging(isDebugMode); - const initializationResult = await initializeApp(config, settings); + // Check input format early to determine initialization flow + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + + // For stream-json mode, defer config.initialize() until after the initialize control request + // For other modes, initialize normally + let initializationResult: InitializationResult | undefined; + if (inputFormat !== InputFormat.STREAM_JSON) { + initializationResult = await initializeApp(config, settings); + } if ( settings.merged.security?.auth?.selectedType === @@ -417,19 +431,15 @@ export async function main() { settings, startupWarnings, process.cwd(), - initializationResult, + initializationResult!, ); return; } - await config.initialize(); - - // Check input format BEFORE reading stdin - // In STREAM_JSON mode, stdin should be left for StreamJsonInputReader - const inputFormat = - typeof config.getInputFormat === 'function' - ? config.getInputFormat() - : InputFormat.TEXT; + // For non-stream-json mode, initialize config here + if (inputFormat !== InputFormat.STREAM_JSON) { + await config.initialize(); + } // Only read stdin if NOT in stream-json mode // In stream-json mode, stdin is used for protocol messages (control requests, etc.) @@ -442,7 +452,8 @@ export async function main() { } const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, + (argv.authType as AuthType) || + settings.merged.security?.auth?.selectedType, settings.merged.security?.auth?.useExternal, config, settings, diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 2cad8dec..7436336b 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -9,7 +9,7 @@ import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { homedir } from 'node:os'; -export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes +export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes // State let currentLanguage: SupportedLanguage = 'en'; @@ -51,10 +51,12 @@ export function detectSystemLanguage(): SupportedLanguage { const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG']; if (envLang?.startsWith('zh')) return 'zh'; if (envLang?.startsWith('en')) return 'en'; + if (envLang?.startsWith('ru')) return 'ru'; try { const locale = Intl.DateTimeFormat().resolvedOptions().locale; if (locale.startsWith('zh')) return 'zh'; + if (locale.startsWith('ru')) return 'ru'; } catch { // Fallback to default } diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 3ab57edb..dec11869 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -110,7 +110,6 @@ export default { 'open full Qwen Code documentation in your browser', 'Configuration not available.': 'Configuration not available.', 'change the auth method': 'change the auth method', - 'Show quit confirmation dialog': 'Show quit confirmation dialog', 'Copy the last result or code snippet to clipboard': 'Copy the last result or code snippet to clipboard', @@ -690,18 +689,6 @@ export default { 'A custom command wants to run the following shell commands:': 'A custom command wants to run the following shell commands:', - // ============================================================================ - // Dialogs - Quit Confirmation - // ============================================================================ - 'What would you like to do before exiting?': - 'What would you like to do before exiting?', - 'Quit immediately (/quit)': 'Quit immediately (/quit)', - 'Generate summary and quit (/summary)': - 'Generate summary and quit (/summary)', - 'Save conversation and quit (/chat save)': - 'Save conversation and quit (/chat save)', - 'Cancel (stay in application)': 'Cancel (stay in application)', - // ============================================================================ // Dialogs - Pro Quota // ============================================================================ @@ -880,6 +867,7 @@ export default { // Exit Screen / Stats // ============================================================================ 'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!', + 'To continue this session, run': 'To continue this session, run', 'Interaction Summary': 'Interaction Summary', 'Session ID:': 'Session ID:', 'Tool Calls:': 'Tool Calls:', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js new file mode 100644 index 00000000..009578be --- /dev/null +++ b/packages/cli/src/i18n/locales/ru.js @@ -0,0 +1,1121 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// ะ ัƒััะบะธะน ะฟะตั€ะตะฒะพะด ะดะปั Qwen Code CLI +// ะšะปัŽั‡ ัะปัƒะถะธั‚ ะพะดะฝะพะฒั€ะตะผะตะฝะฝะพ ะบะปัŽั‡ะพะผ ะฟะตั€ะตะฒะพะดะฐ ะธ ั‚ะตะบัั‚ะพะผ ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ + +export default { + // ============================================================================ + // ะกะฟั€ะฐะฒะบะฐ / ะšะพะผะฟะพะฝะตะฝั‚ั‹ ะธะฝั‚ะตั€ั„ะตะนัะฐ + // ============================================================================ + 'Basics:': 'ะžัะฝะพะฒั‹:', + 'Add context': 'ะ”ะพะฑะฐะฒะธั‚ัŒ ะบะพะฝั‚ะตะบัั‚', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต {{symbol}} ะดะปั ะดะพะฑะฐะฒะปะตะฝะธั ั„ะฐะนะปะพะฒ ะฒ ะบะพะฝั‚ะตะบัั‚ (ะฝะฐะฟั€ะธะผะตั€, {{example}}) ะดะปั ะฒั‹ะฑะพั€ะฐ ะบะพะฝะบั€ะตั‚ะฝั‹ั… ั„ะฐะนะปะพะฒ ะธะปะธ ะฟะฐะฟะพะบ).', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'ะ ะตะถะธะผ ั‚ะตั€ะผะธะฝะฐะปะฐ', + 'YOLO mode': 'ะ ะตะถะธะผ YOLO', + 'plan mode': 'ะ ะตะถะธะผ ะฟะปะฐะฝะธั€ะพะฒะฐะฝะธั', + 'auto-accept edits': 'ะ ะตะถะธะผ ะฟั€ะธะฝัั‚ะธั ะฟั€ะฐะฒะพะบ', + 'Accepting edits': 'ะŸั€ะธะฝัั‚ะธะต ะฟั€ะฐะฒะพะบ', + '(shift + tab to cycle)': '(shift + tab ะดะปั ะฟะตั€ะตะบะปัŽั‡ะตะฝะธั)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'ะ’ั‹ะฟะพะปะฝัะนั‚ะต ะบะพะผะฐะฝะดั‹ ั‚ะตั€ะผะธะฝะฐะปะฐ ั‡ะตั€ะตะท {{symbol}} (ะฝะฐะฟั€ะธะผะตั€, {{example1}}) ะธะปะธ ะธัะฟะพะปัŒะทัƒะนั‚ะต ะตัั‚ะตัั‚ะฒะตะฝะฝั‹ะน ัะทั‹ะบ (ะฝะฐะฟั€ะธะผะตั€, {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': 'ะšะพะผะฐะฝะดั‹:', + 'shell command': 'ะบะพะผะฐะฝะดะฐ ั‚ะตั€ะผะธะฝะฐะปะฐ', + 'Model Context Protocol command (from external servers)': + 'ะšะพะผะฐะฝะดะฐ Model Context Protocol (ะธะท ะฒะฝะตัˆะฝะธั… ัะตั€ะฒะตั€ะพะฒ)', + 'Keyboard Shortcuts:': 'ะ“ะพั€ัั‡ะธะต ะบะปะฐะฒะธัˆะธ:', + 'Jump through words in the input': 'ะŸะตั€ะตั…ะพะด ะฟะพ ัะปะพะฒะฐะผ ะฒะพ ะฒะฒะพะดะต', + 'Close dialogs, cancel requests, or quit application': + 'ะ—ะฐะบั€ั‹ั‚ัŒ ะดะธะฐะปะพะณะธ, ะพั‚ะผะตะฝะธั‚ัŒ ะทะฐะฟั€ะพัั‹ ะธะปะธ ะฒั‹ะนั‚ะธ ะธะท ะฟั€ะธะปะพะถะตะฝะธั', + 'New line': 'ะะพะฒะฐั ัั‚ั€ะพะบะฐ', + 'New line (Alt+Enter works for certain linux distros)': + 'ะะพะฒะฐั ัั‚ั€ะพะบะฐ (Alt+Enter ั€ะฐะฑะพั‚ะฐะตั‚ ั‚ะพะปัŒะบะพ ะฒ ะฝะตะบะพั‚ะพั€ั‹ั… ะดะธัั‚ั€ะธะฑัƒั‚ะธะฒะฐั… Linux)', + 'Clear the screen': 'ะžั‡ะธัั‚ะธั‚ัŒ ัะบั€ะฐะฝ', + 'Open input in external editor': 'ะžั‚ะบั€ั‹ั‚ัŒ ะฒะฒะพะด ะฒะพ ะฒะฝะตัˆะฝะตะผ ั€ะตะดะฐะบั‚ะพั€ะต', + 'Send message': 'ะžั‚ะฟั€ะฐะฒะธั‚ัŒ ัะพะพะฑั‰ะตะฝะธะต', + 'Initializing...': 'ะ˜ะฝะธั†ะธะฐะปะธะทะฐั†ะธั...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'ะŸะพะดะบะปัŽั‡ะตะฝะธะต ะบ MCP-ัะตั€ะฒะตั€ะฐะผ... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': 'ะ’ะฒะตะดะธั‚ะต ัะพะพะฑั‰ะตะฝะธะต ะธะปะธ @ะฟัƒั‚ัŒ/ะบ/ั„ะฐะนะปัƒ', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "ะะฐะถะผะธั‚ะต 'i' ะดะปั ั€ะตะถะธะผะฐ ะ’ะกะขะะ’ะšะ ะธ 'Esc' ะดะปั ะžะ‘ะซะงะะžะ“ะž ั€ะตะถะธะผะฐ.", + 'Cancel operation / Clear input (double press)': + 'ะžั‚ะผะตะฝะธั‚ัŒ ะพะฟะตั€ะฐั†ะธัŽ / ะžั‡ะธัั‚ะธั‚ัŒ ะฒะฒะพะด (ะดะฒะพะนะฝะพะต ะฝะฐะถะฐั‚ะธะต)', + 'Cycle approval modes': 'ะŸะตั€ะตะบะปัŽั‡ะตะฝะธะต ั€ะตะถะธะผะพะฒ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั', + 'Cycle through your prompt history': 'ะŸั€ะพะปะธัั‚ะฐั‚ัŒ ะธัั‚ะพั€ะธัŽ ะทะฐะฟั€ะพัะพะฒ', + 'For a full list of shortcuts, see {{docPath}}': + 'ะŸะพะปะฝั‹ะน ัะฟะธัะพะบ ะณะพั€ัั‡ะธั… ะบะปะฐะฒะธัˆ ัะผ. ะฒ {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'ะกะฟั€ะฐะฒะบะฐ ะฟะพ Qwen Code', + 'show version info': 'ะŸั€ะพัะผะพั‚ั€ ะธะฝั„ะพั€ะผะฐั†ะธะธ ะพ ะฒะตั€ัะธะธ', + 'submit a bug report': 'ะžั‚ะฟั€ะฐะฒะบะฐ ะพั‚ั‡ั‘ั‚ะฐ ะพะฑ ะพัˆะธะฑะบะต', + 'About Qwen Code': 'ะžะฑ Qwen Code', + + // ============================================================================ + // ะŸะพะปั ัะธัั‚ะตะผะฝะพะน ะธะฝั„ะพั€ะผะฐั†ะธะธ + // ============================================================================ + 'CLI Version': 'ะ’ะตั€ัะธั CLI', + 'Git Commit': 'Git-ะบะพะผะผะธั‚', + Model: 'ะœะพะดะตะปัŒ', + Sandbox: 'ะŸะตัะพั‡ะฝะธั†ะฐ', + 'OS Platform': 'ะŸะปะฐั‚ั„ะพั€ะผะฐ ะžะก', + 'OS Arch': 'ะั€ั…ะธั‚ะตะบั‚ัƒั€ะฐ ะžะก', + 'OS Release': 'ะ’ะตั€ัะธั ะžะก', + 'Node.js Version': 'ะ’ะตั€ัะธั Node.js', + 'NPM Version': 'ะ’ะตั€ัะธั NPM', + 'Session ID': 'ID ัะตััะธะธ', + 'Auth Method': 'ะœะตั‚ะพะด ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ', + 'Base URL': 'ะ‘ะฐะทะพะฒั‹ะน URL', + 'Memory Usage': 'ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต ะฟะฐะผัั‚ะธ', + 'IDE Client': 'ะšะปะธะตะฝั‚ IDE', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะžะฑั‰ะธะต + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'ะะฝะฐะปะธะท ะฟั€ะพะตะบั‚ะฐ ะธ ัะพะทะดะฐะฝะธะต ะฐะดะฐะฟั‚ะธั€ะพะฒะฐะฝะฝะพะณะพ ั„ะฐะนะปะฐ QWEN.md', + 'list available Qwen Code tools. Usage: /tools [desc]': + 'ะŸั€ะพัะผะพั‚ั€ ะดะพัั‚ัƒะฟะฝั‹ั… ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ Qwen Code. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /tools [desc]', + 'Available Qwen Code CLI tools:': 'ะ”ะพัั‚ัƒะฟะฝั‹ะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹ Qwen Code CLI:', + 'No tools available': 'ะะตั‚ ะดะพัั‚ัƒะฟะฝั‹ั… ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + 'View or change the approval mode for tool usage': + 'ะŸั€ะพัะผะพั‚ั€ ะธะปะธ ะธะทะผะตะฝะตะฝะธะต ั€ะตะถะธะผะฐ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ะดะปั ะธัะฟะพะปัŒะทะพะฒะฐะฝะธั ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + 'View or change the language setting': + 'ะŸั€ะพัะผะพั‚ั€ ะธะปะธ ะธะทะผะตะฝะตะฝะธะต ะฝะฐัั‚ั€ะพะตะบ ัะทั‹ะบะฐ', + 'change the theme': 'ะ˜ะทะผะตะฝะตะฝะธะต ั‚ะตะผั‹', + 'Select Theme': 'ะ’ั‹ะฑะพั€ ั‚ะตะผั‹', + Preview: 'ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€', + '(Use Enter to select, Tab to configure scope)': + '(Enter ะดะปั ะฒั‹ะฑะพั€ะฐ, Tab ะดะปั ะฝะฐัั‚ั€ะพะนะบะธ ะพะฑะปะฐัั‚ะธ)', + '(Use Enter to apply scope, Tab to select theme)': + '(Enter ะดะปั ะฟั€ะธะผะตะฝะตะฝะธั ะพะฑะปะฐัั‚ะธ, Tab ะดะปั ะฒั‹ะฑะพั€ะฐ ั‚ะตะผั‹)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'ะะฐัั‚ั€ะพะนะบะฐ ั‚ะตะผั‹ ะฝะตะดะพัั‚ัƒะฟะฝะฐ ะธะท-ะทะฐ ะฟะตั€ะตะผะตะฝะฝะพะน ะพะบั€ัƒะถะตะฝะธั NO_COLOR.', + 'Theme "{{themeName}}" not found.': 'ะขะตะผะฐ "{{themeName}}" ะฝะต ะฝะฐะนะดะตะฝะฐ.', + 'Theme "{{themeName}}" not found in selected scope.': + 'ะขะตะผะฐ "{{themeName}}" ะฝะต ะฝะฐะนะดะตะฝะฐ ะฒ ะฒั‹ะฑั€ะฐะฝะฝะพะน ะพะฑะปะฐัั‚ะธ.', + 'clear the screen and conversation history': + 'ะžั‡ะธัั‚ะบะฐ ัะบั€ะฐะฝะฐ ะธ ะธัั‚ะพั€ะธะธ ะดะธะฐะปะพะณะฐ', + 'Compresses the context by replacing it with a summary.': + 'ะกะถะฐั‚ะธะต ะบะพะฝั‚ะตะบัั‚ะฐ ะทะฐะผะตะฝะพะน ะฝะฐ ะบั€ะฐั‚ะบัƒัŽ ัะฒะพะดะบัƒ', + 'open full Qwen Code documentation in your browser': + 'ะžั‚ะบั€ั‹ั‚ะธะต ะฟะพะปะฝะพะน ะดะพะบัƒะผะตะฝั‚ะฐั†ะธะธ Qwen Code ะฒ ะฑั€ะฐัƒะทะตั€ะต', + 'Configuration not available.': 'ะšะพะฝั„ะธะณัƒั€ะฐั†ะธั ะฝะตะดะพัั‚ัƒะฟะฝะฐ.', + 'change the auth method': 'ะ˜ะทะผะตะฝะตะฝะธะต ะผะตั‚ะพะดะฐ ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ', + 'Copy the last result or code snippet to clipboard': + 'ะšะพะฟะธั€ะพะฒะฐะฝะธะต ะฟะพัะปะตะดะฝะตะณะพ ั€ะตะทัƒะปัŒั‚ะฐั‚ะฐ ะธะปะธ ั„ั€ะฐะณะผะตะฝั‚ะฐ ะบะพะดะฐ ะฒ ะฑัƒั„ะตั€ ะพะฑะผะตะฝะฐ', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะะณะตะฝั‚ั‹ + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'ะฃะฟั€ะฐะฒะปะตะฝะธะต ะฟะพะดะฐะณะตะฝั‚ะฐะผะธ ะดะปั ะดะตะปะตะณะธั€ะพะฒะฐะฝะธั ัะฟะตั†ะธะฐะปะธะทะธั€ะพะฒะฐะฝะฝั‹ั… ะทะฐะดะฐั‡', + 'Manage existing subagents (view, edit, delete).': + 'ะฃะฟั€ะฐะฒะปะตะฝะธะต ััƒั‰ะตัั‚ะฒัƒัŽั‰ะธะผะธ ะฟะพะดะฐะณะตะฝั‚ะฐะผะธ (ะฟั€ะพัะผะพั‚ั€, ะฟั€ะฐะฒะบะฐ, ัƒะดะฐะปะตะฝะธะต)', + 'Create a new subagent with guided setup.': + 'ะกะพะทะดะฐะฝะธะต ะฝะพะฒะพะณะพ ะฟะพะดะฐะณะตะฝั‚ะฐ ั ะฟะพัˆะฐะณะพะฒะพะน ะฝะฐัั‚ั€ะพะนะบะพะน', + + // ============================================================================ + // ะะณะตะฝั‚ั‹ - ะ”ะธะฐะปะพะณ ัƒะฟั€ะฐะฒะปะตะฝะธั + // ============================================================================ + Agents: 'ะะณะตะฝั‚ั‹', + 'Choose Action': 'ะ’ั‹ะฑะตั€ะธั‚ะต ะดะตะนัั‚ะฒะธะต', + 'Edit {{name}}': 'ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ {{name}}', + 'Edit Tools: {{name}}': 'ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹: {{name}}', + 'Edit Color: {{name}}': 'ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ั†ะฒะตั‚: {{name}}', + 'Delete {{name}}': 'ะฃะดะฐะปะธั‚ัŒ {{name}}', + 'Unknown Step': 'ะะตะธะทะฒะตัั‚ะฝั‹ะน ัˆะฐะณ', + 'Esc to close': 'Esc ะดะปั ะทะฐะบั€ั‹ั‚ะธั', + 'Enter to select, โ†‘โ†“ to navigate, Esc to close': + 'Enter ะดะปั ะฒั‹ะฑะพั€ะฐ, โ†‘โ†“ ะดะปั ะฝะฐะฒะธะณะฐั†ะธะธ, Esc ะดะปั ะทะฐะบั€ั‹ั‚ะธั', + 'Esc to go back': 'Esc ะดะปั ะฒะพะทะฒั€ะฐั‚ะฐ', + 'Enter to confirm, Esc to cancel': 'Enter ะดะปั ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั, Esc ะดะปั ะพั‚ะผะตะฝั‹', + 'Enter to select, โ†‘โ†“ to navigate, Esc to go back': + 'Enter ะดะปั ะฒั‹ะฑะพั€ะฐ, โ†‘โ†“ ะดะปั ะฝะฐะฒะธะณะฐั†ะธะธ, Esc ะดะปั ะฒะพะทะฒั€ะฐั‚ะฐ', + 'Invalid step: {{step}}': 'ะะตะฒะตั€ะฝั‹ะน ัˆะฐะณ: {{step}}', + 'No subagents found.': 'ะŸะพะดะฐะณะตะฝั‚ั‹ ะฝะต ะฝะฐะนะดะตะฝั‹.', + "Use '/agents create' to create your first subagent.": + "ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต '/agents create' ะดะปั ัะพะทะดะฐะฝะธั ะฟะตั€ะฒะพะณะพ ะฟะพะดะฐะณะตะฝั‚ะฐ.", + '(built-in)': '(ะฒัั‚ั€ะพะตะฝะฝั‹ะน)', + '(overridden by project level agent)': + '(ะฟะตั€ะตะพะฟั€ะตะดะตะปะตะฝ ะฐะณะตะฝั‚ะพะผ ัƒั€ะพะฒะฝั ะฟั€ะพะตะบั‚ะฐ)', + 'Project Level ({{path}})': 'ะฃั€ะพะฒะตะฝัŒ ะฟั€ะพะตะบั‚ะฐ ({{path}})', + 'User Level ({{path}})': 'ะฃั€ะพะฒะตะฝัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ({{path}})', + 'Built-in Agents': 'ะ’ัั‚ั€ะพะตะฝะฝั‹ะต ะฐะณะตะฝั‚ั‹', + 'Using: {{count}} agents': 'ะ˜ัะฟะพะปัŒะทัƒะตั‚ัั: {{count}} ะฐะณะตะฝั‚(ะพะฒ)', + 'View Agent': 'ะŸั€ะพัะผะพั‚ั€ะตั‚ัŒ ะฐะณะตะฝั‚ะฐ', + 'Edit Agent': 'ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ะฐะณะตะฝั‚ะฐ', + 'Delete Agent': 'ะฃะดะฐะปะธั‚ัŒ ะฐะณะตะฝั‚ะฐ', + Back: 'ะะฐะทะฐะด', + 'No agent selected': 'ะะณะตะฝั‚ ะฝะต ะฒั‹ะฑั€ะฐะฝ', + 'File Path: ': 'ะŸัƒั‚ัŒ ะบ ั„ะฐะนะปัƒ: ', + 'Tools: ': 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚ั‹: ', + 'Color: ': 'ะฆะฒะตั‚: ', + 'Description:': 'ะžะฟะธัะฐะฝะธะต:', + 'System Prompt:': 'ะกะธัั‚ะตะผะฝั‹ะน ะฟั€ะพะผะฟั‚:', + 'Open in editor': 'ะžั‚ะบั€ั‹ั‚ัŒ ะฒ ั€ะตะดะฐะบั‚ะพั€ะต', + 'Edit tools': 'ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹', + 'Edit color': 'ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ั†ะฒะตั‚', + 'โŒ Error:': 'โŒ ะžัˆะธะฑะบะฐ:', + 'Are you sure you want to delete agent "{{name}}"?': + 'ะ’ั‹ ัƒะฒะตั€ะตะฝั‹, ั‡ั‚ะพ ั…ะพั‚ะธั‚ะต ัƒะดะฐะปะธั‚ัŒ ะฐะณะตะฝั‚ะฐ "{{name}}"?', + // ============================================================================ + // ะะณะตะฝั‚ั‹ - ะœะฐัั‚ะตั€ ัะพะทะดะฐะฝะธั + // ============================================================================ + 'Project Level (.qwen/agents/)': 'ะฃั€ะพะฒะตะฝัŒ ะฟั€ะพะตะบั‚ะฐ (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'ะฃั€ะพะฒะตะฝัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั (~/.qwen/agents/)', + 'โœ… Subagent Created Successfully!': 'โœ… ะŸะพะดะฐะณะตะฝั‚ ัƒัะฟะตัˆะฝะพ ัะพะทะดะฐะฝ!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'ะŸะพะดะฐะณะตะฝั‚ "{{name}}" ัะพั…ั€ะฐะฝะตะฝ ะฝะฐ ัƒั€ะพะฒะฝะต {{level}}.', + 'Name: ': 'ะ˜ะผั: ', + 'Location: ': 'ะ ะฐัะฟะพะปะพะถะตะฝะธะต: ', + 'โŒ Error saving subagent:': 'โŒ ะžัˆะธะฑะบะฐ ัะพั…ั€ะฐะฝะตะฝะธั ะฟะพะดะฐะณะตะฝั‚ะฐ:', + 'Warnings:': 'ะŸั€ะตะดัƒะฟั€ะตะถะดะตะฝะธั:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'ะ˜ะผั "{{name}}" ัƒะถะต ััƒั‰ะตัั‚ะฒัƒะตั‚ ะฝะฐ ัƒั€ะพะฒะฝะต {{level}} - ััƒั‰ะตัั‚ะฒัƒัŽั‰ะธะน ะฟะพะดะฐะณะตะฝั‚ ะฑัƒะดะตั‚ ะฟะตั€ะตะทะฐะฟะธัะฐะฝ', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'ะ˜ะผั "{{name}}" ััƒั‰ะตัั‚ะฒัƒะตั‚ ะฝะฐ ัƒั€ะพะฒะฝะต ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั - ัƒั€ะพะฒะตะฝัŒ ะฟั€ะพะตะบั‚ะฐ ะฑัƒะดะตั‚ ะธะผะตั‚ัŒ ะฟั€ะธะพั€ะธั‚ะตั‚', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'ะ˜ะผั "{{name}}" ััƒั‰ะตัั‚ะฒัƒะตั‚ ะฝะฐ ัƒั€ะพะฒะฝะต ะฟั€ะพะตะบั‚ะฐ - ััƒั‰ะตัั‚ะฒัƒัŽั‰ะธะน ะฟะพะดะฐะณะตะฝั‚ ะฑัƒะดะตั‚ ะธะผะตั‚ัŒ ะฟั€ะธะพั€ะธั‚ะตั‚', + 'Description is over {{length}} characters': + 'ะžะฟะธัะฐะฝะธะต ะฟั€ะตะฒั‹ัˆะฐะตั‚ {{length}} ัะธะผะฒะพะปะพะฒ', + 'System prompt is over {{length}} characters': + 'ะกะธัั‚ะตะผะฝั‹ะน ะฟั€ะพะผะฟั‚ ะฟั€ะตะฒั‹ัˆะฐะตั‚ {{length}} ัะธะผะฒะพะปะพะฒ', + // ะะณะตะฝั‚ั‹ - ะจะฐะณะธ ะผะฐัั‚ะตั€ะฐ ัะพะทะดะฐะฝะธั + 'Step {{n}}: Choose Location': 'ะจะฐะณ {{n}}: ะ’ั‹ะฑะตั€ะธั‚ะต ั€ะฐัะฟะพะปะพะถะตะฝะธะต', + 'Step {{n}}: Choose Generation Method': 'ะจะฐะณ {{n}}: ะ’ั‹ะฑะตั€ะธั‚ะต ะผะตั‚ะพะด ะณะตะฝะตั€ะฐั†ะธะธ', + 'Generate with Qwen Code (Recommended)': + 'ะกะณะตะฝะตั€ะธั€ะพะฒะฐั‚ัŒ ั ะฟะพะผะพั‰ัŒัŽ Qwen Code (ะ ะตะบะพะผะตะฝะดัƒะตั‚ัั)', + 'Manual Creation': 'ะ ัƒั‡ะฝะพะต ัะพะทะดะฐะฝะธะต', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'ะžะฟะธัˆะธั‚ะต, ั‡ั‚ะพ ะดะพะปะถะตะฝ ะดะตะปะฐั‚ัŒ ัั‚ะพั‚ ะฟะพะดะฐะณะตะฝั‚ ะธ ะบะพะณะดะฐ ะตะณะพ ัะปะตะดัƒะตั‚ ะธัะฟะพะปัŒะทะพะฒะฐั‚ัŒ. (ะ‘ัƒะดัŒั‚ะต ะฟะพะดั€ะพะฑะฝั‹ ะดะปั ะปัƒั‡ัˆะธั… ั€ะตะทัƒะปัŒั‚ะฐั‚ะพะฒ)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'ะฝะฐะฟั€ะธะผะตั€, ะญะบัะฟะตั€ั‚ะฝั‹ะน ั€ะตะฒัŒัŽะฒะตั€ ะบะพะดะฐ, ะฟั€ะพะฒะตั€ััŽั‰ะธะน ะบะพะด ะฝะฐ ัะพะพั‚ะฒะตั‚ัั‚ะฒะธะต ะปัƒั‡ัˆะธะผ ะฟั€ะฐะบั‚ะธะบะฐะผ...', + 'Generating subagent configuration...': 'ะ“ะตะฝะตั€ะฐั†ะธั ะบะพะฝั„ะธะณัƒั€ะฐั†ะธะธ ะฟะพะดะฐะณะตะฝั‚ะฐ...', + 'Failed to generate subagent: {{error}}': + 'ะะต ัƒะดะฐะปะพััŒ ัะณะตะฝะตั€ะธั€ะพะฒะฐั‚ัŒ ะฟะพะดะฐะณะตะฝั‚ะฐ: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'ะจะฐะณ {{n}}: ะžะฟะธัˆะธั‚ะต ะฟะพะดะฐะณะตะฝั‚ะฐ', + 'Step {{n}}: Enter Subagent Name': 'ะจะฐะณ {{n}}: ะ’ะฒะตะดะธั‚ะต ะธะผั ะฟะพะดะฐะณะตะฝั‚ะฐ', + 'Step {{n}}: Enter System Prompt': 'ะจะฐะณ {{n}}: ะ’ะฒะตะดะธั‚ะต ัะธัั‚ะตะผะฝั‹ะน ะฟั€ะพะผะฟั‚', + 'Step {{n}}: Enter Description': 'ะจะฐะณ {{n}}: ะ’ะฒะตะดะธั‚ะต ะพะฟะธัะฐะฝะธะต', + // ะะณะตะฝั‚ั‹ - ะ’ั‹ะฑะพั€ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ + 'Step {{n}}: Select Tools': 'ะจะฐะณ {{n}}: ะ’ั‹ะฑะตั€ะธั‚ะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹', + 'All Tools (Default)': 'ะ’ัะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹ (ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ)', + 'All Tools': 'ะ’ัะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹', + 'Read-only Tools': 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ั‚ะพะปัŒะบะพ ะดะปั ั‡ั‚ะตะฝะธั', + 'Read & Edit Tools': 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ะดะปั ั‡ั‚ะตะฝะธั ะธ ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธั', + 'Read & Edit & Execution Tools': + 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ะดะปั ั‡ั‚ะตะฝะธั, ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธั ะธ ะฒั‹ะฟะพะปะฝะตะฝะธั', + 'All tools selected, including MCP tools': + 'ะ’ัะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ะฒั‹ะฑั€ะฐะฝั‹, ะฒะบะปัŽั‡ะฐั ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹ MCP', + 'Selected tools:': 'ะ’ั‹ะฑั€ะฐะฝะฝั‹ะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹:', + 'Read-only tools:': 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ั‚ะพะปัŒะบะพ ะดะปั ั‡ั‚ะตะฝะธั:', + 'Edit tools:': 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธั:', + 'Execution tools:': 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ะฒั‹ะฟะพะปะฝะตะฝะธั:', + 'Step {{n}}: Choose Background Color': 'ะจะฐะณ {{n}}: ะ’ั‹ะฑะตั€ะธั‚ะต ั†ะฒะตั‚ ั„ะพะฝะฐ', + 'Step {{n}}: Confirm and Save': 'ะจะฐะณ {{n}}: ะŸะพะดั‚ะฒะตั€ะดะธั‚ะต ะธ ัะพั…ั€ะฐะฝะธั‚ะต', + // ะะณะตะฝั‚ั‹ - ะะฐะฒะธะณะฐั†ะธั ะธ ะธะฝัั‚ั€ัƒะบั†ะธะธ + 'Esc to cancel': 'Esc ะดะปั ะพั‚ะผะตะฝั‹', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Enter ะดะปั ัะพั…ั€ะฐะฝะตะฝะธั, e ะดะปั ัะพั…ั€ะฐะฝะตะฝะธั ะธ ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธั, Esc ะดะปั ะฒะพะทะฒั€ะฐั‚ะฐ', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Enter ะดะปั ะฟั€ะพะดะพะปะถะตะฝะธั, {{navigation}}Esc ะดะปั {{action}}', + cancel: 'ะพั‚ะผะตะฝั‹', + 'go back': 'ะฒะพะทะฒั€ะฐั‚ะฐ', + 'โ†‘โ†“ to navigate, ': 'โ†‘โ†“ ะดะปั ะฝะฐะฒะธะณะฐั†ะธะธ, ', + 'Enter a clear, unique name for this subagent.': + 'ะ’ะฒะตะดะธั‚ะต ั‡ะตั‚ะบะพะต, ัƒะฝะธะบะฐะปัŒะฝะพะต ะธะผั ะดะปั ัั‚ะพะณะพ ะฟะพะดะฐะณะตะฝั‚ะฐ.', + 'e.g., Code Reviewer': 'ะฝะฐะฟั€ะธะผะตั€, ะ ะตะฒัŒัŽะฒะตั€ ะบะพะดะฐ', + 'Name cannot be empty.': 'ะ˜ะผั ะฝะต ะผะพะถะตั‚ ะฑั‹ั‚ัŒ ะฟัƒัั‚ั‹ะผ.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'ะะฐะฟะธัˆะธั‚ะต ัะธัั‚ะตะผะฝั‹ะน ะฟั€ะพะผะฟั‚, ะพะฟั€ะตะดะตะปััŽั‰ะธะน ะฟะพะฒะตะดะตะฝะธะต ะฟะพะดะฐะณะตะฝั‚ะฐ. ะ‘ัƒะดัŒั‚ะต ะฟะพะดั€ะพะฑะฝั‹ ะดะปั ะปัƒั‡ัˆะธั… ั€ะตะทัƒะปัŒั‚ะฐั‚ะพะฒ.', + 'e.g., You are an expert code reviewer...': + 'ะฝะฐะฟั€ะธะผะตั€, ะ’ั‹ ัะบัะฟะตั€ั‚ะฝั‹ะน ั€ะตะฒัŒัŽะฒะตั€ ะบะพะดะฐ...', + 'System prompt cannot be empty.': 'ะกะธัั‚ะตะผะฝั‹ะน ะฟั€ะพะผะฟั‚ ะฝะต ะผะพะถะตั‚ ะฑั‹ั‚ัŒ ะฟัƒัั‚ั‹ะผ.', + 'Describe when and how this subagent should be used.': + 'ะžะฟะธัˆะธั‚ะต, ะบะพะณะดะฐ ะธ ะบะฐะบ ัะปะตะดัƒะตั‚ ะธัะฟะพะปัŒะทะพะฒะฐั‚ัŒ ัั‚ะพะณะพ ะฟะพะดะฐะณะตะฝั‚ะฐ.', + 'e.g., Reviews code for best practices and potential bugs.': + 'ะฝะฐะฟั€ะธะผะตั€, ะŸั€ะพะฒะตั€ัะตั‚ ะบะพะด ะฝะฐ ัะพะพั‚ะฒะตั‚ัั‚ะฒะธะต ะปัƒั‡ัˆะธะผ ะฟั€ะฐะบั‚ะธะบะฐะผ ะธ ะฟะพั‚ะตะฝั†ะธะฐะปัŒะฝั‹ะต ะพัˆะธะฑะบะธ.', + 'Description cannot be empty.': 'ะžะฟะธัะฐะฝะธะต ะฝะต ะผะพะถะตั‚ ะฑั‹ั‚ัŒ ะฟัƒัั‚ั‹ะผ.', + 'Failed to launch editor: {{error}}': + 'ะะต ัƒะดะฐะปะพััŒ ะทะฐะฟัƒัั‚ะธั‚ัŒ ั€ะตะดะฐะบั‚ะพั€: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'ะะต ัƒะดะฐะปะพััŒ ัะพั…ั€ะฐะฝะธั‚ัŒ ะธ ะพั‚ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ะฟะพะดะฐะณะตะฝั‚ะฐ: {{error}}', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะžะฑั‰ะธะต (ะฟั€ะพะดะพะปะถะตะฝะธะต) + // ============================================================================ + 'View and edit Qwen Code settings': 'ะŸั€ะพัะผะพั‚ั€ ะธ ะธะทะผะตะฝะตะฝะธะต ะฝะฐัั‚ั€ะพะตะบ Qwen Code', + Settings: 'ะะฐัั‚ั€ะพะนะบะธ', + '(Use Enter to select{{tabText}})': '(Enter ะดะปั ะฒั‹ะฑะพั€ะฐ{{tabText}})', + ', Tab to change focus': ', Tab ะดะปั ัะผะตะฝั‹ ั„ะพะบัƒัะฐ', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'ะ”ะปั ะฟั€ะธะผะตะฝะตะฝะธั ะธะทะผะตะฝะตะฝะธะน ะฝะตะพะฑั…ะพะดะธะผะพ ะฟะตั€ะตะทะฐะฟัƒัั‚ะธั‚ัŒ Qwen Code. ะะฐะถะผะธั‚ะต r ะดะปั ะฒั‹ั…ะพะดะฐ ะธ ะฟั€ะธะผะตะฝะตะฝะธั ะธะทะผะตะฝะตะฝะธะน.', + + // ============================================================================ + // ะœะตั‚ะบะธ ะฝะฐัั‚ั€ะพะตะบ + // ============================================================================ + 'Vim Mode': 'ะ ะตะถะธะผ Vim', + 'Disable Auto Update': 'ะžั‚ะบะปัŽั‡ะธั‚ัŒ ะฐะฒั‚ะพะพะฑะฝะพะฒะปะตะฝะธะต', + 'Enable Prompt Completion': 'ะ’ะบะปัŽั‡ะธั‚ัŒ ะฐะฒั‚ะพะดะพะฟะพะปะฝะตะฝะธะต ะฟั€ะพะผะฟั‚ะพะฒ', + 'Debug Keystroke Logging': 'ะ›ะพะณะธั€ะพะฒะฐะฝะธะต ะฝะฐะถะฐั‚ะธะน ะบะปะฐะฒะธัˆ ะดะปั ะพั‚ะปะฐะดะบะธ', + Language: 'ะฏะทั‹ะบ', + 'Output Format': 'ะคะพั€ะผะฐั‚ ะฒั‹ะฒะพะดะฐ', + 'Hide Window Title': 'ะกะบั€ั‹ั‚ัŒ ะทะฐะณะพะปะพะฒะพะบ ะพะบะฝะฐ', + 'Show Status in Title': 'ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ัั‚ะฐั‚ัƒั ะฒ ะทะฐะณะพะปะพะฒะบะต', + 'Hide Tips': 'ะกะบั€ั‹ั‚ัŒ ะฟะพะดัะบะฐะทะบะธ', + 'Hide Banner': 'ะกะบั€ั‹ั‚ัŒ ะฑะฐะฝะฝะตั€', + 'Hide Context Summary': 'ะกะบั€ั‹ั‚ัŒ ัะฒะพะดะบัƒ ะบะพะฝั‚ะตะบัั‚ะฐ', + 'Hide CWD': 'ะกะบั€ั‹ั‚ัŒ ั‚ะตะบัƒั‰ัƒัŽ ะดะธั€ะตะบั‚ะพั€ะธัŽ', + 'Hide Sandbox Status': 'ะกะบั€ั‹ั‚ัŒ ัั‚ะฐั‚ัƒั ะฟะตัะพั‡ะฝะธั†ั‹', + 'Hide Model Info': 'ะกะบั€ั‹ั‚ัŒ ะธะฝั„ะพั€ะผะฐั†ะธัŽ ะพ ะผะพะดะตะปะธ', + 'Hide Footer': 'ะกะบั€ั‹ั‚ัŒ ะฝะธะถะฝะธะน ะบะพะปะพะฝั‚ะธั‚ัƒะป', + 'Show Memory Usage': 'ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ะธัะฟะพะปัŒะทะพะฒะฐะฝะธะต ะฟะฐะผัั‚ะธ', + 'Show Line Numbers': 'ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ะฝะพะผะตั€ะฐ ัั‚ั€ะพะบ', + 'Show Citations': 'ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ั†ะธั‚ะฐั‚ั‹', + 'Custom Witty Phrases': 'ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะธะต ะพัั‚ั€ะพัƒะผะฝั‹ะต ั„ั€ะฐะทั‹', + 'Enable Welcome Back': 'ะ’ะบะปัŽั‡ะธั‚ัŒ ะฟั€ะธะฒะตั‚ัั‚ะฒะธะต ะฟั€ะธ ะฒะพะทะฒั€ะฐั‚ะต', + 'Disable Loading Phrases': 'ะžั‚ะบะปัŽั‡ะธั‚ัŒ ั„ั€ะฐะทั‹ ะฟั€ะธ ะทะฐะณั€ัƒะทะบะต', + 'Screen Reader Mode': 'ะ ะตะถะธะผ ะฟั€ะพะณั€ะฐะผะผั‹ ั‡ั‚ะตะฝะธั ั ัะบั€ะฐะฝะฐ', + 'IDE Mode': 'ะ ะตะถะธะผ IDE', + 'Max Session Turns': 'ะœะฐะบั. ะบะพะปะธั‡ะตัั‚ะฒะพ ั…ะพะดะพะฒ ัะตััะธะธ', + 'Skip Next Speaker Check': 'ะŸั€ะพะฟัƒัั‚ะธั‚ัŒ ะฟั€ะพะฒะตั€ะบัƒ ัะปะตะดัƒัŽั‰ะตะณะพ ะณะพะฒะพั€ัั‰ะตะณะพ', + 'Skip Loop Detection': 'ะŸั€ะพะฟัƒัั‚ะธั‚ัŒ ะพะฑะฝะฐั€ัƒะถะตะฝะธะต ั†ะธะบะปะพะฒ', + 'Skip Startup Context': 'ะŸั€ะพะฟัƒัั‚ะธั‚ัŒ ะฝะฐั‡ะฐะปัŒะฝั‹ะน ะบะพะฝั‚ะตะบัั‚', + 'Enable OpenAI Logging': 'ะ’ะบะปัŽั‡ะธั‚ัŒ ะปะพะณะธั€ะพะฒะฐะฝะธะต OpenAI', + 'OpenAI Logging Directory': 'ะ”ะธั€ะตะบั‚ะพั€ะธั ะปะพะณะพะฒ OpenAI', + Timeout: 'ะขะฐะนะผะฐัƒั‚', + 'Max Retries': 'ะœะฐะบั. ะบะพะปะธั‡ะตัั‚ะฒะพ ะฟะพะฟั‹ั‚ะพะบ', + 'Disable Cache Control': 'ะžั‚ะบะปัŽั‡ะธั‚ัŒ ัƒะฟั€ะฐะฒะปะตะฝะธะต ะบััˆะตะผ', + 'Memory Discovery Max Dirs': 'ะœะฐะบั. ะดะธั€ะตะบั‚ะพั€ะธะน ะดะปั ะฟะพะธัะบะฐ ะฒ ะฟะฐะผัั‚ะธ', + 'Load Memory From Include Directories': + 'ะ—ะฐะณั€ัƒะถะฐั‚ัŒ ะฟะฐะผัั‚ัŒ ะธะท ะฒะบะปัŽั‡ะตะฝะฝั‹ั… ะดะธั€ะตะบั‚ะพั€ะธะน', + 'Respect .gitignore': 'ะฃั‡ะธั‚ั‹ะฒะฐั‚ัŒ .gitignore', + 'Respect .qwenignore': 'ะฃั‡ะธั‚ั‹ะฒะฐั‚ัŒ .qwenignore', + 'Enable Recursive File Search': 'ะ’ะบะปัŽั‡ะธั‚ัŒ ั€ะตะบัƒั€ัะธะฒะฝั‹ะน ะฟะพะธัะบ ั„ะฐะนะปะพะฒ', + 'Disable Fuzzy Search': 'ะžั‚ะบะปัŽั‡ะธั‚ัŒ ะฝะตั‡ะตั‚ะบะธะน ะฟะพะธัะบ', + 'Enable Interactive Shell': 'ะ’ะบะปัŽั‡ะธั‚ัŒ ะธะฝั‚ะตั€ะฐะบั‚ะธะฒะฝั‹ะน ั‚ะตั€ะผะธะฝะฐะป', + 'Show Color': 'ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ั†ะฒะตั‚ะฐ', + 'Auto Accept': 'ะะฒั‚ะพะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต', + 'Use Ripgrep': 'ะ˜ัะฟะพะปัŒะทะพะฒะฐั‚ัŒ Ripgrep', + 'Use Builtin Ripgrep': 'ะ˜ัะฟะพะปัŒะทะพะฒะฐั‚ัŒ ะฒัั‚ั€ะพะตะฝะฝั‹ะน Ripgrep', + 'Enable Tool Output Truncation': 'ะ’ะบะปัŽั‡ะธั‚ัŒ ะพะฑั€ะตะทะบัƒ ะฒั‹ะฒะพะดะฐ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + 'Tool Output Truncation Threshold': 'ะŸะพั€ะพะณ ะพะฑั€ะตะทะบะธ ะฒั‹ะฒะพะดะฐ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + 'Tool Output Truncation Lines': 'ะ›ะธะผะธั‚ ัั‚ั€ะพะบ ะฒั‹ะฒะพะดะฐ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + 'Folder Trust': 'ะ”ะพะฒะตั€ะธะต ะบ ะฟะฐะฟะบะต', + 'Vision Model Preview': 'ะ’ะธะทัƒะฐะปัŒะฝะฐั ะผะพะดะตะปัŒ (ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€)', + // ะ’ะฐั€ะธะฐะฝั‚ั‹ ะฟะตั€ะตั‡ะธัะปะตะฝะธะน ะฝะฐัั‚ั€ะพะตะบ + 'Auto (detect from system)': 'ะะฒั‚ะพ (ะพะฟั€ะตะดะตะปะธั‚ัŒ ะธะท ัะธัั‚ะตะผั‹)', + Text: 'ะขะตะบัั‚', + JSON: 'JSON', + Plan: 'ะŸะปะฐะฝ', + Default: 'ะŸะพ ัƒะผะพะปั‡ะฐะฝะธัŽ', + 'Auto Edit': 'ะะฒั‚ะพั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธะต', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'ะ’ะบะปัŽั‡ะตะฝะธะต/ะฒั‹ะบะปัŽั‡ะตะฝะธะต ั€ะตะถะธะผะฐ vim', + 'check session stats. Usage: /stats [model|tools]': + 'ะŸั€ะพัะผะพั‚ั€ ัั‚ะฐั‚ะธัั‚ะธะบะธ ัะตััะธะธ. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'ะŸะพะบะฐะทะฐั‚ัŒ ัั‚ะฐั‚ะธัั‚ะธะบัƒ ะธัะฟะพะปัŒะทะพะฒะฐะฝะธั ะผะพะดะตะปะธ.', + 'Show tool-specific usage statistics.': + 'ะŸะพะบะฐะทะฐั‚ัŒ ัั‚ะฐั‚ะธัั‚ะธะบัƒ ะธัะฟะพะปัŒะทะพะฒะฐะฝะธั ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ.', + 'exit the cli': 'ะ’ั‹ั…ะพะด ะธะท CLI', + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'ะŸะพะบะฐะทะฐั‚ัŒ ะฝะฐัั‚ั€ะพะตะฝะฝั‹ะต MCP-ัะตั€ะฒะตั€ั‹ ะธ ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹, ะธะปะธ ะฐะฒั‚ะพั€ะธะทะพะฒะฐั‚ัŒัั ะฝะฐ ัะตั€ะฒะตั€ะฐั… ั ะฟะพะดะดะตั€ะถะบะพะน OAuth', + 'Manage workspace directories': + 'ะฃะฟั€ะฐะฒะปะตะฝะธะต ะดะธั€ะตะบั‚ะพั€ะธัะผะธ ั€ะฐะฑะพั‡ะตะณะพ ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะฐ', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'ะ”ะพะฑะฐะฒะธั‚ัŒ ะดะธั€ะตะบั‚ะพั€ะธะธ ะฒ ั€ะฐะฑะพั‡ะตะต ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะพ. ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต ะทะฐะฟัั‚ัƒัŽ ะดะปั ั€ะฐะทะดะตะปะตะฝะธั ะฟัƒั‚ะตะน', + 'Show all directories in the workspace': + 'ะŸะพะบะฐะทะฐั‚ัŒ ะฒัะต ะดะธั€ะตะบั‚ะพั€ะธะธ ะฒ ั€ะฐะฑะพั‡ะตะผ ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะต', + 'set external editor preference': + 'ะฃัั‚ะฐะฝะพะฒะบะฐ ะฟั€ะตะดะฟะพั‡ะธั‚ะฐะตะผะพะณะพ ะฒะฝะตัˆะฝะตะณะพ ั€ะตะดะฐะบั‚ะพั€ะฐ', + 'Manage extensions': 'ะฃะฟั€ะฐะฒะปะตะฝะธะต ั€ะฐััˆะธั€ะตะฝะธัะผะธ', + 'List active extensions': 'ะŸะพะบะฐะทะฐั‚ัŒ ะฐะบั‚ะธะฒะฝั‹ะต ั€ะฐััˆะธั€ะตะฝะธั', + 'Update extensions. Usage: update |--all': + 'ะžะฑะฝะพะฒะธั‚ัŒ ั€ะฐััˆะธั€ะตะฝะธั. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: update |--all', + 'manage IDE integration': 'ะฃะฟั€ะฐะฒะปะตะฝะธะต ะธะฝั‚ะตะณั€ะฐั†ะธะตะน ั IDE', + 'check status of IDE integration': 'ะŸั€ะพะฒะตั€ะธั‚ัŒ ัั‚ะฐั‚ัƒั ะธะฝั‚ะตะณั€ะฐั†ะธะธ ั IDE', + 'install required IDE companion for {{ideName}}': + 'ะฃัั‚ะฐะฝะพะฒะธั‚ัŒ ะฝะตะพะฑั…ะพะดะธะผั‹ะน ะบะพะผะฟะฐะฝัŒะพะฝ IDE ะดะปั {{ideName}}', + 'enable IDE integration': 'ะ’ะบะปัŽั‡ะตะฝะธะต ะธะฝั‚ะตะณั€ะฐั†ะธะธ ั IDE', + 'disable IDE integration': 'ะžั‚ะบะปัŽั‡ะตะฝะธะต ะธะฝั‚ะตะณั€ะฐั†ะธะธ ั IDE', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'ะ˜ะฝั‚ะตะณั€ะฐั†ะธั ั IDE ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั ะฒ ะฒะฐัˆะตะผ ะพะบั€ัƒะถะตะฝะธะธ. ะ”ะปั ะธัะฟะพะปัŒะทะพะฒะฐะฝะธั ัั‚ะพะน ั„ัƒะฝะบั†ะธะธ ะทะฐะฟัƒัั‚ะธั‚ะต Qwen Code ะฒ ะพะดะฝะพะน ะธะท ะฟะพะดะดะตั€ะถะธะฒะฐะตะผั‹ั… IDE: VS Code ะธะปะธ ั„ะพั€ะบะฐั… VS Code.', + 'Set up GitHub Actions': 'ะะฐัั‚ั€ะพะนะบะฐ GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'ะะฐัั‚ั€ะพะนะบะฐ ะฟั€ะธะฒัะทะบะธ ะบะปะฐะฒะธัˆ ั‚ะตั€ะผะธะฝะฐะปะฐ ะดะปั ะผะฝะพะณะพัั‚ั€ะพั‡ะฝะพะณะพ ะฒะฒะพะดะฐ (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะตั€ะตะทะฐะฟัƒัั‚ะธั‚ะต ั‚ะตั€ะผะธะฝะฐะป ะดะปั ะฟั€ะธะผะตะฝะตะฝะธั ะธะทะผะตะฝะตะฝะธะน.', + 'Failed to configure terminal: {{error}}': + 'ะะต ัƒะดะฐะปะพััŒ ะฝะฐัั‚ั€ะพะธั‚ัŒ ั‚ะตั€ะผะธะฝะฐะป: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'ะะต ัƒะดะฐะปะพััŒ ะพะฟั€ะตะดะตะปะธั‚ัŒ ะฟัƒั‚ัŒ ะบะพะฝั„ะธะณัƒั€ะฐั†ะธะธ {{terminalName}} ะฒ Windows: ะฟะตั€ะตะผะตะฝะฝะฐั ะพะบั€ัƒะถะตะฝะธั APPDATA ะฝะต ัƒัั‚ะฐะฝะพะฒะปะตะฝะฐ.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json ััƒั‰ะตัั‚ะฒัƒะตั‚, ะฝะพ ะฝะต ัะฒะปัะตั‚ัั ะบะพั€ั€ะตะบั‚ะฝั‹ะผ ะผะฐััะธะฒะพะผ JSON. ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะธัะฟั€ะฐะฒัŒั‚ะต ั„ะฐะนะป ะฒั€ัƒั‡ะฝัƒัŽ ะธะปะธ ัƒะดะฐะปะธั‚ะต ะตะณะพ ะดะปั ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะพะน ะฝะฐัั‚ั€ะพะนะบะธ.', + 'File: {{file}}': 'ะคะฐะนะป: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'ะะต ัƒะดะฐะปะพััŒ ั€ะฐะทะพะฑั€ะฐั‚ัŒ {{terminalName}} keybindings.json. ะคะฐะนะป ัะพะดะตั€ะถะธั‚ ะฝะตะบะพั€ั€ะตะบั‚ะฝั‹ะน JSON. ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะธัะฟั€ะฐะฒัŒั‚ะต ั„ะฐะนะป ะฒั€ัƒั‡ะฝัƒัŽ ะธะปะธ ัƒะดะฐะปะธั‚ะต ะตะณะพ ะดะปั ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะพะน ะฝะฐัั‚ั€ะพะนะบะธ.', + 'Error: {{error}}': 'ะžัˆะธะฑะบะฐ: {{error}}', + 'Shift+Enter binding already exists': 'ะŸั€ะธะฒัะทะบะฐ Shift+Enter ัƒะถะต ััƒั‰ะตัั‚ะฒัƒะตั‚', + 'Ctrl+Enter binding already exists': 'ะŸั€ะธะฒัะทะบะฐ Ctrl+Enter ัƒะถะต ััƒั‰ะตัั‚ะฒัƒะตั‚', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'ะžะฑะฝะฐั€ัƒะถะตะฝั‹ ััƒั‰ะตัั‚ะฒัƒัŽั‰ะธะต ะฟั€ะธะฒัะทะบะธ ะบะปะฐะฒะธัˆ. ะะต ะฑัƒะดัƒั‚ ะธะทะผะตะฝะตะฝั‹ ะฒะพ ะธะทะฑะตะถะฐะฝะธะต ะบะพะฝั„ะปะธะบั‚ะพะฒ.', + 'Please check and modify manually if needed: {{file}}': + 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟั€ะพะฒะตั€ัŒั‚ะต ะธ ะธะทะผะตะฝะธั‚ะต ะฒั€ัƒั‡ะฝัƒัŽ ะฟั€ะธ ะฝะตะพะฑั…ะพะดะธะผะพัั‚ะธ: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'ะ”ะพะฑะฐะฒะปะตะฝั‹ ะฟั€ะธะฒัะทะบะธ Shift+Enter ะธ Ctrl+Enter ะดะปั {{terminalName}}.', + 'Modified: {{file}}': 'ะ˜ะทะผะตะฝะตะฝะพ: {{file}}', + '{{terminalName}} keybindings already configured.': + 'ะŸั€ะธะฒัะทะบะธ ะบะปะฐะฒะธัˆ {{terminalName}} ัƒะถะต ะฝะฐัั‚ั€ะพะตะฝั‹.', + 'Failed to configure {{terminalName}}.': + 'ะะต ัƒะดะฐะปะพััŒ ะฝะฐัั‚ั€ะพะธั‚ัŒ {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'ะ’ะฐัˆ ั‚ะตั€ะผะธะฝะฐะป ัƒะถะต ะฝะฐัั‚ั€ะพะตะฝ ะดะปั ะพะฟั‚ะธะผะฐะปัŒะฝะพะน ั€ะฐะฑะพั‚ั‹ ั ะผะฝะพะณะพัั‚ั€ะพั‡ะฝั‹ะผ ะฒะฒะพะดะพะผ (Shift+Enter ะธ Ctrl+Enter).', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'ะะต ัƒะดะฐะปะพััŒ ะพะฟั€ะตะดะตะปะธั‚ัŒ ั‚ะธะฟ ั‚ะตั€ะผะธะฝะฐะปะฐ. ะŸะพะดะดะตั€ะถะธะฒะฐะตะผั‹ะต ั‚ะตั€ะผะธะฝะฐะปั‹: VS Code, Cursor, Windsurf ะธ Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'ะขะตั€ะผะธะฝะฐะป "{{terminal}}" ะตั‰ะต ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั.', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะฏะทั‹ะบ + // ============================================================================ + 'Invalid language. Available: en-US, zh-CN': + 'ะะตะฒะตั€ะฝั‹ะน ัะทั‹ะบ. ะ”ะพัั‚ัƒะฟะฝั‹: en-US, zh-CN, ru-RU', + 'Language subcommands do not accept additional arguments.': + 'ะŸะพะดะบะพะผะฐะฝะดั‹ ัะทั‹ะบะฐ ะฝะต ะฟั€ะธะฝะธะผะฐัŽั‚ ะดะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ั… ะฐั€ะณัƒะผะตะฝั‚ะพะฒ.', + 'Current UI language: {{lang}}': 'ะขะตะบัƒั‰ะธะน ัะทั‹ะบ ะธะฝั‚ะตั€ั„ะตะนัะฐ: {{lang}}', + 'Current LLM output language: {{lang}}': 'ะขะตะบัƒั‰ะธะน ัะทั‹ะบ ะฒั‹ะฒะพะดะฐ LLM: {{lang}}', + 'LLM output language not set': 'ะฏะทั‹ะบ ะฒั‹ะฒะพะดะฐ LLM ะฝะต ัƒัั‚ะฐะฝะพะฒะปะตะฝ', + 'Set UI language': 'ะฃัั‚ะฐะฝะพะฒะบะฐ ัะทั‹ะบะฐ ะธะฝั‚ะตั€ั„ะตะนัะฐ', + 'Set LLM output language': 'ะฃัั‚ะฐะฝะพะฒะบะฐ ัะทั‹ะบะฐ ะฒั‹ะฒะพะดะฐ LLM', + 'Usage: /language ui [zh-CN|en-US]': + 'ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /language ui [zh-CN|en-US|ru-RU]', + 'Usage: /language output ': 'ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /language output ', + 'Example: /language output ไธญๆ–‡': 'ะŸั€ะธะผะตั€: /language output ไธญๆ–‡', + 'Example: /language output English': 'ะŸั€ะธะผะตั€: /language output English', + 'Example: /language output ๆ—ฅๆœฌ่ชž': 'ะŸั€ะธะผะตั€: /language output ๆ—ฅๆœฌ่ชž', + 'UI language changed to {{lang}}': 'ะฏะทั‹ะบ ะธะฝั‚ะตั€ั„ะตะนัะฐ ะธะทะผะตะฝะตะฝ ะฝะฐ {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'ะคะฐะนะป ะฟั€ะฐะฒะธะป ัะทั‹ะบะฐ ะฒั‹ะฒะพะดะฐ LLM ัะพะทะดะฐะฝ ะฒ {{path}}', + 'Please restart the application for the changes to take effect.': + 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะตั€ะตะทะฐะฟัƒัั‚ะธั‚ะต ะฟั€ะธะปะพะถะตะฝะธะต ะดะปั ะฟั€ะธะผะตะฝะตะฝะธั ะธะทะผะตะฝะตะฝะธะน.', + 'Failed to generate LLM output language rule file: {{error}}': + 'ะะต ัƒะดะฐะปะพััŒ ัะพะทะดะฐั‚ัŒ ั„ะฐะนะป ะฟั€ะฐะฒะธะป ัะทั‹ะบะฐ ะฒั‹ะฒะพะดะฐ LLM: {{error}}', + 'Invalid command. Available subcommands:': + 'ะะตะฒะตั€ะฝะฐั ะบะพะผะฐะฝะดะฐ. ะ”ะพัั‚ัƒะฟะฝั‹ะต ะฟะพะดะบะพะผะฐะฝะดั‹:', + 'Available subcommands:': 'ะ”ะพัั‚ัƒะฟะฝั‹ะต ะฟะพะดะบะพะผะฐะฝะดั‹:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'ะ”ะปั ะทะฐะฟั€ะพัะฐ ะดะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ั… ัะทั‹ะบะพะฒั‹ั… ะฟะฐะบะตั‚ะพะฒ ะธะฝั‚ะตั€ั„ะตะนัะฐ, ะฟะพะถะฐะปัƒะนัั‚ะฐ, ัะพะทะดะฐะนั‚ะต ะพะฑั€ะฐั‰ะตะฝะธะต ะฝะฐ GitHub.', + 'Available options:': 'ะ”ะพัั‚ัƒะฟะฝั‹ะต ะฒะฐั€ะธะฐะฝั‚ั‹:', + ' - zh-CN: Simplified Chinese': ' - zh-CN: ะฃะฟั€ะพั‰ะตะฝะฝั‹ะน ะบะธั‚ะฐะนัะบะธะน', + ' - en-US: English': ' - en-US: ะะฝะณะปะธะนัะบะธะน', + ' - ru-RU: Russian': ' - ru-RU: ะ ัƒััะบะธะน', + 'Set UI language to Simplified Chinese (zh-CN)': + 'ะฃัั‚ะฐะฝะพะฒะธั‚ัŒ ัะทั‹ะบ ะธะฝั‚ะตั€ั„ะตะนัะฐ ะฝะฐ ัƒะฟั€ะพั‰ะตะฝะฝั‹ะน ะบะธั‚ะฐะนัะบะธะน (zh-CN)', + 'Set UI language to English (en-US)': + 'ะฃัั‚ะฐะฝะพะฒะธั‚ัŒ ัะทั‹ะบ ะธะฝั‚ะตั€ั„ะตะนัะฐ ะฝะฐ ะฐะฝะณะปะธะนัะบะธะน (en-US)', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะ ะตะถะธะผ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั + // ============================================================================ + 'Approval Mode': 'ะ ะตะถะธะผ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั', + 'Current approval mode: {{mode}}': 'ะขะตะบัƒั‰ะธะน ั€ะตะถะธะผ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั: {{mode}}', + 'Available approval modes:': 'ะ”ะพัั‚ัƒะฟะฝั‹ะต ั€ะตะถะธะผั‹ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั:', + 'Approval mode changed to: {{mode}}': + 'ะ ะตะถะธะผ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ะธะทะผะตะฝะตะฝ ะฝะฐ: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'ะ ะตะถะธะผ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ะธะทะผะตะฝะตะฝ ะฝะฐ: {{mode}} (ัะพั…ั€ะฐะฝะตะฝะพ ะฒ ะฝะฐัั‚ั€ะพะนะบะฐั… {{scope}}{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /approval-mode [--session|--user|--project]', + 'Scope subcommands do not accept additional arguments.': + 'ะŸะพะดะบะพะผะฐะฝะดั‹ ะพะฑะปะฐัั‚ะธ ะฝะต ะฟั€ะธะฝะธะผะฐัŽั‚ ะดะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ั… ะฐั€ะณัƒะผะตะฝั‚ะพะฒ.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'ะ ะตะถะธะผ ะฟะปะฐะฝะธั€ะพะฒะฐะฝะธั - ั‚ะพะปัŒะบะพ ะฐะฝะฐะปะธะท, ะฑะตะท ะธะทะผะตะฝะตะฝะธั ั„ะฐะนะปะพะฒ ะธะปะธ ะฒั‹ะฟะพะปะฝะตะฝะธั ะบะพะผะฐะฝะด', + 'Default mode - Require approval for file edits or shell commands': + 'ะ ะตะถะธะผ ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ - ั‚ั€ะตะฑัƒะตั‚ัั ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะดะปั ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธั ั„ะฐะนะปะพะฒ ะธะปะธ ะบะพะผะฐะฝะด ั‚ะตั€ะผะธะฝะฐะปะฐ', + 'Auto-edit mode - Automatically approve file edits': + 'ะ ะตะถะธะผ ะฐะฒั‚ะพั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธั - ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะพะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะธะทะผะตะฝะตะฝะธะน ั„ะฐะนะปะพะฒ', + 'YOLO mode - Automatically approve all tools': + 'ะ ะตะถะธะผ YOLO - ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะพะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะฒัะตั… ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + '{{mode}} mode': 'ะ ะตะถะธะผ {{mode}}', + 'Settings service is not available; unable to persist the approval mode.': + 'ะกะปัƒะถะฑะฐ ะฝะฐัั‚ั€ะพะตะบ ะฝะตะดะพัั‚ัƒะฟะฝะฐ; ะฝะตะฒะพะทะผะพะถะฝะพ ัะพั…ั€ะฐะฝะธั‚ัŒ ั€ะตะถะธะผ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั.', + 'Failed to save approval mode: {{error}}': + 'ะะต ัƒะดะฐะปะพััŒ ัะพั…ั€ะฐะฝะธั‚ัŒ ั€ะตะถะธะผ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั: {{error}}', + 'Failed to change approval mode: {{error}}': + 'ะะต ัƒะดะฐะปะพััŒ ะธะทะผะตะฝะธั‚ัŒ ั€ะตะถะธะผ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั: {{error}}', + 'Apply to current session only (temporary)': + 'ะŸั€ะธะผะตะฝะธั‚ัŒ ั‚ะพะปัŒะบะพ ะบ ั‚ะตะบัƒั‰ะตะน ัะตััะธะธ (ะฒั€ะตะผะตะฝะฝะพ)', + 'Persist for this project/workspace': + 'ะกะพั…ั€ะฐะฝะธั‚ัŒ ะดะปั ัั‚ะพะณะพ ะฟั€ะพะตะบั‚ะฐ/ั€ะฐะฑะพั‡ะตะณะพ ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะฐ', + 'Persist for this user on this machine': + 'ะกะพั…ั€ะฐะฝะธั‚ัŒ ะดะปั ัั‚ะพะณะพ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ะฝะฐ ัั‚ะพะน ะผะฐัˆะธะฝะต', + 'Analyze only, do not modify files or execute commands': + 'ะขะพะปัŒะบะพ ะฐะฝะฐะปะธะท, ะฑะตะท ะธะทะผะตะฝะตะฝะธั ั„ะฐะนะปะพะฒ ะธะปะธ ะฒั‹ะฟะพะปะฝะตะฝะธั ะบะพะผะฐะฝะด', + 'Require approval for file edits or shell commands': + 'ะขั€ะตะฑัƒะตั‚ัั ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะดะปั ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธั ั„ะฐะนะปะพะฒ ะธะปะธ ะบะพะผะฐะฝะด ั‚ะตั€ะผะธะฝะฐะปะฐ', + 'Automatically approve file edits': + 'ะะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ ะฟะพะดั‚ะฒะตั€ะถะดะฐั‚ัŒ ะธะทะผะตะฝะตะฝะธั ั„ะฐะนะปะพะฒ', + 'Automatically approve all tools': + 'ะะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ ะฟะพะดั‚ะฒะตั€ะถะดะฐั‚ัŒ ะฒัะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'ะ ะตะถะธะผ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ั€ะฐะฑะพั‡ะตะณะพ ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะฐ ััƒั‰ะตัั‚ะฒัƒะตั‚ ะธ ะธะผะตะตั‚ ะฟั€ะธะพั€ะธั‚ะตั‚. ะ˜ะทะผะตะฝะตะฝะธะต ะฝะฐ ัƒั€ะพะฒะฝะต ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ะฝะต ะฑัƒะดะตั‚ ะธะผะตั‚ัŒ ัั„ั„ะตะบั‚ะฐ.', + '(Use Enter to select, Tab to change focus)': + '(Enter ะดะปั ะฒั‹ะฑะพั€ะฐ, Tab ะดะปั ัะผะตะฝั‹ ั„ะพะบัƒัะฐ)', + 'Apply To': 'ะŸั€ะธะผะตะฝะธั‚ัŒ ะบ', + 'User Settings': 'ะะฐัั‚ั€ะพะนะบะธ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั', + 'Workspace Settings': 'ะะฐัั‚ั€ะพะนะบะธ ั€ะฐะฑะพั‡ะตะณะพ ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะฐ', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะŸะฐะผัั‚ัŒ + // ============================================================================ + 'Commands for interacting with memory.': + 'ะšะพะผะฐะฝะดั‹ ะดะปั ะฒะทะฐะธะผะพะดะตะนัั‚ะฒะธั ั ะฟะฐะผัั‚ัŒัŽ', + 'Show the current memory contents.': 'ะŸะพะบะฐะทะฐั‚ัŒ ั‚ะตะบัƒั‰ะตะต ัะพะดะตั€ะถะธะผะพะต ะฟะฐะผัั‚ะธ.', + 'Show project-level memory contents.': 'ะŸะพะบะฐะทะฐั‚ัŒ ะฟะฐะผัั‚ัŒ ัƒั€ะพะฒะฝั ะฟั€ะพะตะบั‚ะฐ.', + 'Show global memory contents.': 'ะŸะพะบะฐะทะฐั‚ัŒ ะณะปะพะฑะฐะปัŒะฝัƒัŽ ะฟะฐะผัั‚ัŒ.', + 'Add content to project-level memory.': + 'ะ”ะพะฑะฐะฒะธั‚ัŒ ัะพะดะตั€ะถะธะผะพะต ะฒ ะฟะฐะผัั‚ัŒ ัƒั€ะพะฒะฝั ะฟั€ะพะตะบั‚ะฐ.', + 'Add content to global memory.': 'ะ”ะพะฑะฐะฒะธั‚ัŒ ัะพะดะตั€ะถะธะผะพะต ะฒ ะณะปะพะฑะฐะปัŒะฝัƒัŽ ะฟะฐะผัั‚ัŒ.', + 'Refresh the memory from the source.': 'ะžะฑะฝะพะฒะธั‚ัŒ ะฟะฐะผัั‚ัŒ ะธะท ะธัั‚ะพั‡ะฝะธะบะฐ.', + 'Usage: /memory add --project ': + 'ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /memory add --project <ั‚ะตะบัั‚ ะดะปั ะทะฐะฟะพะผะธะฝะฐะฝะธั>', + 'Usage: /memory add --global ': + 'ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /memory add --global <ั‚ะตะบัั‚ ะดะปั ะทะฐะฟะพะผะธะฝะฐะฝะธั>', + 'Attempting to save to project memory: "{{text}}"': + 'ะŸะพะฟั‹ั‚ะบะฐ ัะพั…ั€ะฐะฝะธั‚ัŒ ะฒ ะฟะฐะผัั‚ัŒ ะฟั€ะพะตะบั‚ะฐ: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'ะŸะพะฟั‹ั‚ะบะฐ ัะพั…ั€ะฐะฝะธั‚ัŒ ะฒ ะณะปะพะฑะฐะปัŒะฝัƒัŽ ะฟะฐะผัั‚ัŒ: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'ะขะตะบัƒั‰ะตะต ัะพะดะตั€ะถะธะผะพะต ะฟะฐะผัั‚ะธ ะธะท {{count}} ั„ะฐะนะปะฐ(ะพะฒ):', + 'Memory is currently empty.': 'ะŸะฐะผัั‚ัŒ ะฒ ะฝะฐัั‚ะพัั‰ะตะต ะฒั€ะตะผั ะฟัƒัั‚ะฐ.', + 'Project memory file not found or is currently empty.': + 'ะคะฐะนะป ะฟะฐะผัั‚ะธ ะฟั€ะพะตะบั‚ะฐ ะฝะต ะฝะฐะนะดะตะฝ ะธะปะธ ะฒ ะฝะฐัั‚ะพัั‰ะตะต ะฒั€ะตะผั ะฟัƒัั‚.', + 'Global memory file not found or is currently empty.': + 'ะคะฐะนะป ะณะปะพะฑะฐะปัŒะฝะพะน ะฟะฐะผัั‚ะธ ะฝะต ะฝะฐะนะดะตะฝ ะธะปะธ ะฒ ะฝะฐัั‚ะพัั‰ะตะต ะฒั€ะตะผั ะฟัƒัั‚.', + 'Global memory is currently empty.': + 'ะ“ะปะพะฑะฐะปัŒะฝะฐั ะฟะฐะผัั‚ัŒ ะฒ ะฝะฐัั‚ะพัั‰ะตะต ะฒั€ะตะผั ะฟัƒัั‚ะฐ.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'ะกะพะดะตั€ะถะธะผะพะต ะณะปะพะฑะฐะปัŒะฝะพะน ะฟะฐะผัั‚ะธ:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'ะกะพะดะตั€ะถะธะผะพะต ะฟะฐะผัั‚ะธ ะฟั€ะพะตะบั‚ะฐ ะธะท {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': + 'ะŸะฐะผัั‚ัŒ ะฟั€ะพะตะบั‚ะฐ ะฒ ะฝะฐัั‚ะพัั‰ะตะต ะฒั€ะตะผั ะฟัƒัั‚ะฐ.', + 'Refreshing memory from source files...': + 'ะžะฑะฝะพะฒะปะตะฝะธะต ะฟะฐะผัั‚ะธ ะธะท ะธัั…ะพะดะฝั‹ั… ั„ะฐะนะปะพะฒ...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'ะ”ะพะฑะฐะฒะธั‚ัŒ ัะพะดะตั€ะถะธะผะพะต ะฒ ะฟะฐะผัั‚ัŒ. ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต --global ะดะปั ะณะปะพะฑะฐะปัŒะฝะพะน ะฟะฐะผัั‚ะธ ะธะปะธ --project ะดะปั ะฟะฐะผัั‚ะธ ะฟั€ะพะตะบั‚ะฐ.', + 'Usage: /memory add [--global|--project] ': + 'ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /memory add [--global|--project] <ั‚ะตะบัั‚ ะดะปั ะทะฐะฟะพะผะธะฝะฐะฝะธั>', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'ะŸะพะฟั‹ั‚ะบะฐ ัะพั…ั€ะฐะฝะธั‚ัŒ ะฒ ะฟะฐะผัั‚ัŒ {{scope}}: "{{fact}}"', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'ะะฒั‚ะพั€ะธะทะพะฒะฐั‚ัŒัั ะฝะฐ MCP-ัะตั€ะฒะตั€ะต ั ะฟะพะดะดะตั€ะถะบะพะน OAuth', + 'List configured MCP servers and tools': + 'ะŸั€ะพัะผะพั‚ั€ ะฝะฐัั‚ั€ะพะตะฝะฝั‹ั… MCP-ัะตั€ะฒะตั€ะพะฒ ะธ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + 'Restarts MCP servers.': 'ะŸะตั€ะตะทะฐะฟัƒัั‚ะธั‚ัŒ MCP-ัะตั€ะฒะตั€ั‹.', + 'Config not loaded.': 'ะšะพะฝั„ะธะณัƒั€ะฐั†ะธั ะฝะต ะทะฐะณั€ัƒะถะตะฝะฐ.', + 'Could not retrieve tool registry.': + 'ะะต ัƒะดะฐะปะพััŒ ะฟะพะปัƒั‡ะธั‚ัŒ ั€ะตะตัั‚ั€ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ.', + 'No MCP servers configured with OAuth authentication.': + 'ะะตั‚ MCP-ัะตั€ะฒะตั€ะพะฒ, ะฝะฐัั‚ั€ะพะตะฝะฝั‹ั… ั ะฐะฒั‚ะพั€ะธะทะฐั†ะธะตะน OAuth.', + 'MCP servers with OAuth authentication:': 'MCP-ัะตั€ะฒะตั€ั‹ ั ะฐะฒั‚ะพั€ะธะทะฐั†ะธะตะน OAuth:', + 'Use /mcp auth to authenticate.': + 'ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต /mcp auth <ะธะผั-ัะตั€ะฒะตั€ะฐ> ะดะปั ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ.', + "MCP server '{{name}}' not found.": "MCP-ัะตั€ะฒะตั€ '{{name}}' ะฝะต ะฝะฐะนะดะตะฝ.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "ะฃัะฟะตัˆะฝะพ ะฐะฒั‚ะพั€ะธะทะพะฒะฐะฝะพ ะธ ะพะฑะฝะพะฒะปะตะฝั‹ ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ะดะปั '{{name}}'.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "ะะต ัƒะดะฐะปะพััŒ ะฐะฒั‚ะพั€ะธะทะพะฒะฐั‚ัŒัั ะฝะฐ MCP-ัะตั€ะฒะตั€ะต '{{name}}': {{error}}", + "Re-discovering tools from '{{name}}'...": + "ะŸะพะฒั‚ะพั€ะฝะพะต ะพะฑะฝะฐั€ัƒะถะตะฝะธะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ ะพั‚ '{{name}}'...", + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะงะฐั‚ + // ============================================================================ + 'Manage conversation history.': 'ะฃะฟั€ะฐะฒะปะตะฝะธะต ะธัั‚ะพั€ะธะตะน ะดะธะฐะปะพะณะพะฒ.', + 'List saved conversation checkpoints': + 'ะŸะพะบะฐะทะฐั‚ัŒ ัะพั…ั€ะฐะฝะตะฝะฝั‹ะต ั‚ะพั‡ะบะธ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ะดะธะฐะปะพะณะฐ', + 'No saved conversation checkpoints found.': + 'ะะต ะฝะฐะนะดะตะฝะพ ัะพั…ั€ะฐะฝะตะฝะฝั‹ั… ั‚ะพั‡ะตะบ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ะดะธะฐะปะพะณะฐ.', + 'List of saved conversations:': 'ะกะฟะธัะพะบ ัะพั…ั€ะฐะฝะตะฝะฝั‹ั… ะดะธะฐะปะพะณะพะฒ:', + 'Note: Newest last, oldest first': + 'ะŸั€ะธะผะตั‡ะฐะฝะธะต: ะฝะพะฒั‹ะต ะฟะพัะปะตะดะฝะธะผะธ, ัั‚ะฐั€ั‹ะต ะฟะตั€ะฒั‹ะผะธ', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'ะกะพั…ั€ะฐะฝะธั‚ัŒ ั‚ะตะบัƒั‰ะธะน ะดะธะฐะปะพะณ ะบะฐะบ ั‚ะพั‡ะบัƒ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /chat save <ั‚ะตะณ>', + 'Missing tag. Usage: /chat save ': + 'ะžั‚ััƒั‚ัั‚ะฒัƒะตั‚ ั‚ะตะณ. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /chat save <ั‚ะตะณ>', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'ะฃะดะฐะปะธั‚ัŒ ั‚ะพั‡ะบัƒ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ะดะธะฐะปะพะณะฐ. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /chat delete <ั‚ะตะณ>', + 'Missing tag. Usage: /chat delete ': + 'ะžั‚ััƒั‚ัั‚ะฒัƒะตั‚ ั‚ะตะณ. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /chat delete <ั‚ะตะณ>', + "Conversation checkpoint '{{tag}}' has been deleted.": + "ะขะพั‡ะบะฐ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ะดะธะฐะปะพะณะฐ '{{tag}}' ัƒะดะฐะปะตะฝะฐ.", + "Error: No checkpoint found with tag '{{tag}}'.": + "ะžัˆะธะฑะบะฐ: ั‚ะพั‡ะบะฐ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ั ั‚ะตะณะพะผ '{{tag}}' ะฝะต ะฝะฐะนะดะตะฝะฐ.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'ะ’ะพะทะพะฑะฝะพะฒะธั‚ัŒ ะดะธะฐะปะพะณ ะธะท ั‚ะพั‡ะบะธ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /chat resume <ั‚ะตะณ>', + 'Missing tag. Usage: /chat resume ': + 'ะžั‚ััƒั‚ัั‚ะฒัƒะตั‚ ั‚ะตะณ. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /chat resume <ั‚ะตะณ>', + 'No saved checkpoint found with tag: {{tag}}.': + 'ะะต ะฝะฐะนะดะตะฝะฐ ัะพั…ั€ะฐะฝะตะฝะฝะฐั ั‚ะพั‡ะบะฐ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ั ั‚ะตะณะพะผ: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'ะขะพั‡ะบะฐ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ั ั‚ะตะณะพะผ {{tag}} ัƒะถะต ััƒั‰ะตัั‚ะฒัƒะตั‚. ะŸะตั€ะตะทะฐะฟะธัะฐั‚ัŒ?', + 'No chat client available to save conversation.': + 'ะะตั‚ ะดะพัั‚ัƒะฟะฝะพะณะพ ะบะปะธะตะฝั‚ะฐ ั‡ะฐั‚ะฐ ะดะปั ัะพั…ั€ะฐะฝะตะฝะธั ะดะธะฐะปะพะณะฐ.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'ะขะพั‡ะบะฐ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ะดะธะฐะปะพะณะฐ ัะพั…ั€ะฐะฝะตะฝะฐ ั ั‚ะตะณะพะผ: {{tag}}.', + 'No conversation found to save.': 'ะะตั‚ ะดะธะฐะปะพะณะฐ ะดะปั ัะพั…ั€ะฐะฝะตะฝะธั.', + 'No chat client available to share conversation.': + 'ะะตั‚ ะดะพัั‚ัƒะฟะฝะพะณะพ ะบะปะธะตะฝั‚ะฐ ั‡ะฐั‚ะฐ ะดะปั ัะบัะฟะพั€ั‚ะฐ ะดะธะฐะปะพะณะฐ.', + 'Invalid file format. Only .md and .json are supported.': + 'ะะตะฒะตั€ะฝั‹ะน ั„ะพั€ะผะฐั‚ ั„ะฐะนะปะฐ. ะŸะพะดะดะตั€ะถะธะฒะฐัŽั‚ัั ั‚ะพะปัŒะบะพ .md ะธ .json.', + 'Error sharing conversation: {{error}}': + 'ะžัˆะธะฑะบะฐ ะฟั€ะธ ัะบัะฟะพั€ั‚ะต ะดะธะฐะปะพะณะฐ: {{error}}', + 'Conversation shared to {{filePath}}': 'ะ”ะธะฐะปะพะณ ัะบัะฟะพั€ั‚ะธั€ะพะฒะฐะฝ ะฒ {{filePath}}', + 'No conversation found to share.': 'ะะตั‚ ะดะธะฐะปะพะณะฐ ะดะปั ัะบัะฟะพั€ั‚ะฐ.', + 'Share the current conversation to a markdown or json file. Usage: /chat share <ะฟัƒั‚ัŒ-ะบ-ั„ะฐะนะปัƒ>': + 'ะญะบัะฟะพั€ั‚ะธั€ะพะฒะฐั‚ัŒ ั‚ะตะบัƒั‰ะธะน ะดะธะฐะปะพะณ ะฒ markdown ะธะปะธ json ั„ะฐะนะป. ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต: /chat share <ะฟัƒั‚ัŒ-ะบ-ั„ะฐะนะปัƒ>', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะ ะตะทัŽะผะต + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'ะกะณะตะฝะตั€ะธั€ะพะฒะฐั‚ัŒ ัะฒะพะดะบัƒ ะฟั€ะพะตะบั‚ะฐ ะธ ัะพั…ั€ะฐะฝะธั‚ัŒ ะตั‘ ะฒ .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + 'ะะตั‚ ะดะพัั‚ัƒะฟะฝะพะณะพ ั‡ะฐั‚-ะบะปะธะตะฝั‚ะฐ ะดะปั ะณะตะฝะตั€ะฐั†ะธะธ ัะฒะพะดะบะธ.', + 'Already generating summary, wait for previous request to complete': + 'ะ“ะตะฝะตั€ะฐั†ะธั ัะฒะพะดะบะธ ัƒะถะต ะฒั‹ะฟะพะปะฝัะตั‚ัั, ะดะพะถะดะธั‚ะตััŒ ะทะฐะฒะตั€ัˆะตะฝะธั ะฟั€ะตะดั‹ะดัƒั‰ะตะณะพ ะทะฐะฟั€ะพัะฐ', + 'No conversation found to summarize.': + 'ะะต ะฝะฐะนะดะตะฝะพ ะดะธะฐะปะพะณะพะฒ ะดะปั ัะพะทะดะฐะฝะธั ัะฒะพะดะบะธ.', + 'Failed to generate project context summary: {{error}}': + 'ะะต ัƒะดะฐะปะพััŒ ัะณะตะฝะตั€ะธั€ะพะฒะฐั‚ัŒ ัะฒะพะดะบัƒ ะบะพะฝั‚ะตะบัั‚ะฐ ะฟั€ะพะตะบั‚ะฐ: {{error}}', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะœะพะดะตะปัŒ + // ============================================================================ + 'Switch the model for this session': 'ะŸะตั€ะตะบะปัŽั‡ะตะฝะธะต ะผะพะดะตะปะธ ะดะปั ัั‚ะพะน ัะตััะธะธ', + 'Content generator configuration not available.': + 'ะšะพะฝั„ะธะณัƒั€ะฐั†ะธั ะณะตะฝะตั€ะฐั‚ะพั€ะฐ ัะพะดะตั€ะถะธะผะพะณะพ ะฝะตะดะพัั‚ัƒะฟะฝะฐ.', + 'Authentication type not available.': 'ะขะธะฟ ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ ะฝะตะดะพัั‚ัƒะฟะตะฝ.', + 'No models available for the current authentication type ({{authType}}).': + 'ะะตั‚ ะดะพัั‚ัƒะฟะฝั‹ั… ะผะพะดะตะปะตะน ะดะปั ั‚ะตะบัƒั‰ะตะณะพ ั‚ะธะฟะฐ ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ ({{authType}}).', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะžั‡ะธัั‚ะบะฐ + // ============================================================================ + 'Clearing terminal and resetting chat.': 'ะžั‡ะธัั‚ะบะฐ ั‚ะตั€ะผะธะฝะฐะปะฐ ะธ ัะฑั€ะพั ั‡ะฐั‚ะฐ.', + 'Clearing terminal.': 'ะžั‡ะธัั‚ะบะฐ ั‚ะตั€ะผะธะฝะฐะปะฐ.', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะกะถะฐั‚ะธะต + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'ะฃะถะต ะฒั‹ะฟะพะปะฝัะตั‚ัั ัะถะฐั‚ะธะต, ะดะพะถะดะธั‚ะตััŒ ะทะฐะฒะตั€ัˆะตะฝะธั ะฟั€ะตะดั‹ะดัƒั‰ะตะณะพ ะทะฐะฟั€ะพัะฐ', + 'Failed to compress chat history.': 'ะะต ัƒะดะฐะปะพััŒ ัะถะฐั‚ัŒ ะธัั‚ะพั€ะธัŽ ั‡ะฐั‚ะฐ.', + 'Failed to compress chat history: {{error}}': + 'ะะต ัƒะดะฐะปะพััŒ ัะถะฐั‚ัŒ ะธัั‚ะพั€ะธัŽ ั‡ะฐั‚ะฐ: {{error}}', + 'Compressing chat history': 'ะกะถะฐั‚ะธะต ะธัั‚ะพั€ะธะธ ั‡ะฐั‚ะฐ', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'ะ˜ัั‚ะพั€ะธั ั‡ะฐั‚ะฐ ัะถะฐั‚ะฐ ั {{originalTokens}} ะดะพ {{newTokens}} ั‚ะพะบะตะฝะพะฒ.', + 'Compression was not beneficial for this history size.': + 'ะกะถะฐั‚ะธะต ะฝะต ะฑั‹ะปะพ ะฟะพะปะตะทะฝั‹ะผ ะดะปั ัั‚ะพะณะพ ั€ะฐะทะผะตั€ะฐ ะธัั‚ะพั€ะธะธ.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'ะกะถะฐั‚ะธะต ะธัั‚ะพั€ะธะธ ั‡ะฐั‚ะฐ ะฝะต ัƒะผะตะฝัŒัˆะธะปะพ ั€ะฐะทะผะตั€. ะญั‚ะพ ะผะพะถะตั‚ ัƒะบะฐะทั‹ะฒะฐั‚ัŒ ะฝะฐ ะฟั€ะพะฑะปะตะผั‹ ั ะฟั€ะพะผะฟั‚ะพะผ ัะถะฐั‚ะธั.', + 'Could not compress chat history due to a token counting error.': + 'ะะต ัƒะดะฐะปะพััŒ ัะถะฐั‚ัŒ ะธัั‚ะพั€ะธัŽ ั‡ะฐั‚ะฐ ะธะท-ะทะฐ ะพัˆะธะฑะบะธ ะฟะพะดัั‡ะตั‚ะฐ ั‚ะพะบะตะฝะพะฒ.', + 'Chat history is already compressed.': 'ะ˜ัั‚ะพั€ะธั ั‡ะฐั‚ะฐ ัƒะถะต ัะถะฐั‚ะฐ.', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะ”ะธั€ะตะบั‚ะพั€ะธั + // ============================================================================ + 'Configuration is not available.': 'ะšะพะฝั„ะธะณัƒั€ะฐั†ะธั ะฝะตะดะพัั‚ัƒะฟะฝะฐ.', + 'Please provide at least one path to add.': + 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ัƒะบะฐะถะธั‚ะต ั…ะพั‚ั ะฑั‹ ะพะดะธะฝ ะฟัƒั‚ัŒ ะดะปั ะดะพะฑะฐะฒะปะตะฝะธั.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'ะšะพะผะฐะฝะดะฐ /directory add ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั ะฒ ะพะณั€ะฐะฝะธั‡ะธั‚ะตะปัŒะฝั‹ั… ะฟั€ะพั„ะธะปัั… ะฟะตัะพั‡ะฝะธั†ั‹. ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะธัะฟะพะปัŒะทัƒะนั‚ะต --include-directories ะฟั€ะธ ะทะฐะฟัƒัะบะต ัะตััะธะธ.', + "Error adding '{{path}}': {{error}}": + "ะžัˆะธะฑะบะฐ ะฟั€ะธ ะดะพะฑะฐะฒะปะตะฝะธะธ '{{path}}': {{error}}", + 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': + 'ะฃัะฟะตัˆะฝะพ ะดะพะฑะฐะฒะปะตะฝั‹ ั„ะฐะนะปั‹ GEMINI.md ะธะท ัะปะตะดัƒัŽั‰ะธั… ะดะธั€ะตะบั‚ะพั€ะธะน (ะตัะปะธ ะพะฝะธ ะตัั‚ัŒ):\n- {{directories}}', + 'Error refreshing memory: {{error}}': + 'ะžัˆะธะฑะบะฐ ะฟั€ะธ ะพะฑะฝะพะฒะปะตะฝะธะธ ะฟะฐะผัั‚ะธ: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'ะฃัะฟะตัˆะฝะพ ะดะพะฑะฐะฒะปะตะฝั‹ ะดะธั€ะตะบั‚ะพั€ะธะธ:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'ะขะตะบัƒั‰ะธะต ะดะธั€ะตะบั‚ะพั€ะธะธ ั€ะฐะฑะพั‡ะตะณะพ ะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะฐ:\n{{directories}}', + + // ============================================================================ + // ะšะพะผะฐะฝะดั‹ - ะ”ะพะบัƒะผะตะฝั‚ะฐั†ะธั + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะพั‚ะบั€ะพะนั‚ะต ัะปะตะดัƒัŽั‰ะธะน URL ะฒ ะฑั€ะฐัƒะทะตั€ะต ะดะปั ะฟั€ะพัะผะพั‚ั€ะฐ ะดะพะบัƒะผะตะฝั‚ะฐั†ะธะธ:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'ะžั‚ะบั€ั‹ั‚ะธะต ะดะพะบัƒะผะตะฝั‚ะฐั†ะธะธ ะฒ ะฑั€ะฐัƒะทะตั€ะต: {{url}}', + + // ============================================================================ + // ะ”ะธะฐะปะพะณะธ - ะŸะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ + // ============================================================================ + 'Do you want to proceed?': 'ะ’ั‹ ั…ะพั‚ะธั‚ะต ะฟั€ะพะดะพะปะถะธั‚ัŒ?', + 'Yes, allow once': 'ะ”ะฐ, ั€ะฐะทั€ะตัˆะธั‚ัŒ ะพะดะธะฝ ั€ะฐะท', + 'Allow always': 'ะ’ัะตะณะดะฐ ั€ะฐะทั€ะตัˆะฐั‚ัŒ', + No: 'ะะตั‚', + 'No (esc)': 'ะะตั‚ (esc)', + 'Yes, allow always for this session': 'ะ”ะฐ, ะฒัะตะณะดะฐ ั€ะฐะทั€ะตัˆะฐั‚ัŒ ะดะปั ัั‚ะพะน ัะตััะธะธ', + 'Modify in progress:': 'ะ˜ะดะตั‚ ะธะทะผะตะฝะตะฝะธะต:', + 'Save and close external editor to continue': + 'ะกะพั…ั€ะฐะฝะธั‚ะต ะธ ะทะฐะบั€ะพะนั‚ะต ะฒะฝะตัˆะฝะธะน ั€ะตะดะฐะบั‚ะพั€ ะดะปั ะฟั€ะพะดะพะปะถะตะฝะธั', + 'Apply this change?': 'ะŸั€ะธะผะตะฝะธั‚ัŒ ัั‚ะพ ะธะทะผะตะฝะตะฝะธะต?', + 'Yes, allow always': 'ะ”ะฐ, ะฒัะตะณะดะฐ ั€ะฐะทั€ะตัˆะฐั‚ัŒ', + 'Modify with external editor': 'ะ˜ะทะผะตะฝะธั‚ัŒ ะฒะพ ะฒะฝะตัˆะฝะตะผ ั€ะตะดะฐะบั‚ะพั€ะต', + 'No, suggest changes (esc)': 'ะะตั‚, ะฟั€ะตะดะปะพะถะธั‚ัŒ ะธะทะผะตะฝะตะฝะธั (esc)', + "Allow execution of: '{{command}}'?": "ะ ะฐะทั€ะตัˆะธั‚ัŒ ะฒั‹ะฟะพะปะฝะตะฝะธะต: '{{command}}'?", + 'Yes, allow always ...': 'ะ”ะฐ, ะฒัะตะณะดะฐ ั€ะฐะทั€ะตัˆะฐั‚ัŒ ...', + 'Yes, and auto-accept edits': 'ะ”ะฐ, ะธ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ ะฟั€ะธะฝะธะผะฐั‚ัŒ ะฟั€ะฐะฒะบะธ', + 'Yes, and manually approve edits': 'ะ”ะฐ, ะธ ะฒั€ัƒั‡ะฝัƒัŽ ะฟะพะดั‚ะฒะตั€ะถะดะฐั‚ัŒ ะฟั€ะฐะฒะบะธ', + 'No, keep planning (esc)': 'ะะตั‚, ะฟั€ะพะดะพะปะถะธั‚ัŒ ะฟะปะฐะฝะธั€ะพะฒะฐะฝะธะต (esc)', + 'URLs to fetch:': 'URL ะดะปั ะทะฐะณั€ัƒะทะบะธ:', + 'MCP Server: {{server}}': 'MCP-ัะตั€ะฒะตั€: {{server}}', + 'Tool: {{tool}}': 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'ะ ะฐะทั€ะตัˆะธั‚ัŒ ะฒั‹ะฟะพะปะฝะตะฝะธะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ะฐ MCP "{{tool}}" ั ัะตั€ะฒะตั€ะฐ "{{server}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'ะ”ะฐ, ะฒัะตะณะดะฐ ั€ะฐะทั€ะตัˆะฐั‚ัŒ ะธะฝัั‚ั€ัƒะผะตะฝั‚ "{{tool}}" ั ัะตั€ะฒะตั€ะฐ "{{server}}"', + 'Yes, always allow all tools from server "{{server}}"': + 'ะ”ะฐ, ะฒัะตะณะดะฐ ั€ะฐะทั€ะตัˆะฐั‚ัŒ ะฒัะต ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ั ัะตั€ะฒะตั€ะฐ "{{server}}"', + + // ============================================================================ + // ะ”ะธะฐะปะพะณะธ - ะŸะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะพะฑะพะปะพั‡ะบะธ + // ============================================================================ + 'Shell Command Execution': 'ะ’ั‹ะฟะพะปะฝะตะฝะธะต ะบะพะผะฐะฝะดั‹ ั‚ะตั€ะผะธะฝะฐะปะฐ', + 'A custom command wants to run the following shell commands:': + 'ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะฐั ะบะพะผะฐะฝะดะฐ ั…ะพั‡ะตั‚ ะฒั‹ะฟะพะปะฝะธั‚ัŒ ัะปะตะดัƒัŽั‰ะธะต ะบะพะผะฐะฝะดั‹ ั‚ะตั€ะผะธะฝะฐะปะฐ:', + + // ============================================================================ + // ะ”ะธะฐะปะพะณะธ - ะšะฒะพั‚ะฐ ะฟะพะดะฟะธัะบะธ Pro + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'ะ˜ัั‡ะตั€ะฟะฐะฝะฐ ะบะฒะพั‚ะฐ ะฟะพะดะฟะธัะบะธ Pro ะดะปั {{model}}.', + 'Change auth (executes the /auth command)': + 'ะ˜ะทะผะตะฝะธั‚ัŒ ะฐะฒั‚ะพั€ะธะทะฐั†ะธัŽ (ะฒั‹ะฟะพะปะฝัะตั‚ ะบะพะผะฐะฝะดัƒ /auth)', + 'Continue with {{model}}': 'ะŸั€ะพะดะพะปะถะธั‚ัŒ ั {{model}}', + + // ============================================================================ + // ะ”ะธะฐะปะพะณะธ - ะŸั€ะธะฒะตั‚ัั‚ะฒะธะต ะฟั€ะธ ะฒะพะทะฒั€ะฐั‰ะตะฝะธะธ + // ============================================================================ + 'Current Plan:': 'ะขะตะบัƒั‰ะธะน ะฟะปะฐะฝ:', + 'Progress: {{done}}/{{total}} tasks completed': + 'ะŸั€ะพะณั€ะตัั: {{done}}/{{total}} ะทะฐะดะฐั‡ ะฒั‹ะฟะพะปะฝะตะฝะพ', + ', {{inProgress}} in progress': ', {{inProgress}} ะฒ ะฟั€ะพั†ะตััะต', + 'Pending Tasks:': 'ะžะถะธะดะฐัŽั‰ะธะต ะทะฐะดะฐั‡ะธ:', + 'What would you like to do?': 'ะงั‚ะพ ะฒั‹ ั…ะพั‚ะธั‚ะต ัะดะตะปะฐั‚ัŒ?', + 'Choose how to proceed with your session:': + 'ะ’ั‹ะฑะตั€ะธั‚ะต, ะบะฐะบ ะฟั€ะพะดะพะปะถะธั‚ัŒ ัะตััะธัŽ:', + 'Start new chat session': 'ะะฐั‡ะฐั‚ัŒ ะฝะพะฒัƒัŽ ัะตััะธัŽ ั‡ะฐั‚ะฐ', + 'Continue previous conversation': 'ะŸั€ะพะดะพะปะถะธั‚ัŒ ะฟั€ะตะดั‹ะดัƒั‰ะธะน ะดะธะฐะปะพะณ', + '๐Ÿ‘‹ Welcome back! (Last updated: {{timeAgo}})': + '๐Ÿ‘‹ ะก ะฒะพะทะฒั€ะฐั‰ะตะฝะธะตะผ! (ะŸะพัะปะตะดะฝะตะต ะพะฑะฝะพะฒะปะตะฝะธะต: {{timeAgo}})', + '๐ŸŽฏ Overall Goal:': '๐ŸŽฏ ะžะฑั‰ะฐั ั†ะตะปัŒ:', + + // ============================================================================ + // ะ”ะธะฐะปะพะณะธ - ะะฒั‚ะพั€ะธะทะฐั†ะธั + // ============================================================================ + 'Get started': 'ะะฐั‡ะฐั‚ัŒ', + 'How would you like to authenticate for this project?': + 'ะšะฐะบ ะฒั‹ ั…ะพั‚ะธั‚ะต ะฐะฒั‚ะพั€ะธะทะพะฒะฐั‚ัŒัั ะดะปั ัั‚ะพะณะพ ะฟั€ะพะตะบั‚ะฐ?', + 'OpenAI API key is required to use OpenAI authentication.': + 'ะ”ะปั ะธัะฟะพะปัŒะทะพะฒะฐะฝะธั ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ OpenAI ั‚ั€ะตะฑัƒะตั‚ัั ะบะปัŽั‡ API OpenAI.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'ะ’ั‹ ะดะพะปะถะฝั‹ ะฒั‹ะฑั€ะฐั‚ัŒ ะผะตั‚ะพะด ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ ะดะปั ะฟั€ะพะดะพะปะถะตะฝะธั. ะะฐะถะผะธั‚ะต Ctrl+C ัะฝะพะฒะฐ ะดะปั ะฒั‹ั…ะพะดะฐ.', + '(Use Enter to Set Auth)': '(Enter ะดะปั ัƒัั‚ะฐะฝะพะฒะบะธ ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ)', + 'Terms of Services and Privacy Notice for Qwen Code': + 'ะฃัะปะพะฒะธั ะพะฑัะปัƒะถะธะฒะฐะฝะธั ะธ ัƒะฒะตะดะพะผะปะตะฝะธะต ะพ ะบะพะฝั„ะธะดะตะฝั†ะธะฐะปัŒะฝะพัั‚ะธ ะดะปั Qwen Code', + 'Qwen OAuth': 'Qwen OAuth', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'ะะต ัƒะดะฐะปะพััŒ ะฒะพะนั‚ะธ. ะกะพะพะฑั‰ะตะฝะธะต: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'ะะฒั‚ะพั€ะธะทะฐั†ะธั ะดะพะปะถะฝะฐ ะฑั‹ั‚ัŒ {{enforcedType}}, ะฝะพ ะฒั‹ ัะตะนั‡ะฐั ะธัะฟะพะปัŒะทัƒะตั‚ะต {{currentType}}.', + 'Qwen OAuth authentication timed out. Please try again.': + 'ะ’ั€ะตะผั ะพะถะธะดะฐะฝะธั ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ Qwen OAuth ะธัั‚ะตะบะปะพ. ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะพะฟั€ะพะฑัƒะนั‚ะต ัะฝะพะฒะฐ.', + 'Qwen OAuth authentication cancelled.': 'ะะฒั‚ะพั€ะธะทะฐั†ะธั Qwen OAuth ะพั‚ะผะตะฝะตะฝะฐ.', + 'Qwen OAuth Authentication': 'ะะฒั‚ะพั€ะธะทะฐั†ะธั Qwen OAuth', + 'Please visit this URL to authorize:': + 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะพัะตั‚ะธั‚ะต ัั‚ะพั‚ URL ะดะปั ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ:', + 'Or scan the QR code below:': 'ะ˜ะปะธ ะพั‚ัะบะฐะฝะธั€ัƒะนั‚ะต QR-ะบะพะด ะฝะธะถะต:', + 'Waiting for authorization': 'ะžะถะธะดะฐะฝะธะต ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ', + 'Time remaining:': 'ะžัั‚ะฐะปะพััŒ ะฒั€ะตะผะตะฝะธ:', + '(Press ESC or CTRL+C to cancel)': '(ะะฐะถะผะธั‚ะต ESC ะธะปะธ CTRL+C ะดะปั ะพั‚ะผะตะฝั‹)', + 'Qwen OAuth Authentication Timeout': 'ะขะฐะนะผะฐัƒั‚ ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ Qwen OAuth', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'ะขะพะบะตะฝ OAuth ะธัั‚ะตะบ (ะฑะพะปะตะต {{seconds}} ัะตะบัƒะฝะด). ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฒั‹ะฑะตั€ะธั‚ะต ะผะตั‚ะพะด ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ ัะฝะพะฒะฐ.', + 'Press any key to return to authentication type selection.': + 'ะะฐะถะผะธั‚ะต ะปัŽะฑัƒัŽ ะบะปะฐะฒะธัˆัƒ ะดะปั ะฒะพะทะฒั€ะฐั‚ะฐ ะบ ะฒั‹ะฑะพั€ัƒ ั‚ะธะฟะฐ ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ.', + 'Waiting for Qwen OAuth authentication...': + 'ะžะถะธะดะฐะฝะธะต ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ Qwen OAuth...', + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.': + 'ะŸั€ะธะผะตั‡ะฐะฝะธะต: ะ’ะฐัˆ ััƒั‰ะตัั‚ะฒัƒัŽั‰ะธะน ะบะปัŽั‡ API ะฒ settings.json ะฝะต ะฑัƒะดะตั‚ ัƒะดะฐะปะตะฝ ะฟั€ะธ ะธัะฟะพะปัŒะทะพะฒะฐะฝะธะธ Qwen OAuth. ะ’ั‹ ะผะพะถะตั‚ะต ะฟะตั€ะตะบะปัŽั‡ะธั‚ัŒัั ะพะฑั€ะฐั‚ะฝะพ ะฝะฐ ะฐะฒั‚ะพั€ะธะทะฐั†ะธัŽ OpenAI ะฟะพะทะถะต ะฟั€ะธ ะฝะตะพะฑั…ะพะดะธะผะพัั‚ะธ.', + 'Authentication timed out. Please try again.': + 'ะ’ั€ะตะผั ะพะถะธะดะฐะฝะธั ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ ะธัั‚ะตะบะปะพ. ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะพะฟั€ะพะฑัƒะนั‚ะต ัะฝะพะฒะฐ.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'ะžะถะธะดะฐะฝะธะต ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ... (ะะฐะถะผะธั‚ะต ESC ะธะปะธ CTRL+C ะดะปั ะพั‚ะผะตะฝั‹)', + 'Failed to authenticate. Message: {{message}}': + 'ะะต ัƒะดะฐะปะพััŒ ะฐะฒั‚ะพั€ะธะทะพะฒะฐั‚ัŒัั. ะกะพะพะฑั‰ะตะฝะธะต: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'ะฃัะฟะตัˆะฝะพ ะฐะฒั‚ะพั€ะธะทะพะฒะฐะฝะพ ั ัƒั‡ะตั‚ะฝั‹ะผะธ ะดะฐะฝะฝั‹ะผะธ {{authType}}.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'ะะตะฒะตั€ะฝะพะต ะทะฝะฐั‡ะตะฝะธะต QWEN_DEFAULT_AUTH_TYPE: "{{value}}". ะ”ะพะฟัƒัั‚ะธะผั‹ะต ะทะฝะฐั‡ะตะฝะธั: {{validValues}}', + 'OpenAI Configuration Required': 'ะขั€ะตะฑัƒะตั‚ัั ะบะพะฝั„ะธะณัƒั€ะฐั†ะธั OpenAI', + 'Please enter your OpenAI configuration. You can get an API key from': + 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฒะฒะตะดะธั‚ะต ะบะพะฝั„ะธะณัƒั€ะฐั†ะธัŽ OpenAI. ะ’ั‹ ะผะพะถะตั‚ะต ะฟะพะปัƒั‡ะธั‚ัŒ ะบะปัŽั‡ API ะฝะฐ', + 'API Key:': 'ะšะปัŽั‡ API:', + 'Invalid credentials: {{errorMessage}}': + 'ะะตะฒะตั€ะฝั‹ะต ัƒั‡ะตั‚ะฝั‹ะต ะดะฐะฝะฝั‹ะต: {{errorMessage}}', + 'Failed to validate credentials': 'ะะต ัƒะดะฐะปะพััŒ ะฟั€ะพะฒะตั€ะธั‚ัŒ ัƒั‡ะตั‚ะฝั‹ะต ะดะฐะฝะฝั‹ะต', + 'Press Enter to continue, Tab/โ†‘โ†“ to navigate, Esc to cancel': + 'Enter ะดะปั ะฟั€ะพะดะพะปะถะตะฝะธั, Tab/โ†‘โ†“ ะดะปั ะฝะฐะฒะธะณะฐั†ะธะธ, Esc ะดะปั ะพั‚ะผะตะฝั‹', + + // ============================================================================ + // ะ”ะธะฐะปะพะณะธ - ะœะพะดะตะปัŒ + // ============================================================================ + 'Select Model': 'ะ’ั‹ะฑั€ะฐั‚ัŒ ะผะพะดะตะปัŒ', + '(Press Esc to close)': '(ะะฐะถะผะธั‚ะต Esc ะดะปั ะทะฐะบั€ั‹ั‚ะธั)', + 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': + 'ะŸะพัะปะตะดะฝัั ะผะพะดะตะปัŒ Qwen Coder ะพั‚ Alibaba Cloud ModelStudio (ะฒะตั€ัะธั: qwen3-coder-plus-2025-09-23)', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'ะŸะพัะปะตะดะฝัั ะผะพะดะตะปัŒ Qwen Vision ะพั‚ Alibaba Cloud ModelStudio (ะฒะตั€ัะธั: qwen3-vl-plus-2025-09-23)', + + // ============================================================================ + // ะ”ะธะฐะปะพะณะธ - ะ ะฐะทั€ะตัˆะตะฝะธั + // ============================================================================ + 'Manage folder trust settings': 'ะฃะฟั€ะฐะฒะปะตะฝะธะต ะฝะฐัั‚ั€ะพะนะบะฐะผะธ ะดะพะฒะตั€ะธั ะบ ะฟะฐะฟะบะฐะผ', + + // ============================================================================ + // ะกั‚ั€ะพะบะฐ ัะพัั‚ะพัะฝะธั + // ============================================================================ + 'Using:': 'ะ˜ัะฟะพะปัŒะทัƒะตั‚ัั:', + '{{count}} open file': '{{count}} ะพั‚ะบั€ั‹ั‚ั‹ะน ั„ะฐะนะป', + '{{count}} open files': '{{count}} ะพั‚ะบั€ั‹ั‚ั‹ั… ั„ะฐะนะปะฐ(ะพะฒ)', + '(ctrl+g to view)': '(ctrl+g ะดะปั ะฟั€ะพัะผะพั‚ั€ะฐ)', + '{{count}} {{name}} file': '{{count}} ั„ะฐะนะป {{name}}', + '{{count}} {{name}} files': '{{count}} ั„ะฐะนะปะฐ(ะพะฒ) {{name}}', + '{{count}} MCP server': '{{count}} MCP-ัะตั€ะฒะตั€', + '{{count}} MCP servers': '{{count}} MCP-ัะตั€ะฒะตั€ะฐ(ะพะฒ)', + '{{count}} Blocked': '{{count}} ะทะฐะฑะปะพะบะธั€ะพะฒะฐะฝ(ะพ)', + '(ctrl+t to view)': '(ctrl+t ะดะปั ะฟั€ะพัะผะพั‚ั€ะฐ)', + '(ctrl+t to toggle)': '(ctrl+t ะดะปั ะฟะตั€ะตะบะปัŽั‡ะตะฝะธั)', + 'Press Ctrl+C again to exit.': 'ะะฐะถะผะธั‚ะต Ctrl+C ัะฝะพะฒะฐ ะดะปั ะฒั‹ั…ะพะดะฐ.', + 'Press Ctrl+D again to exit.': 'ะะฐะถะผะธั‚ะต Ctrl+D ัะฝะพะฒะฐ ะดะปั ะฒั‹ั…ะพะดะฐ.', + 'Press Esc again to clear.': 'ะะฐะถะผะธั‚ะต Esc ัะฝะพะฒะฐ ะดะปั ะพั‡ะธัั‚ะบะธ.', + + // ============================================================================ + // ะกั‚ะฐั‚ัƒั MCP + // ============================================================================ + 'No MCP servers configured.': 'ะะต ะฝะฐัั‚ั€ะพะตะฝะพ MCP-ัะตั€ะฒะตั€ะพะฒ.', + 'Please view MCP documentation in your browser:': + 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟั€ะพัะผะพั‚ั€ะธั‚ะต ะดะพะบัƒะผะตะฝั‚ะฐั†ะธัŽ MCP ะฒ ะฑั€ะฐัƒะทะตั€ะต:', + 'or use the cli /docs command': 'ะธะปะธ ะธัะฟะพะปัŒะทัƒะนั‚ะต ะบะพะผะฐะฝะดัƒ cli /docs', + 'โณ MCP servers are starting up ({{count}} initializing)...': + 'โณ MCP-ัะตั€ะฒะตั€ั‹ ะทะฐะฟัƒัะบะฐัŽั‚ัั ({{count}} ะธะฝะธั†ะธะฐะปะธะทะธั€ัƒะตั‚ัั)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'ะŸั€ะธะผะตั‡ะฐะฝะธะต: ะŸะตั€ะฒั‹ะน ะทะฐะฟัƒัะบ ะผะพะถะตั‚ ะทะฐะฝัั‚ัŒ ะฑะพะปัŒัˆะต ะฒั€ะตะผะตะฝะธ. ะ”ะพัั‚ัƒะฟะฝะพัั‚ัŒ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ ะพะฑะฝะพะฒะธั‚ัั ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ.', + 'Configured MCP servers:': 'ะะฐัั‚ั€ะพะตะฝะฝั‹ะต MCP-ัะตั€ะฒะตั€ั‹:', + Ready: 'ะ“ะพั‚ะพะฒ', + 'Starting... (first startup may take longer)': + 'ะ—ะฐะฟัƒัะบ... (ะฟะตั€ะฒั‹ะน ะทะฐะฟัƒัะบ ะผะพะถะตั‚ ะทะฐะฝัั‚ัŒ ะฑะพะปัŒัˆะต ะฒั€ะตะผะตะฝะธ)', + Disconnected: 'ะžั‚ะบะปัŽั‡ะตะฝ', + '{{count}} tool': '{{count}} ะธะฝัั‚ั€ัƒะผะตะฝั‚', + '{{count}} tools': '{{count}} ะธะฝัั‚ั€ัƒะผะตะฝั‚ะฐ(ะพะฒ)', + '{{count}} prompt': '{{count}} ะฟั€ะพะผะฟั‚', + '{{count}} prompts': '{{count}} ะฟั€ะพะผะฟั‚ะฐ(ะพะฒ)', + '(from {{extensionName}})': '(ะพั‚ {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth ะธัั‚ะตะบ', + 'OAuth not authenticated': 'OAuth ะฝะต ะฐะฒั‚ะพั€ะธะทะพะฒะฐะฝ', + 'tools and prompts will appear when ready': + 'ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ะธ ะฟั€ะพะผะฟั‚ั‹ ะฟะพัะฒัั‚ัั, ะบะพะณะดะฐ ะฑัƒะดัƒั‚ ะณะพั‚ะพะฒั‹', + '{{count}} tools cached': '{{count}} ะธะฝัั‚ั€ัƒะผะตะฝั‚ะฐ(ะพะฒ) ะฒ ะบััˆะต', + 'Tools:': 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚ั‹:', + 'Parameters:': 'ะŸะฐั€ะฐะผะตั‚ั€ั‹:', + 'Prompts:': 'ะŸั€ะพะผะฟั‚ั‹:', + Blocked: 'ะ—ะฐะฑะปะพะบะธั€ะพะฒะฐะฝะพ', + '๐Ÿ’ก Tips:': '๐Ÿ’ก ะŸะพะดัะบะฐะทะบะธ:', + Use: 'ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต', + 'to show server and tool descriptions': + 'ะดะปั ะฟะพะบะฐะทะฐ ะพะฟะธัะฐะฝะธะน ัะตั€ะฒะตั€ะฐ ะธ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + 'to show tool parameter schemas': 'ะดะปั ะฟะพะบะฐะทะฐ ัั…ะตะผ ะฟะฐั€ะฐะผะตั‚ั€ะพะฒ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + 'to hide descriptions': 'ะดะปั ัะบั€ั‹ั‚ะธั ะพะฟะธัะฐะฝะธะน', + 'to authenticate with OAuth-enabled servers': + 'ะดะปั ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ ะฝะฐ ัะตั€ะฒะตั€ะฐั… ั ะฟะพะดะดะตั€ะถะบะพะน OAuth', + Press: 'ะะฐะถะผะธั‚ะต', + 'to toggle tool descriptions on/off': + 'ะดะปั ะฟะตั€ะตะบะปัŽั‡ะตะฝะธั ะพะฟะธัะฐะฝะธะน ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ', + "Starting OAuth authentication for MCP server '{{name}}'...": + "ะะฐั‡ะฐะปะพ ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ OAuth ะดะปั MCP-ัะตั€ะฒะตั€ะฐ '{{name}}'...", + 'Restarting MCP servers...': 'ะŸะตั€ะตะทะฐะฟัƒัะบ MCP-ัะตั€ะฒะตั€ะพะฒ...', + + // ============================================================================ + // ะŸะพะดัะบะฐะทะบะธ ะฟั€ะธ ะทะฐะฟัƒัะบะต + // ============================================================================ + 'Tips for getting started:': 'ะŸะพะดัะบะฐะทะบะธ ะดะปั ะฝะฐั‡ะฐะปะฐ ั€ะฐะฑะพั‚ั‹:', + '1. Ask questions, edit files, or run commands.': + '1. ะ—ะฐะดะฐะฒะฐะนั‚ะต ะฒะพะฟั€ะพัั‹, ั€ะตะดะฐะบั‚ะธั€ัƒะนั‚ะต ั„ะฐะนะปั‹ ะธะปะธ ะฒั‹ะฟะพะปะฝัะนั‚ะต ะบะพะผะฐะฝะดั‹.', + '2. Be specific for the best results.': + '2. ะ‘ัƒะดัŒั‚ะต ะบะพะฝะบั€ะตั‚ะฝั‹ ะดะปั ะปัƒั‡ัˆะธั… ั€ะตะทัƒะปัŒั‚ะฐั‚ะพะฒ.', + 'files to customize your interactions with Qwen Code.': + 'ั„ะฐะนะปั‹ ะดะปั ะฝะฐัั‚ั€ะพะนะบะธ ะฒะทะฐะธะผะพะดะตะนัั‚ะฒะธั ั Qwen Code.', + 'for more information.': 'ะดะปั ะฟะพะปัƒั‡ะตะฝะธั ะดะพะฟะพะปะฝะธั‚ะตะปัŒะฝะพะน ะธะฝั„ะพั€ะผะฐั†ะธะธ.', + + // ============================================================================ + // ะญะบั€ะฐะฝ ะฒั‹ั…ะพะดะฐ / ะกั‚ะฐั‚ะธัั‚ะธะบะฐ + // ============================================================================ + 'Agent powering down. Goodbye!': 'ะะณะตะฝั‚ ะทะฐะฒะตั€ัˆะฐะตั‚ ั€ะฐะฑะพั‚ัƒ. ะ”ะพ ัะฒะธะดะฐะฝะธั!', + 'Interaction Summary': 'ะกะฒะพะดะบะฐ ะฒะทะฐะธะผะพะดะตะนัั‚ะฒะธั', + 'Session ID:': 'ID ัะตััะธะธ:', + 'Tool Calls:': 'ะ’ั‹ะทะพะฒั‹ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ:', + 'Success Rate:': 'ะŸั€ะพั†ะตะฝั‚ ัƒัะฟะตั…ะฐ:', + 'User Agreement:': 'ะกะพะณะปะฐัะธะต ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั:', + reviewed: 'ะฟั€ะพะฒะตั€ะตะฝะพ', + 'Code Changes:': 'ะ˜ะทะผะตะฝะตะฝะธั ะบะพะดะฐ:', + Performance: 'ะŸั€ะพะธะทะฒะพะดะธั‚ะตะปัŒะฝะพัั‚ัŒ', + 'Wall Time:': 'ะžะฑั‰ะตะต ะฒั€ะตะผั:', + 'Agent Active:': 'ะะบั‚ะธะฒะฝะพัั‚ัŒ ะฐะณะตะฝั‚ะฐ:', + 'API Time:': 'ะ’ั€ะตะผั API:', + 'Tool Time:': 'ะ’ั€ะตะผั ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ:', + 'Session Stats': 'ะกั‚ะฐั‚ะธัั‚ะธะบะฐ ัะตััะธะธ', + 'Model Usage': 'ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะธะต ะผะพะดะตะปะธ', + Reqs: 'ะ—ะฐะฟั€ะพัะพะฒ', + 'Input Tokens': 'ะ’ั…ะพะดะฝั‹ั… ั‚ะพะบะตะฝะพะฒ', + 'Output Tokens': 'ะ’ั‹ั…ะพะดะฝั‹ั… ั‚ะพะบะตะฝะพะฒ', + 'Savings Highlight:': 'ะญะบะพะฝะพะผะธั:', + 'of input tokens were served from the cache, reducing costs.': + 'ะฒั…ะพะดะฝั‹ั… ั‚ะพะบะตะฝะพะฒ ะพะฑัะปัƒะถะตะฝะพ ะธะท ะบััˆะฐ, ัะฝะธะถะฐั ะทะฐั‚ั€ะฐั‚ั‹.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'ะŸะพะดัะบะฐะทะบะฐ: ะ”ะปั ะฟะพะปะฝะพะน ั€ะฐะทะฑะธะฒะบะธ ั‚ะพะบะตะฝะพะฒ ะฒั‹ะฟะพะปะฝะธั‚ะต `/stats model`.', + 'Model Stats For Nerds': 'ะกั‚ะฐั‚ะธัั‚ะธะบะฐ ะผะพะดะตะปะธ ะดะปั ะณะธะบะพะฒ', + 'Tool Stats For Nerds': 'ะกั‚ะฐั‚ะธัั‚ะธะบะฐ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ ะดะปั ะณะธะบะพะฒ', + Metric: 'ะœะตั‚ั€ะธะบะฐ', + API: 'API', + Requests: 'ะ—ะฐะฟั€ะพัั‹', + Errors: 'ะžัˆะธะฑะบะธ', + 'Avg Latency': 'ะกั€ะตะดะฝัั ะทะฐะดะตั€ะถะบะฐ', + Tokens: 'ะขะพะบะตะฝั‹', + Total: 'ะ’ัะตะณะพ', + Prompt: 'ะŸั€ะพะผะฟั‚', + Cached: 'ะšััˆะธั€ะพะฒะฐะฝะพ', + Thoughts: 'ะ ะฐะทะผั‹ัˆะปะตะฝะธั', + Tool: 'ะ˜ะฝัั‚ั€ัƒะผะตะฝั‚', + Output: 'ะ’ั‹ะฒะพะด', + 'No API calls have been made in this session.': + 'ะ’ ัั‚ะพะน ัะตััะธะธ ะฝะต ะฑั‹ะปะพ ะฒั‹ะทะพะฒะพะฒ API.', + 'Tool Name': 'ะ˜ะผั ะธะฝัั‚ั€ัƒะผะตะฝั‚ะฐ', + Calls: 'ะ’ั‹ะทะพะฒั‹', + 'Success Rate': 'ะŸั€ะพั†ะตะฝั‚ ัƒัะฟะตั…ะฐ', + 'Avg Duration': 'ะกั€ะตะดะฝัั ะดะปะธั‚ะตะปัŒะฝะพัั‚ัŒ', + 'User Decision Summary': 'ะกะฒะพะดะบะฐ ั€ะตัˆะตะฝะธะน ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั', + 'Total Reviewed Suggestions:': 'ะ’ัะตะณะพ ะฟั€ะพะฒะตั€ะตะฝะพ ะฟั€ะตะดะปะพะถะตะฝะธะน:', + ' ยป Accepted:': ' ยป ะŸั€ะธะฝัั‚ะพ:', + ' ยป Rejected:': ' ยป ะžั‚ะบะปะพะฝะตะฝะพ:', + ' ยป Modified:': ' ยป ะ˜ะทะผะตะฝะตะฝะพ:', + ' Overall Agreement Rate:': ' ะžะฑั‰ะธะน ะฟั€ะพั†ะตะฝั‚ ัะพะณะปะฐัะธั:', + 'No tool calls have been made in this session.': + 'ะ’ ัั‚ะพะน ัะตััะธะธ ะฝะต ะฑั‹ะปะพ ะฒั‹ะทะพะฒะพะฒ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ.', + 'Session start time is unavailable, cannot calculate stats.': + 'ะ’ั€ะตะผั ะฝะฐั‡ะฐะปะฐ ัะตััะธะธ ะฝะตะดะพัั‚ัƒะฟะฝะพ, ะฝะตะฒะพะทะผะพะถะฝะพ ั€ะฐััั‡ะธั‚ะฐั‚ัŒ ัั‚ะฐั‚ะธัั‚ะธะบัƒ.', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': + 'ะžะถะธะดะฐะฝะธะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ะพั‚ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั...', + '(esc to cancel, {{time}})': '(esc ะดะปั ะพั‚ะผะตะฝั‹, {{time}})', + "I'm Feeling Lucky": 'ะœะฝะต ะฟะพะฒะตะทั‘ั‚!', + 'Shipping awesomeness... ': 'ะ”ะพัั‚ะฐะฒะปัะตะผ ะบั€ัƒั‚ะธะทะฝัƒ... ', + 'Painting the serifs back on...': 'ะ ะธััƒะตะผ ะทะฐัะตั‡ะบะธ ะฝะฐ ะฑัƒะบะฒะฐั…...', + 'Navigating the slime mold...': 'ะŸั€ะพะฑะธั€ะฐะตะผัั ั‡ะตั€ะตะท ัะปะธะทะตะฒะธะบะพะฒ..', + 'Consulting the digital spirits...': 'ะกะพะฒะตั‚ัƒะตะผัั ั ั†ะธั„ั€ะพะฒั‹ะผะธ ะดัƒั…ะฐะผะธ...', + 'Reticulating splines...': 'ะกะณะปะฐะถะธะฒะฐะฝะธะต ัะฟะปะฐะนะฝะพะฒ...', + 'Warming up the AI hamsters...': 'ะ ะฐะทะพะณั€ะตะฒะฐะตะผ ะ˜ะ˜-ั…ะพะผัั‡ะบะพะฒ...', + 'Asking the magic conch shell...': 'ะกะฟั€ะฐัˆะธะฒะฐะตะผ ะฒะพะปัˆะตะฑะฝัƒัŽ ั€ะฐะบัƒัˆะบัƒ...', + 'Generating witty retort...': 'ะ“ะตะฝะตั€ะธั€ัƒะตะผ ะพัั‚ั€ะพัƒะผะฝั‹ะน ะพั‚ะฒะตั‚...', + 'Polishing the algorithms...': 'ะŸะพะปะธั€ัƒะตะผ ะฐะปะณะพั€ะธั‚ะผั‹...', + "Don't rush perfection (or my code)...": + 'ะะต ั‚ะพั€ะพะฟะธั‚ะต ัะพะฒะตั€ัˆะตะฝัั‚ะฒะพ (ะธะปะธ ะผะพะน ะบะพะด)...', + 'Brewing fresh bytes...': 'ะ—ะฐะฒะฐั€ะธะฒะฐะตะผ ัะฒะตะถะธะต ะฑะฐะนั‚ั‹...', + 'Counting electrons...': 'ะŸะตั€ะตัั‡ะธั‚ั‹ะฒะฐะตะผ ัะปะตะบั‚ั€ะพะฝั‹...', + 'Engaging cognitive processors...': 'ะ—ะฐะดะตะนัั‚ะฒัƒะตะผ ะบะพะณะฝะธั‚ะธะฒะฝั‹ะต ะฟั€ะพั†ะตััะพั€ั‹...', + 'Checking for syntax errors in the universe...': + 'ะ˜ั‰ะตะผ ัะธะฝั‚ะฐะบัะธั‡ะตัะบะธะต ะพัˆะธะฑะบะธ ะฒะพ ะฒัะตะปะตะฝะฝะพะน...', + 'One moment, optimizing humor...': 'ะกะตะบัƒะฝะดะพั‡ะบัƒ, ะพะฟั‚ะธะผะธะทะธั€ัƒะตะผ ัŽะผะพั€...', + 'Shuffling punchlines...': 'ะŸะตั€ะตั‚ะฐัะพะฒั‹ะฒะฐะตะผ ะฟะฐะฝั‡ะปะฐะนะฝั‹...', + 'Untangling neural nets...': 'ะ ะฐัะฟัƒั‚ะฐะฒะฐะตะผ ะฝะตะนั€ะพัะตั‚ะธ...', + 'Compiling brilliance...': 'ะšะพะผะฟะธะปะธั€ัƒะตะผ ะณะตะฝะธะฐะปัŒะฝะพัั‚ัŒ...', + 'Loading wit.exe...': 'ะ—ะฐะณั€ัƒะถะฐะตะผ yumor.exe...', + 'Summoning the cloud of wisdom...': 'ะŸั€ะธะทั‹ะฒะฐะตะผ ะพะฑะปะฐะบะพ ะผัƒะดั€ะพัั‚ะธ...', + 'Preparing a witty response...': 'ะ“ะพั‚ะพะฒะธะผ ะพัั‚ั€ะพัƒะผะฝั‹ะน ะพั‚ะฒะตั‚...', + "Just a sec, I'm debugging reality...": 'ะกะตะบัƒะฝะดัƒ, ะธะดั‘ั‚ ะพั‚ะปะฐะดะบะฐ ั€ะตะฐะปัŒะฝะพัั‚ะธ...', + 'Confuzzling the options...': 'ะ—ะฐะฟัƒั‚ั‹ะฒะฐะตะผ ะฒะฐั€ะธะฐะฝั‚ั‹...', + 'Tuning the cosmic frequencies...': 'ะะฐัั‚ั€ะฐะธะฒะฐะตะผ ะบะพัะผะธั‡ะตัะบะธะต ั‡ะฐัั‚ะพั‚ั‹...', + 'Crafting a response worthy of your patience...': + 'ะกะพะทะดะฐะตะผ ะพั‚ะฒะตั‚, ะดะพัั‚ะพะนะฝั‹ะน ะฒะฐัˆะตะณะพ ั‚ะตั€ะฟะตะฝะธั...', + 'Compiling the 1s and 0s...': 'ะšะพะผะฟะธะปะธั€ัƒะตะผ ะตะดะธะฝะธั‡ะบะธ ะธ ะฝะพะปะธะบะธ...', + 'Resolving dependencies... and existential crises...': + 'ะ ะฐะทั€ะตัˆะฐะตะผ ะทะฐะฒะธัะธะผะพัั‚ะธ... ะธ ัะบะทะธัั‚ะตะฝั†ะธะฐะปัŒะฝั‹ะต ะบั€ะธะทะธัั‹...', + 'Defragmenting memories... both RAM and personal...': + 'ะ”ะตั„ั€ะฐะณะผะตะฝั‚ะฐั†ะธั ะฟะฐะผัั‚ะธ... ะธ ะพะฟะตั€ะฐั‚ะธะฒะฝะพะน, ะธ ะปะธั‡ะฝะพะน...', + 'Rebooting the humor module...': 'ะŸะตั€ะตะทะฐะณั€ัƒะทะบะฐ ะผะพะดัƒะปั ัŽะผะพั€ะฐ...', + 'Caching the essentials (mostly cat memes)...': + 'ะšััˆะธั€ัƒะตะผ ัะฐะผะพะต ะฒะฐะถะฝะพะต (ะฒ ะพัะฝะพะฒะฝะพะผ ะผะตะผั‹ ั ะบะพั‚ะธะบะฐะผะธ)...', + 'Optimizing for ludicrous speed': 'ะžะฟั‚ะธะผะธะทะฐั†ะธั ะดะปั ะฑะตะทัƒะผะฝะพะน ัะบะพั€ะพัั‚ะธ', + "Swapping bits... don't tell the bytes...": + 'ะœะตะฝัะตะผ ะฑะธั‚ั‹... ั‚ะพะปัŒะบะพ ะฑะฐะนั‚ะฐะผ ะฝะต ะณะพะฒะพั€ะธั‚ะต...', + 'Garbage collecting... be right back...': 'ะกะฑะพั€ะบะฐ ะผัƒัะพั€ะฐ... ัะบะพั€ะพ ะฒะตั€ะฝัƒััŒ...', + 'Assembling the interwebs...': 'ะกะฑะพั€ะบะฐ ะธะฝั‚ะตั€ะฝะตั‚ะพะฒ...', + 'Converting coffee into code...': 'ะŸั€ะตะฒั€ะฐั‰ะฐะตะผ ะบะพั„ะต ะฒ ะบะพะด...', + 'Updating the syntax for reality...': 'ะžะฑะฝะพะฒะปัะตะผ ัะธะฝั‚ะฐะบัะธั ั€ะตะฐะปัŒะฝะพัั‚ะธ...', + 'Rewiring the synapses...': 'ะŸะตั€ะตะฟะพะดะบะปัŽั‡ะฐะตะผ ัะธะฝะฐะฟัั‹...', + 'Looking for a misplaced semicolon...': 'ะ˜ั‰ะตะผ ะปะธัˆะฝัŽัŽ ั‚ะพั‡ะบัƒ ั ะทะฐะฟัั‚ะพะน...', + "Greasin' the cogs of the machine...": 'ะกะผะฐะทั‹ะฒะฐะตะผ ัˆะตัั‚ะตั€ั‘ะฝะบะธ ะผะฐัˆะธะฝั‹...', + 'Pre-heating the servers...': 'ะ ะฐะทะพะณั€ะตะฒะฐะตะผ ัะตั€ะฒะตั€ั‹...', + 'Calibrating the flux capacitor...': 'ะšะฐะปะธะฑั€ัƒะตะผ ะฟะพั‚ะพะบะพะฒั‹ะน ะฝะฐะบะพะฟะธั‚ะตะปัŒ...', + 'Engaging the improbability drive...': 'ะ’ะบะปัŽั‡ะฐะตะผ ะดะฒะธะณะฐั‚ะตะปัŒ ะฝะตะฒะตั€ะพัั‚ะฝะพัั‚ะธ...', + 'Channeling the Force...': 'ะะฐะฟั€ะฐะฒะปัะตะผ ะกะธะปัƒ...', + 'Aligning the stars for optimal response...': + 'ะ’ั‹ั€ะฐะฒะฝะธะฒะฐะตะผ ะทะฒั‘ะทะดั‹ ะดะปั ะพะฟั‚ะธะผะฐะปัŒะฝะพะณะพ ะพั‚ะฒะตั‚ะฐ...', + 'So say we all...': 'ะขะฐะบ ัะบะฐะถะตะผ ะผั‹ ะฒัะต...', + 'Loading the next great idea...': 'ะ—ะฐะณั€ัƒะทะบะฐ ัะปะตะดัƒัŽั‰ะตะน ะฒะตะปะธะบะพะน ะธะดะตะธ...', + "Just a moment, I'm in the zone...": 'ะœะธะฝัƒั‚ะบัƒ, ั ะฒ ะฟะพั‚ะพะบะต...', + 'Preparing to dazzle you with brilliance...': + 'ะ“ะพั‚ะพะฒะปัŽััŒ ะพัะปะตะฟะธั‚ัŒ ะฒะฐั ะณะตะฝะธะฐะปัŒะฝะพัั‚ัŒัŽ...', + "Just a tick, I'm polishing my wit...": 'ะกะตะบัƒะฝะดัƒ, ะฟะพะปะธั€ัƒัŽ ะพัั‚ั€ะพัƒะผะธะต...', + "Hold tight, I'm crafting a masterpiece...": 'ะ”ะตั€ะถะธั‚ะตััŒ, ัะพะทะดะฐัŽ ัˆะตะดะตะฒั€...', + "Just a jiffy, I'm debugging the universe...": + 'ะœะธะณะพะผ, ะพั‚ะปะฐะถะธะฒะฐัŽ ะฒัะตะปะตะฝะฝัƒัŽ...', + "Just a moment, I'm aligning the pixels...": 'ะœะพะผะตะฝั‚, ะฒั‹ั€ะฐะฒะฝะธะฒะฐัŽ ะฟะธะบัะตะปะธ...', + "Just a sec, I'm optimizing the humor...": 'ะกะตะบัƒะฝะดัƒ, ะพะฟั‚ะธะผะธะทะธั€ัƒัŽ ัŽะผะพั€...', + "Just a moment, I'm tuning the algorithms...": + 'ะœะพะผะตะฝั‚, ะฝะฐัั‚ั€ะฐะธะฒะฐัŽ ะฐะปะณะพั€ะธั‚ะผั‹...', + 'Warp speed engaged...': 'ะ’ะฐั€ะฟ-ัะบะพั€ะพัั‚ัŒ ะฒะบะปัŽั‡ะตะฝะฐ...', + 'Mining for more Dilithium crystals...': 'ะ”ะพะฑั‹ะฒะฐะตะผ ะบั€ะธัั‚ะฐะปะปั‹ ะดะธะปะธั‚ะธั...', + "Don't panic...": 'ะ‘ะตะท ะฟะฐะฝะธะบะธ...', + 'Following the white rabbit...': 'ะกะปะตะดัƒะตะผ ะทะฐ ะฑะตะปั‹ะผ ะบั€ะพะปะธะบะพะผ...', + 'The truth is in here... somewhere...': 'ะ˜ัั‚ะธะฝะฐ ะณะดะต-ั‚ะพ ะทะดะตััŒ... ะฒะฝัƒั‚ั€ะธ...', + 'Blowing on the cartridge...': 'ะŸั€ะพะดัƒะฒะฐะตะผ ะบะฐั€ั‚ั€ะธะดะถ...', + 'Loading... Do a barrel roll!': 'ะ—ะฐะณั€ัƒะทะบะฐ... ะกะดะตะปะฐะน ะฑะพั‡ะบัƒ!', + 'Waiting for the respawn...': 'ะ–ะดะตะผ ั€ะตัะฟะฐัƒะฝะฐ...', + 'Finishing the Kessel Run in less than 12 parsecs...': + 'ะ”ะตะปะฐะตะผ ะ”ัƒะณัƒ ะšะตััะตะปั ะผะตะฝะตะต ั‡ะตะผ ะทะฐ 12 ะฟะฐั€ัะตะบะพะฒ...', + "The cake is not a lie, it's just still loading...": + 'ะขะพั€ั‚ะธะบ โ€” ะฝะต ะปะพะถัŒ, ะพะฝ ะฟั€ะพัั‚ะพ ะตั‰ั‘ ะณั€ัƒะทะธั‚ัั...', + 'Fiddling with the character creation screen...': + 'ะ’ะพะทะธะผัั ั ัะบั€ะฐะฝะพะผ ัะพะทะดะฐะฝะธั ะฟะตั€ัะพะฝะฐะถะฐ...', + "Just a moment, I'm finding the right meme...": + 'ะœะธะฝัƒั‚ะบัƒ, ะธั‰ัƒ ะฟะพะดั…ะพะดัั‰ะธะน ะผะตะผ...', + "Pressing 'A' to continue...": "ะะฐะถะธะผะฐะตะผ 'A' ะดะปั ะฟั€ะพะดะพะปะถะตะฝะธั...", + 'Herding digital cats...': 'ะŸะฐัั‘ะผ ั†ะธั„ั€ะพะฒั‹ั… ะบะพั‚ะพะฒ...', + 'Polishing the pixels...': 'ะŸะพะปะธั€ัƒะตะผ ะฟะธะบัะตะปะธ...', + 'Finding a suitable loading screen pun...': + 'ะ˜ั‰ะตะผ ะฟะพะดั…ะพะดัั‰ะธะน ะบะฐะปะฐะผะฑัƒั€ ะดะปั ัะบั€ะฐะฝะฐ ะทะฐะณั€ัƒะทะบะธ...', + 'Distracting you with this witty phrase...': + 'ะžั‚ะฒะปะตะบะฐะตะผ ะฒะฐั ัั‚ะพะน ะพัั‚ั€ะพัƒะผะฝะพะน ั„ั€ะฐะทะพะน...', + 'Almost there... probably...': 'ะŸะพั‡ั‚ะธ ะณะพั‚ะพะฒะพ... ะฒั€ะพะดะต...', + 'Our hamsters are working as fast as they can...': + 'ะะฐัˆะธ ั…ะพะผัั‡ะบะธ ั€ะฐะฑะพั‚ะฐัŽั‚ ะธะทะพ ะฒัะตั… ัะธะป...', + 'Giving Cloudy a pat on the head...': 'ะ“ะปะฐะดะธะผ ะžะฑะปะฐั‡ะบะพ ะฟะพ ะณะพะปะพะฒะต...', + 'Petting the cat...': 'ะ“ะปะฐะดะธะผ ะบะพั‚ะฐ...', + 'Rickrolling my boss...': 'ะ ะธะบั€ะพะปะปะธะผ ะฝะฐั‡ะฐะปัŒะฝะธะบะฐ...', + 'Never gonna give you up, never gonna let you down...': + 'Never gonna give you up, never gonna let you down...', + 'Slapping the bass...': 'ะ›ะฐะฑะฐะตะผ ะฑะฐั-ะณะธั‚ะฐั€ัƒ...', + 'Tasting the snozberries...': 'ะŸั€ะพะฑัƒะตะผ ัะฝัƒะทะฑะตั€ั€ะธ ะฝะฐ ะฒะบัƒั...', + "I'm going the distance, I'm going for speed...": + 'ะ˜ะดัƒ ะดะพ ะบะพะฝั†ะฐ, ะธะดัƒ ะฝะฐ ัะบะพั€ะพัั‚ัŒ...', + 'Is this the real life? Is this just fantasy?...': + 'Is this the real life? Is this just fantasy?...', + "I've got a good feeling about this...": 'ะฃ ะผะตะฝั ั…ะพั€ะพัˆะตะต ะฟั€ะตะดั‡ัƒะฒัั‚ะฒะธะต...', + 'Poking the bear...': 'ะ”ั€ะฐะทะฝะธะผ ะผะตะดะฒะตะดั... (ะะต ะปะตะทัŒ...)', + 'Doing research on the latest memes...': 'ะ˜ะทัƒั‡ะฐะตะผ ัะฒะตะถะธะต ะผะตะผั‹...', + 'Figuring out how to make this more witty...': + 'ะ”ัƒะผะฐะตะผ, ะบะฐะบ ัะดะตะปะฐั‚ัŒ ัั‚ะพ ะพัั‚ั€ะพัƒะผะฝะตะต...', + 'Hmmm... let me think...': 'ะฅะผะผ... ะดะฐะนั‚ะต ะฟะพะดัƒะผะฐั‚ัŒ...', + 'What do you call a fish with no eyes? A fsh...': + 'ะšะฐะบ ะฝะฐะทั‹ะฒะฐะตั‚ัั ะฑัƒะผะตั€ะฐะฝะณ, ะบะพั‚ะพั€ั‹ะน ะฝะต ะฒะพะทะฒั€ะฐั‰ะฐะตั‚ัั? ะŸะฐะปะบะฐ...', + 'Why did the computer go to therapy? It had too many bytes...': + 'ะŸะพั‡ะตะผัƒ ะบะพะผะฟัŒัŽั‚ะตั€ ะฟั€ะพัั‚ัƒะดะธะปัั? ะŸะพั‚ะพะผัƒ ั‡ั‚ะพ ะพัั‚ะฐะฒะธะป ะพะบะฝะฐ ะพั‚ะบั€ั‹ั‚ั‹ะผะธ...', + "Why don't programmers like nature? It has too many bugs...": + 'ะŸะพั‡ะตะผัƒ ะฟั€ะพะณั€ะฐะผะผะธัั‚ั‹ ะฝะต ะปัŽะฑัั‚ ะณัƒะปัั‚ัŒ ะฝะฐ ัƒะปะธั†ะต? ะขะฐะผ ัั€ะตะดะฐ ะฝะต ะฝะฐัั‚ั€ะพะตะฝะฐ...', + 'Why do programmers prefer dark mode? Because light attracts bugs...': + 'ะŸะพั‡ะตะผัƒ ะฟั€ะพะณั€ะฐะผะผะธัั‚ั‹ ะฟั€ะตะดะฟะพั‡ะธั‚ะฐัŽั‚ ั‚ั‘ะผะฝัƒัŽ ั‚ะตะผัƒ? ะŸะพั‚ะพะผัƒ ั‡ั‚ะพ ะฒ ั‚ะตะผะฝะพั‚ะต ะฝะต ะฒะธะดะฝะพ ะฑะฐะณะพะฒ...', + 'Why did the developer go broke? Because they used up all their cache...': + 'ะŸะพั‡ะตะผัƒ ั€ะฐะทั€ะฐะฑะพั‚ั‡ะธะบ ั€ะฐะทะพั€ะธะปัั? ะŸะพั‚ะพะผัƒ ั‡ั‚ะพ ะฟะพั‚ั€ะฐั‚ะธะป ะฒะตััŒ ัะฒะพะน ะบััˆ...', + "What can you do with a broken pencil? Nothing, it's pointless...": + 'ะงั‚ะพ ะผะพะถะฝะพ ะดะตะปะฐั‚ัŒ ัะพ ัะปะพะผะฐะฝะฝั‹ะผ ะบะฐั€ะฐะฝะดะฐัˆะพะผ? ะะธั‡ะตะณะพ โ€” ะพะฝ ั‚ัƒะฟะพะน...', + 'Applying percussive maintenance...': 'ะŸั€ะพะฒะพะถัƒ ะฝะฐัั‚ั€ะพะนะบัƒ ะผะตั‚ะพะดะพะผ ั‚ั‹ะบะฐ...', + 'Searching for the correct USB orientation...': + 'ะ˜ั‰ะตะผ, ะบะฐะบะพะน ัั‚ะพั€ะพะฝะพะน ะฒัั‚ะฐะฒะปัั‚ัŒ ั„ะปะตัˆะบัƒ...', + 'Ensuring the magic smoke stays inside the wires...': + 'ะกะปะตะดะธะผ, ั‡ั‚ะพะฑั‹ ะฒะพะปัˆะตะฑะฝั‹ะน ะดั‹ะผ ะฝะต ะฒั‹ัˆะตะป ะธะท ะฟั€ะพะฒะพะดะพะฒ...', + 'Rewriting in Rust for no particular reason...': + 'ะŸะตั€ะตะฟะธัั‹ะฒะฐะตะผ ะฒัั‘ ะฝะฐ Rust ะฑะตะท ะพัะพะฑะพะน ะฟั€ะธั‡ะธะฝั‹...', + 'Trying to exit Vim...': 'ะŸั‹ั‚ะฐะตะผัั ะฒั‹ะนั‚ะธ ะธะท Vim...', + 'Spinning up the hamster wheel...': 'ะ ะฐัะบั€ัƒั‡ะธะฒะฐะตะผ ะบะพะปะตัะพ ะดะปั ั…ะพะผัะบะฐ...', + "That's not a bug, it's an undocumented feature...": 'ะญั‚ะพ ะฝะต ะฑะฐะณ, ะฐ ั„ะธั‡ะฐ...', + 'Engage.': 'ะŸะพะตั…ะฐะปะธ!', + "I'll be back... with an answer.": 'ะฏ ะฒะตั€ะฝัƒััŒ... ั ะพั‚ะฒะตั‚ะพะผ.', + 'My other process is a TARDIS...': 'ะœะพะน ะดั€ัƒะณะพะน ะฟั€ะพั†ะตัั โ€” ัั‚ะพ ะขะะ ะ”ะ˜ะก...', + 'Communing with the machine spirit...': 'ะžะฑั‰ะฐะตะผัั ั ะดัƒั…ะพะผ ะผะฐัˆะธะฝั‹...', + 'Letting the thoughts marinate...': 'ะ”ะฐะตะผ ะผั‹ัะปัะผ ะทะฐะผะฐั€ะธะฝะพะฒะฐั‚ัŒัั...', + 'Just remembered where I put my keys...': + 'ะขะพะปัŒะบะพ ั‡ั‚ะพ ะฒัะฟะพะผะฝะธะป, ะบัƒะดะฐ ะฟะพะปะพะถะธะป ะบะปัŽั‡ะธ...', + 'Pondering the orb...': 'ะ ะฐะทะผั‹ัˆะปััŽ ะฝะฐะด ัั„ะตั€ะพะน...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.": + 'ะฏ ะฒะธะดะตะป ั‚ะฐะบะพะต, ะฒะพ ั‡ั‚ะพ ะฒั‹, ะปัŽะดะธ, ะฟั€ะพัั‚ะพ ะฝะต ะฟะพะฒะตั€ะธั‚ะต... ะฝะฐะฟั€ะธะผะตั€, ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั, ั‡ะธั‚ะฐัŽั‰ะตะณะพ ัะพะพะฑั‰ะตะฝะธั ะทะฐะณั€ัƒะทะบะธ.', + 'Initiating thoughtful gaze...': 'ะ˜ะฝะธั†ะธะธั€ัƒะตะผ ะทะฐะดัƒะผั‡ะธะฒั‹ะน ะฒะทะณะปัะด...', + "What's a computer's favorite snack? Microchips.": + 'ะงั‚ะพ ัะตั€ะฒะตั€ ะทะฐะบะฐะทั‹ะฒะฐะตั‚ ะฒ ะฑะฐั€ะต? ะŸะธะฝะณ-ะบะพะปะฐะดัƒ.', + "Why do Java developers wear glasses? Because they don't C#.": + 'ะŸะพั‡ะตะผัƒ Java-ั€ะฐะทั€ะฐะฑะพั‚ั‡ะธะบะธ ะฝะต ัƒะฑะธั€ะฐัŽั‚ัั ะดะพะผะฐ? ะžะฝะธ ะถะดัƒั‚ ัะฑะพั€ั‰ะธะบ ะผัƒัะพั€ะฐ...', + 'Charging the laser... pew pew!': 'ะ—ะฐั€ัะถะฐะตะผ ะปะฐะทะตั€... ะฟะธัƒ-ะฟะธัƒ!', + 'Dividing by zero... just kidding!': 'ะ”ะตะปะธะผ ะฝะฐ ะฝะพะปัŒ... ัˆัƒั‡ัƒ!', + 'Looking for an adult superviso... I mean, processing.': + 'ะ˜ั‰ัƒ ะฒะทั€ะพัะปั‹ั… ะดะปั ะฟั€ะธัะผะพั‚... ะฒ ัะผั‹ัะปะต, ะพะฑั€ะฐะฑะฐั‚ั‹ะฒะฐัŽ.', + 'Making it go beep boop.': 'ะ”ะตะปะฐะตะผ ะฑะธะฟ-ะฑัƒะฟ.', + 'Buffering... because even AIs need a moment.': + 'ะ‘ัƒั„ะตั€ะธะทะฐั†ะธั... ะดะฐะถะต ะ˜ะ˜ ะฝัƒะถะฝะพ ะผะณะฝะพะฒะตะฝะธะต.', + 'Entangling quantum particles for a faster response...': + 'ะ—ะฐะฟัƒั‚ั‹ะฒะฐะตะผ ะบะฒะฐะฝั‚ะพะฒั‹ะต ั‡ะฐัั‚ะธั†ั‹ ะดะปั ะฑั‹ัั‚ั€ะพะณะพ ะพั‚ะฒะตั‚ะฐ...', + 'Polishing the chrome... on the algorithms.': + 'ะŸะพะปะธั€ัƒะตะผ ั…ั€ะพะผ... ะฝะฐ ะฐะปะณะพั€ะธั‚ะผะฐั….', + 'Are you not entertained? (Working on it!)': + 'ะ’ั‹ ะตั‰ั‘ ะฝะต ั€ะฐะทะฒะปะตะบะปะธััŒ?! ะ ะฐะทะฒะต ะฒั‹ ะฝะต ะทะฐ ัั‚ะธะผ ััŽะดะฐ ะฟั€ะธัˆะปะธ?!', + 'Summoning the code gremlins... to help, of course.': + 'ะŸั€ะธะทั‹ะฒะฐะตะผ ะณั€ะตะผะปะธะฝะพะฒ ะบะพะดะฐ... ะดะปั ะฟะพะผะพั‰ะธ, ะบะพะฝะตั‡ะฝะพ ะถะต.', + 'Just waiting for the dial-up tone to finish...': + 'ะ–ะดะตะผ, ะฟะพะบะฐ ะทะฐะบะพะฝั‡ะธั‚ัั ะทะฒัƒะบ dial-up ะผะพะดะตะผะฐ...', + 'Recalibrating the humor-o-meter.': 'ะŸะตั€ะตะบะฐะปะธะฑั€ะพะฒะบะฐ ัŽะผะพั€ะพะผะตั‚ั€ะฐ.', + 'My other loading screen is even funnier.': + 'ะœะพะน ะดั€ัƒะณะพะน ัะบั€ะฐะฝ ะทะฐะณั€ัƒะทะบะธ ะตั‰ั‘ ัะผะตัˆะฝะตะต.', + "Pretty sure there's a cat walking on the keyboard somewhere...": + 'ะšะฐะถะตั‚ัั, ะณะดะต-ั‚ะพ ะฟะพ ะบะปะฐะฒะธะฐั‚ัƒั€ะต ะณัƒะปัะตั‚ ะบะพั‚...', + 'Enhancing... Enhancing... Still loading.': + 'ะฃะปัƒั‡ัˆะฐะตะผ... ะ•ั‰ั‘ ัƒะปัƒั‡ัˆะฐะตะผ... ะ’ัั‘ ะตั‰ั‘ ะณั€ัƒะทะธั‚ัั.', + "It's not a bug, it's a feature... of this loading screen.": + 'ะญั‚ะพ ะฝะต ะฑะฐะณ, ัั‚ะพ ั„ะธั‡ะฐ... ัะบั€ะฐะฝะฐ ะทะฐะณั€ัƒะทะบะธ.', + 'Have you tried turning it off and on again? (The loading screen, not me.)': + 'ะŸั€ะพะฑะพะฒะฐะปะธ ะฒั‹ะบะปัŽั‡ะธั‚ัŒ ะธ ะฒะบะปัŽั‡ะธั‚ัŒ ัะฝะพะฒะฐ? (ะญะบั€ะฐะฝ ะทะฐะณั€ัƒะทะบะธ, ะฝะต ะผะตะฝั!)', + 'Constructing additional pylons...': 'ะัƒะถะฝะพ ะฟะพัั‚ั€ะพะธั‚ัŒ ะฑะพะปัŒัˆะต ะฟะธะปะพะฝะพะฒ...', +}; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 474753ae..dc00d068 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -108,7 +108,6 @@ export default { 'ๅœจๆต่งˆๅ™จไธญๆ‰“ๅผ€ๅฎŒๆ•ด็š„ Qwen Code ๆ–‡ๆกฃ', 'Configuration not available.': '้…็ฝฎไธๅฏ็”จ', 'change the auth method': 'ๆ›ดๆ”น่ฎค่ฏๆ–นๆณ•', - 'Show quit confirmation dialog': 'ๆ˜พ็คบ้€€ๅ‡บ็กฎ่ฎคๅฏน่ฏๆก†', 'Copy the last result or code snippet to clipboard': 'ๅฐ†ๆœ€ๅŽ็š„็ป“ๆžœๆˆ–ไปฃ็ ็‰‡ๆฎตๅคๅˆถๅˆฐๅ‰ช่ดดๆฟ', @@ -655,15 +654,6 @@ export default { 'A custom command wants to run the following shell commands:': '่‡ชๅฎšไน‰ๅ‘ฝไปคๆƒณ่ฆ่ฟ่กŒไปฅไธ‹ shell ๅ‘ฝไปค๏ผš', - // ============================================================================ - // Dialogs - Quit Confirmation - // ============================================================================ - 'What would you like to do before exiting?': '้€€ๅ‡บๅ‰ๆ‚จๆƒณ่ฆๅšไป€ไนˆ๏ผŸ', - 'Quit immediately (/quit)': '็ซ‹ๅณ้€€ๅ‡บ (/quit)', - 'Generate summary and quit (/summary)': '็”Ÿๆˆๆ‘˜่ฆๅนถ้€€ๅ‡บ (/summary)', - 'Save conversation and quit (/chat save)': 'ไฟๅญ˜ๅฏน่ฏๅนถ้€€ๅ‡บ (/chat save)', - 'Cancel (stay in application)': 'ๅ–ๆถˆ๏ผˆ็•™ๅœจๅบ”็”จ็จ‹ๅบไธญ๏ผ‰', - // ============================================================================ // Dialogs - Pro Quota // ============================================================================ @@ -830,6 +820,7 @@ export default { // Exit Screen / Stats // ============================================================================ 'Agent powering down. Goodbye!': 'Qwen Code ๆญฃๅœจๅ…ณ้—ญ๏ผŒๅ†่ง๏ผ', + 'To continue this session, run': '่ฆ็ปง็ปญๆญคไผš่ฏ๏ผŒ่ฏท่ฟ่กŒ', 'Interaction Summary': 'ไบคไบ’ๆ‘˜่ฆ', 'Session ID:': 'ไผš่ฏ ID๏ผš', 'Tool Calls:': 'ๅทฅๅ…ท่ฐƒ็”จ๏ผš', diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index fa1b0e0f..d6dc79a4 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -16,9 +16,12 @@ * Controllers: * - SystemController: initialize, interrupt, set_model, supported_commands * - PermissionController: can_use_tool, set_permission_mode - * - MCPController: mcp_message, mcp_server_status + * - SdkMcpController: mcp_server_status (mcp_message handled via callback) * - HookController: hook_callback * + * Note: mcp_message requests are NOT routed through the dispatcher. CLI MCP + * clients send messages via SdkMcpController.createSendSdkMcpMessage() callback. + * * Note: Control request types are centrally defined in the ControlRequestType * enum in packages/sdk/typescript/src/types/controlRequests.ts */ @@ -26,8 +29,8 @@ import type { IControlContext } from './ControlContext.js'; import type { IPendingRequestRegistry } from './controllers/baseController.js'; import { SystemController } from './controllers/systemController.js'; -// import { PermissionController } from './controllers/permissionController.js'; -// import { MCPController } from './controllers/mcpController.js'; +import { PermissionController } from './controllers/permissionController.js'; +import { SdkMcpController } from './controllers/sdkMcpController.js'; // import { HookController } from './controllers/hookController.js'; import type { CLIControlRequest, @@ -64,8 +67,8 @@ export class ControlDispatcher implements IPendingRequestRegistry { // Make controllers publicly accessible readonly systemController: SystemController; - // readonly permissionController: PermissionController; - // readonly mcpController: MCPController; + readonly permissionController: PermissionController; + readonly sdkMcpController: SdkMcpController; // readonly hookController: HookController; // Central pending request registries @@ -83,12 +86,16 @@ export class ControlDispatcher implements IPendingRequestRegistry { this, 'SystemController', ); - // this.permissionController = new PermissionController( - // context, - // this, - // 'PermissionController', - // ); - // this.mcpController = new MCPController(context, this, 'MCPController'); + this.permissionController = new PermissionController( + context, + this, + 'PermissionController', + ); + this.sdkMcpController = new SdkMcpController( + context, + this, + 'SdkMcpController', + ); // this.hookController = new HookController(context, this, 'HookController'); // Listen for main abort signal @@ -228,10 +235,10 @@ export class ControlDispatcher implements IPendingRequestRegistry { } this.pendingOutgoingRequests.clear(); - // Cleanup controllers (MCP controller will close all clients) + // Cleanup controllers this.systemController.cleanup(); - // this.permissionController.cleanup(); - // this.mcpController.cleanup(); + this.permissionController.cleanup(); + this.sdkMcpController.cleanup(); // this.hookController.cleanup(); } @@ -291,6 +298,47 @@ export class ControlDispatcher implements IPendingRequestRegistry { } } + /** + * Get count of pending incoming requests (for debugging) + */ + getPendingIncomingRequestCount(): number { + return this.pendingIncomingRequests.size; + } + + /** + * Wait for all incoming request handlers to complete. + * + * Uses polling since we don't have direct Promise references to handlers. + * The pendingIncomingRequests map is managed by BaseController: + * - Registered when handler starts (in handleRequest) + * - Deregistered when handler completes (success or error) + * + * @param pollIntervalMs - How often to check (default 50ms) + * @param timeoutMs - Maximum wait time (default 30s) + */ + async waitForPendingIncomingRequests( + pollIntervalMs: number = 50, + timeoutMs: number = 30000, + ): Promise { + const startTime = Date.now(); + + while (this.pendingIncomingRequests.size > 0) { + if (Date.now() - startTime > timeoutMs) { + if (this.context.debugMode) { + console.error( + `[ControlDispatcher] Timeout waiting for ${this.pendingIncomingRequests.size} pending incoming requests`, + ); + } + break; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + if (this.context.debugMode && this.pendingIncomingRequests.size === 0) { + console.error('[ControlDispatcher] All incoming requests completed'); + } + } + /** * Returns the controller that handles the given request subtype */ @@ -302,13 +350,12 @@ export class ControlDispatcher implements IPendingRequestRegistry { case 'supported_commands': return this.systemController; - // case 'can_use_tool': - // case 'set_permission_mode': - // return this.permissionController; + case 'can_use_tool': + case 'set_permission_mode': + return this.permissionController; - // case 'mcp_message': - // case 'mcp_server_status': - // return this.mcpController; + case 'mcp_server_status': + return this.sdkMcpController; // case 'hook_callback': // return this.hookController; diff --git a/packages/cli/src/nonInteractive/control/ControlService.ts b/packages/cli/src/nonInteractive/control/ControlService.ts index 7193fb63..671a1853 100644 --- a/packages/cli/src/nonInteractive/control/ControlService.ts +++ b/packages/cli/src/nonInteractive/control/ControlService.ts @@ -29,7 +29,7 @@ import type { IControlContext } from './ControlContext.js'; import type { ControlDispatcher } from './ControlDispatcher.js'; import type { - // PermissionServiceAPI, + PermissionServiceAPI, SystemServiceAPI, // McpServiceAPI, // HookServiceAPI, @@ -61,43 +61,31 @@ export class ControlService { * Handles tool execution permissions, approval checks, and callbacks. * Delegates to the shared PermissionController instance. */ - // get permission(): PermissionServiceAPI { - // const controller = this.dispatcher.permissionController; - // return { - // /** - // * Check if a tool should be allowed based on current permission settings - // * - // * Evaluates permission mode and tool registry to determine if execution - // * should proceed. Can optionally modify tool arguments based on confirmation details. - // * - // * @param toolRequest - Tool call request information - // * @param confirmationDetails - Optional confirmation details for UI - // * @returns Permission decision with optional updated arguments - // */ - // shouldAllowTool: controller.shouldAllowTool.bind(controller), - // - // /** - // * Build UI suggestions for tool confirmation dialogs - // * - // * Creates actionable permission suggestions based on tool confirmation details. - // * - // * @param confirmationDetails - Tool confirmation details - // * @returns Array of permission suggestions or null - // */ - // buildPermissionSuggestions: - // controller.buildPermissionSuggestions.bind(controller), - // - // /** - // * Get callback for monitoring tool call status updates - // * - // * Returns callback function for integration with CoreToolScheduler. - // * - // * @returns Callback function for tool call updates - // */ - // getToolCallUpdateCallback: - // controller.getToolCallUpdateCallback.bind(controller), - // }; - // } + get permission(): PermissionServiceAPI { + const controller = this.dispatcher.permissionController; + return { + /** + * Build UI suggestions for tool confirmation dialogs + * + * Creates actionable permission suggestions based on tool confirmation details. + * + * @param confirmationDetails - Tool confirmation details + * @returns Array of permission suggestions or null + */ + buildPermissionSuggestions: + controller.buildPermissionSuggestions.bind(controller), + + /** + * Get callback for monitoring tool call status updates + * + * Returns callback function for integration with CoreToolScheduler. + * + * @returns Callback function for tool call updates + */ + getToolCallUpdateCallback: + controller.getToolCallUpdateCallback.bind(controller), + }; + } /** * System Domain API diff --git a/packages/cli/src/nonInteractive/control/controllers/baseController.ts b/packages/cli/src/nonInteractive/control/controllers/baseController.ts index d2e20545..dcb9e7c9 100644 --- a/packages/cli/src/nonInteractive/control/controllers/baseController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/baseController.ts @@ -117,16 +117,41 @@ export abstract class BaseController { * Send an outgoing control request to SDK * * Manages lifecycle: register -> send -> wait for response -> deregister + * Respects the provided AbortSignal for cancellation. */ async sendControlRequest( payload: ControlRequestPayload, timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, + signal?: AbortSignal, ): Promise { + // Check if already aborted + if (signal?.aborted) { + throw new Error('Request aborted'); + } + const requestId = randomUUID(); return new Promise((resolve, reject) => { + // Setup abort handler + const abortHandler = () => { + this.registry.deregisterOutgoingRequest(requestId); + reject(new Error('Request aborted')); + if (this.context.debugMode) { + console.error( + `[${this.controllerName}] Outgoing request aborted: ${requestId}`, + ); + } + }; + + if (signal) { + signal.addEventListener('abort', abortHandler, { once: true }); + } + // Setup timeout const timeoutId = setTimeout(() => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } this.registry.deregisterOutgoingRequest(requestId); reject(new Error('Control request timeout')); if (this.context.debugMode) { @@ -136,12 +161,27 @@ export abstract class BaseController { } }, timeoutMs); + // Wrap resolve/reject to clean up abort listener + const wrappedResolve = (response: ControlResponse) => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + resolve(response); + }; + + const wrappedReject = (error: Error) => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + reject(error); + }; + // Register with central registry this.registry.registerOutgoingRequest( requestId, this.controllerName, - resolve, - reject, + wrappedResolve, + wrappedReject, timeoutId, ); @@ -155,6 +195,9 @@ export abstract class BaseController { try { this.context.streamJson.send(request); } catch (error) { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } this.registry.deregisterOutgoingRequest(requestId); reject(error); } @@ -174,7 +217,5 @@ export abstract class BaseController { /** * Cleanup resources */ - cleanup(): void { - // Subclasses can override to add cleanup logic - } + cleanup(): void {} } diff --git a/packages/cli/src/nonInteractive/control/controllers/mcpController.ts b/packages/cli/src/nonInteractive/control/controllers/mcpController.ts deleted file mode 100644 index fccafb67..00000000 --- a/packages/cli/src/nonInteractive/control/controllers/mcpController.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * MCP Controller - * - * Handles MCP-related control requests: - * - mcp_message: Route MCP messages - * - mcp_server_status: Return MCP server status - */ - -import { BaseController } from './baseController.js'; -import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { ResultSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { - ControlRequestPayload, - CLIControlMcpMessageRequest, -} from '../../types.js'; -import type { - MCPServerConfig, - WorkspaceContext, -} from '@qwen-code/qwen-code-core'; -import { - connectToMcpServer, - MCP_DEFAULT_TIMEOUT_MSEC, -} from '@qwen-code/qwen-code-core'; - -export class MCPController extends BaseController { - /** - * Handle MCP control requests - */ - protected async handleRequestPayload( - payload: ControlRequestPayload, - _signal: AbortSignal, - ): Promise> { - switch (payload.subtype) { - case 'mcp_message': - return this.handleMcpMessage(payload as CLIControlMcpMessageRequest); - - case 'mcp_server_status': - return this.handleMcpStatus(); - - default: - throw new Error(`Unsupported request subtype in MCPController`); - } - } - - /** - * Handle mcp_message request - * - * Routes JSON-RPC messages to MCP servers - */ - private async handleMcpMessage( - payload: CLIControlMcpMessageRequest, - ): Promise> { - const serverNameRaw = payload.server_name; - if ( - typeof serverNameRaw !== 'string' || - serverNameRaw.trim().length === 0 - ) { - throw new Error('Missing server_name in mcp_message request'); - } - - const message = payload.message; - if (!message || typeof message !== 'object') { - throw new Error( - 'Missing or invalid message payload for mcp_message request', - ); - } - - // Get or create MCP client - let clientEntry: { client: Client; config: MCPServerConfig }; - try { - clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim()); - } catch (error) { - throw new Error( - error instanceof Error - ? error.message - : 'Failed to connect to MCP server', - ); - } - - const method = message.method; - if (typeof method !== 'string' || method.trim().length === 0) { - throw new Error('Invalid MCP message: missing method'); - } - - const jsonrpcVersion = - typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0'; - const messageId = message.id; - const params = message.params; - const timeout = - typeof clientEntry.config.timeout === 'number' - ? clientEntry.config.timeout - : MCP_DEFAULT_TIMEOUT_MSEC; - - try { - // Handle notification (no id) - if (messageId === undefined) { - await clientEntry.client.notification({ - method, - params, - }); - return { - subtype: 'mcp_message', - mcp_response: { - jsonrpc: jsonrpcVersion, - id: null, - result: { success: true, acknowledged: true }, - }, - }; - } - - // Handle request (with id) - const result = await clientEntry.client.request( - { - method, - params, - }, - ResultSchema, - { timeout }, - ); - - return { - subtype: 'mcp_message', - mcp_response: { - jsonrpc: jsonrpcVersion, - id: messageId, - result, - }, - }; - } catch (error) { - // If connection closed, remove from cache - if (error instanceof Error && /closed/i.test(error.message)) { - this.context.mcpClients.delete(serverNameRaw.trim()); - } - - const errorCode = - typeof (error as { code?: unknown })?.code === 'number' - ? ((error as { code: number }).code as number) - : -32603; - const errorMessage = - error instanceof Error - ? error.message - : 'Failed to execute MCP request'; - const errorData = (error as { data?: unknown })?.data; - - const errorBody: Record = { - code: errorCode, - message: errorMessage, - }; - if (errorData !== undefined) { - errorBody['data'] = errorData; - } - - return { - subtype: 'mcp_message', - mcp_response: { - jsonrpc: jsonrpcVersion, - id: messageId ?? null, - error: errorBody, - }, - }; - } - } - - /** - * Handle mcp_server_status request - * - * Returns status of registered MCP servers - */ - private async handleMcpStatus(): Promise> { - const status: Record = {}; - - // Include SDK MCP servers - for (const serverName of this.context.sdkMcpServers) { - status[serverName] = 'connected'; - } - - // Include CLI-managed MCP clients - for (const serverName of this.context.mcpClients.keys()) { - status[serverName] = 'connected'; - } - - if (this.context.debugMode) { - console.error( - `[MCPController] MCP status: ${Object.keys(status).length} servers`, - ); - } - - return status; - } - - /** - * Get or create MCP client for a server - * - * Implements lazy connection and caching - */ - private async getOrCreateMcpClient( - serverName: string, - ): Promise<{ client: Client; config: MCPServerConfig }> { - // Check cache first - const cached = this.context.mcpClients.get(serverName); - if (cached) { - return cached; - } - - // Get server configuration - const provider = this.context.config as unknown as { - getMcpServers?: () => Record | undefined; - getDebugMode?: () => boolean; - getWorkspaceContext?: () => unknown; - }; - - if (typeof provider.getMcpServers !== 'function') { - throw new Error(`MCP server "${serverName}" is not configured`); - } - - const servers = provider.getMcpServers() ?? {}; - const serverConfig = servers[serverName]; - if (!serverConfig) { - throw new Error(`MCP server "${serverName}" is not configured`); - } - - const debugMode = - typeof provider.getDebugMode === 'function' - ? provider.getDebugMode() - : false; - - const workspaceContext = - typeof provider.getWorkspaceContext === 'function' - ? provider.getWorkspaceContext() - : undefined; - - if (!workspaceContext) { - throw new Error('Workspace context is not available for MCP connection'); - } - - // Connect to MCP server - const client = await connectToMcpServer( - serverName, - serverConfig, - debugMode, - workspaceContext as WorkspaceContext, - ); - - // Cache the client - const entry = { client, config: serverConfig }; - this.context.mcpClients.set(serverName, entry); - - if (this.context.debugMode) { - console.error(`[MCPController] Connected to MCP server: ${serverName}`); - } - - return entry; - } - - /** - * Cleanup MCP clients - */ - override cleanup(): void { - if (this.context.debugMode) { - console.error( - `[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`, - ); - } - - // Close all MCP clients - for (const [serverName, { client }] of this.context.mcpClients.entries()) { - try { - client.close(); - } catch (error) { - if (this.context.debugMode) { - console.error( - `[MCPController] Failed to close MCP client ${serverName}:`, - error, - ); - } - } - } - - this.context.mcpClients.clear(); - } -} diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index f93b4489..4cec3b00 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -15,8 +15,10 @@ */ import type { - ToolCallRequestInfo, WaitingToolCall, + ToolExecuteConfirmationDetails, + ToolMcpConfirmationDetails, + ApprovalMode, } from '@qwen-code/qwen-code-core'; import { InputFormat, @@ -42,15 +44,23 @@ export class PermissionController extends BaseController { */ protected async handleRequestPayload( payload: ControlRequestPayload, - _signal: AbortSignal, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + switch (payload.subtype) { case 'can_use_tool': - return this.handleCanUseTool(payload as CLIControlPermissionRequest); + return this.handleCanUseTool( + payload as CLIControlPermissionRequest, + signal, + ); case 'set_permission_mode': return this.handleSetPermissionMode( payload as CLIControlSetPermissionModeRequest, + signal, ); default: @@ -68,7 +78,12 @@ export class PermissionController extends BaseController { */ private async handleCanUseTool( payload: CLIControlPermissionRequest, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + const toolName = payload.tool_name; if ( !toolName || @@ -190,7 +205,12 @@ export class PermissionController extends BaseController { */ private async handleSetPermissionMode( payload: CLIControlSetPermissionModeRequest, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + const mode = payload.mode; const validModes: PermissionMode[] = [ 'default', @@ -206,6 +226,7 @@ export class PermissionController extends BaseController { } this.context.permissionMode = mode; + this.context.config.setApprovalMode(mode as ApprovalMode); if (this.context.debugMode) { console.error( @@ -334,47 +355,6 @@ export class PermissionController extends BaseController { } } - /** - * Check if a tool should be executed based on current permission settings - * - * This is a convenience method for direct tool execution checks without - * going through the control request flow. - */ - async shouldAllowTool( - toolRequest: ToolCallRequestInfo, - confirmationDetails?: unknown, - ): Promise<{ - allowed: boolean; - message?: string; - updatedArgs?: Record; - }> { - // Check permission mode - const modeResult = this.checkPermissionMode(); - if (!modeResult.allowed) { - return { - allowed: false, - message: modeResult.message, - }; - } - - // Check tool registry - const registryResult = this.checkToolRegistry(toolRequest.name); - if (!registryResult.allowed) { - return { - allowed: false, - message: registryResult.message, - }; - } - - // If we have confirmation details, we could potentially modify args - // This is a hook for future enhancement - if (confirmationDetails) { - // Future: handle argument modifications based on confirmation details - } - - return { allowed: true }; - } - /** * Get callback for monitoring tool calls and handling outgoing permission requests * This is passed to executeToolCall to hook into CoreToolScheduler updates @@ -411,6 +391,14 @@ export class PermissionController extends BaseController { toolCall: WaitingToolCall, ): Promise { try { + // Check if already aborted + if (this.context.abortSignal?.aborted) { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + return; + } + const inputFormat = this.context.config.getInputFormat?.(); const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON; @@ -439,7 +427,8 @@ export class PermissionController extends BaseController { permission_suggestions: permissionSuggestions, blocked_path: null, } as CLIControlPermissionRequest, - 30000, + undefined, // use default timeout + this.context.abortSignal, ); if (response.subtype !== 'success') { @@ -462,8 +451,15 @@ export class PermissionController extends BaseController { ToolConfirmationOutcome.ProceedOnce, ); } else { + // Extract cancel message from response if available + const cancelMessage = + typeof payload['message'] === 'string' + ? payload['message'] + : undefined; + await toolCall.confirmationDetails.onConfirm( ToolConfirmationOutcome.Cancel, + cancelMessage ? { cancelMessage } : undefined, ); } } catch (error) { @@ -473,9 +469,23 @@ export class PermissionController extends BaseController { error, ); } - await toolCall.confirmationDetails.onConfirm( - ToolConfirmationOutcome.Cancel, - ); + // On error, use default cancel message + // Only pass payload for exec and mcp types that support it + const confirmationType = toolCall.confirmationDetails.type; + if (['edit', 'exec', 'mcp'].includes(confirmationType)) { + const execOrMcpDetails = toolCall.confirmationDetails as + | ToolExecuteConfirmationDetails + | ToolMcpConfirmationDetails; + await execOrMcpDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + undefined, + ); + } else { + // For other types, don't pass payload (backward compatible) + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + } } finally { this.pendingOutgoingRequests.delete(toolCall.request.callId); } diff --git a/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts b/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts new file mode 100644 index 00000000..5d0264fb --- /dev/null +++ b/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SDK MCP Controller + * + * Handles MCP communication between CLI MCP clients and SDK MCP servers: + * - Provides sendSdkMcpMessage callback for CLI โ†’ SDK MCP message routing + * - mcp_server_status: Returns status of SDK MCP servers + * + * Message Flow (CLI MCP Client โ†’ SDK MCP Server): + * CLI MCP Client โ†’ SdkControlClientTransport.send() โ†’ + * sendSdkMcpMessage callback โ†’ control_request (mcp_message) โ†’ SDK โ†’ + * SDK MCP Server processes โ†’ control_response โ†’ CLI MCP Client + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { BaseController } from './baseController.js'; +import type { + ControlRequestPayload, + CLIControlMcpMessageRequest, +} from '../../types.js'; + +const MCP_REQUEST_TIMEOUT = 30_000; // 30 seconds + +export class SdkMcpController extends BaseController { + /** + * Handle SDK MCP control requests from ControlDispatcher + * + * Note: mcp_message requests are NOT handled here. CLI MCP clients + * send messages via the sendSdkMcpMessage callback directly, not + * through the control dispatcher. + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + switch (payload.subtype) { + case 'mcp_server_status': + return this.handleMcpStatus(); + + default: + throw new Error(`Unsupported request subtype in SdkMcpController`); + } + } + + /** + * Handle mcp_server_status request + * + * Returns status of all registered SDK MCP servers. + * SDK servers are considered "connected" if they are registered. + */ + private async handleMcpStatus(): Promise> { + const status: Record = {}; + + for (const serverName of this.context.sdkMcpServers) { + // SDK MCP servers are "connected" once registered since they run in SDK process + status[serverName] = 'connected'; + } + + return { + subtype: 'mcp_server_status', + status, + }; + } + + /** + * Send MCP message to SDK server via control plane + * + * @param serverName - Name of the SDK MCP server + * @param message - MCP JSON-RPC message to send + * @returns MCP JSON-RPC response from SDK server + */ + private async sendMcpMessageToSdk( + serverName: string, + message: JSONRPCMessage, + ): Promise { + if (this.context.debugMode) { + console.error( + `[SdkMcpController] Sending MCP message to SDK server '${serverName}':`, + JSON.stringify(message), + ); + } + + // Send control request to SDK with the MCP message + const response = await this.sendControlRequest( + { + subtype: 'mcp_message', + server_name: serverName, + message: message as CLIControlMcpMessageRequest['message'], + }, + MCP_REQUEST_TIMEOUT, + this.context.abortSignal, + ); + + // Extract MCP response from control response + const responsePayload = response.response as Record; + const mcpResponse = responsePayload?.['mcp_response'] as JSONRPCMessage; + + if (!mcpResponse) { + throw new Error( + `Invalid MCP response from SDK for server '${serverName}'`, + ); + } + + if (this.context.debugMode) { + console.error( + `[SdkMcpController] Received MCP response from SDK server '${serverName}':`, + JSON.stringify(mcpResponse), + ); + } + + return mcpResponse; + } + + /** + * Create a callback function for sending MCP messages to SDK servers. + * + * This callback is used by McpClientManager/SdkControlClientTransport to send + * MCP messages from CLI MCP clients to SDK MCP servers via the control plane. + * + * @returns A function that sends MCP messages to SDK and returns the response + */ + createSendSdkMcpMessage(): ( + serverName: string, + message: JSONRPCMessage, + ) => Promise { + return (serverName: string, message: JSONRPCMessage) => + this.sendMcpMessageToSdk(serverName, message); + } +} diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index c3fc651b..e214a881 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -18,7 +18,15 @@ import type { ControlRequestPayload, CLIControlInitializeRequest, CLIControlSetModelRequest, + CLIMcpServerConfig, } from '../../types.js'; +import { CommandService } from '../../../services/CommandService.js'; +import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js'; +import { + MCPServerConfig, + AuthProviderType, + type MCPOAuthConfig, +} from '@qwen-code/qwen-code-core'; export class SystemController extends BaseController { /** @@ -26,20 +34,30 @@ export class SystemController extends BaseController { */ protected async handleRequestPayload( payload: ControlRequestPayload, - _signal: AbortSignal, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + switch (payload.subtype) { case 'initialize': - return this.handleInitialize(payload as CLIControlInitializeRequest); + return this.handleInitialize( + payload as CLIControlInitializeRequest, + signal, + ); case 'interrupt': return this.handleInterrupt(); case 'set_model': - return this.handleSetModel(payload as CLIControlSetModelRequest); + return this.handleSetModel( + payload as CLIControlSetModelRequest, + signal, + ); case 'supported_commands': - return this.handleSupportedCommands(); + return this.handleSupportedCommands(signal); default: throw new Error(`Unsupported request subtype in SystemController`); @@ -49,15 +67,130 @@ export class SystemController extends BaseController { /** * Handle initialize request * - * Registers SDK MCP servers and returns capabilities + * Processes SDK MCP servers config. + * SDK servers are registered in context.sdkMcpServers + * and added to config.mcpServers with the sdk type flag. + * External MCP servers are configured separately in settings. */ private async handleInitialize( payload: CLIControlInitializeRequest, + signal: AbortSignal, ): Promise> { - // Register SDK MCP servers if provided - if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) { - for (const serverName of payload.sdkMcpServers) { - this.context.sdkMcpServers.add(serverName); + if (signal.aborted) { + throw new Error('Request aborted'); + } + + this.context.config.setSdkMode(true); + + // Process SDK MCP servers + if ( + payload.sdkMcpServers && + typeof payload.sdkMcpServers === 'object' && + payload.sdkMcpServers !== null + ) { + const sdkServers: Record = {}; + for (const [key, wireConfig] of Object.entries(payload.sdkMcpServers)) { + const name = + typeof wireConfig?.name === 'string' && wireConfig.name.trim().length + ? wireConfig.name + : key; + + this.context.sdkMcpServers.add(name); + sdkServers[name] = new MCPServerConfig( + undefined, // command + undefined, // args + undefined, // env + undefined, // cwd + undefined, // url + undefined, // httpUrl + undefined, // headers + undefined, // tcp + undefined, // timeout + true, // trust - SDK servers are trusted + undefined, // description + undefined, // includeTools + undefined, // excludeTools + undefined, // extensionName + undefined, // oauth + undefined, // authProviderType + undefined, // targetAudience + undefined, // targetServiceAccount + 'sdk', // type + ); + } + + const sdkServerCount = Object.keys(sdkServers).length; + if (sdkServerCount > 0) { + try { + this.context.config.addMcpServers(sdkServers); + if (this.context.debugMode) { + console.error( + `[SystemController] Added ${sdkServerCount} SDK MCP servers to config`, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to add SDK MCP servers:', + error, + ); + } + } + } + } + + if ( + payload.mcpServers && + typeof payload.mcpServers === 'object' && + payload.mcpServers !== null + ) { + const externalServers: Record = {}; + for (const [name, serverConfig] of Object.entries(payload.mcpServers)) { + const normalized = this.normalizeMcpServerConfig( + name, + serverConfig as CLIMcpServerConfig | undefined, + ); + if (normalized) { + externalServers[name] = normalized; + } + } + + const externalCount = Object.keys(externalServers).length; + if (externalCount > 0) { + try { + this.context.config.addMcpServers(externalServers); + if (this.context.debugMode) { + console.error( + `[SystemController] Added ${externalCount} external MCP servers to config`, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to add external MCP servers:', + error, + ); + } + } + } + } + + if (payload.agents && Array.isArray(payload.agents)) { + try { + this.context.config.setSessionSubagents(payload.agents); + + if (this.context.debugMode) { + console.error( + `[SystemController] Added ${payload.agents.length} session subagents to config`, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to add session subagents:', + error, + ); + } } } @@ -86,36 +219,98 @@ export class SystemController extends BaseController { buildControlCapabilities(): Record { const capabilities: Record = { can_handle_can_use_tool: true, - can_handle_hook_callback: true, + can_handle_hook_callback: false, can_set_permission_mode: typeof this.context.config.setApprovalMode === 'function', can_set_model: typeof this.context.config.setModel === 'function', + // SDK MCP servers are supported - messages routed through control plane + can_handle_mcp_message: true, }; - // Check if MCP message handling is available - try { - const mcpProvider = this.context.config as unknown as { - getMcpServers?: () => Record | undefined; - }; - if (typeof mcpProvider.getMcpServers === 'function') { - const servers = mcpProvider.getMcpServers(); - capabilities['can_handle_mcp_message'] = Boolean( - servers && Object.keys(servers).length > 0, - ); - } else { - capabilities['can_handle_mcp_message'] = false; - } - } catch (error) { + return capabilities; + } + + private normalizeMcpServerConfig( + serverName: string, + config?: CLIMcpServerConfig, + ): MCPServerConfig | null { + if (!config || typeof config !== 'object') { if (this.context.debugMode) { console.error( - '[SystemController] Failed to determine MCP capability:', - error, + `[SystemController] Ignoring invalid MCP server config for '${serverName}'`, ); } - capabilities['can_handle_mcp_message'] = false; + return null; } - return capabilities; + const authProvider = this.normalizeAuthProviderType( + config.authProviderType, + ); + const oauthConfig = this.normalizeOAuthConfig(config.oauth); + + return new MCPServerConfig( + config.command, + config.args, + config.env, + config.cwd, + config.url, + config.httpUrl, + config.headers, + config.tcp, + config.timeout, + config.trust, + config.description, + config.includeTools, + config.excludeTools, + config.extensionName, + oauthConfig, + authProvider, + config.targetAudience, + config.targetServiceAccount, + ); + } + + private normalizeAuthProviderType( + value?: string, + ): AuthProviderType | undefined { + if (!value) { + return undefined; + } + + switch (value) { + case AuthProviderType.DYNAMIC_DISCOVERY: + case AuthProviderType.GOOGLE_CREDENTIALS: + case AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION: + return value; + default: + if (this.context.debugMode) { + console.error( + `[SystemController] Unsupported authProviderType '${value}', skipping`, + ); + } + return undefined; + } + } + + private normalizeOAuthConfig( + oauth?: CLIMcpServerConfig['oauth'], + ): MCPOAuthConfig | undefined { + if (!oauth) { + return undefined; + } + + return { + enabled: oauth.enabled, + clientId: oauth.clientId, + clientSecret: oauth.clientSecret, + authorizationUrl: oauth.authorizationUrl, + tokenUrl: oauth.tokenUrl, + scopes: oauth.scopes, + audiences: oauth.audiences, + redirectUri: oauth.redirectUri, + tokenParamName: oauth.tokenParamName, + registrationUrl: oauth.registrationUrl, + }; } /** @@ -151,7 +346,12 @@ export class SystemController extends BaseController { */ private async handleSetModel( payload: CLIControlSetModelRequest, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + const model = payload.model; // Validate model parameter @@ -189,27 +389,63 @@ export class SystemController extends BaseController { /** * Handle supported_commands request * - * Returns list of supported control commands - * - * Note: This list should match the ControlRequestType enum in - * packages/sdk/typescript/src/types/controlRequests.ts + * Returns list of supported slash commands loaded dynamically */ - private async handleSupportedCommands(): Promise> { - const commands = [ - 'initialize', - 'interrupt', - 'set_model', - 'supported_commands', - 'can_use_tool', - 'set_permission_mode', - 'mcp_message', - 'mcp_server_status', - 'hook_callback', - ]; + private async handleSupportedCommands( + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + const slashCommands = await this.loadSlashCommandNames(signal); return { subtype: 'supported_commands', - commands, + commands: slashCommands, }; } + + /** + * Load slash command names using CommandService + * + * @param signal - AbortSignal to respect for cancellation + * @returns Promise resolving to array of slash command names + */ + private async loadSlashCommandNames(signal: AbortSignal): Promise { + if (signal.aborted) { + return []; + } + + try { + const service = await CommandService.create( + [new BuiltinCommandLoader(this.context.config)], + signal, + ); + + if (signal.aborted) { + return []; + } + + const names = new Set(); + const commands = service.getCommands(); + for (const command of commands) { + names.add(command.name); + } + return Array.from(names).sort(); + } catch (error) { + // Check if the error is due to abort + if (signal.aborted) { + return []; + } + + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to load slash commands:', + error, + ); + } + return []; + } + } } diff --git a/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts index c83637b7..9137d95a 100644 --- a/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts +++ b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts @@ -13,10 +13,7 @@ */ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { - ToolCallRequestInfo, - MCPServerConfig, -} from '@qwen-code/qwen-code-core'; +import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; import type { PermissionSuggestion } from '../../types.js'; /** @@ -26,25 +23,6 @@ import type { PermissionSuggestion } from '../../types.js'; * permission suggestions, and tool call monitoring callbacks. */ export interface PermissionServiceAPI { - /** - * Check if a tool should be allowed based on current permission settings - * - * Evaluates permission mode and tool registry to determine if execution - * should proceed. Can optionally modify tool arguments based on confirmation details. - * - * @param toolRequest - Tool call request information containing name, args, and call ID - * @param confirmationDetails - Optional confirmation details for UI-driven approvals - * @returns Promise resolving to permission decision with optional updated arguments - */ - shouldAllowTool( - toolRequest: ToolCallRequestInfo, - confirmationDetails?: unknown, - ): Promise<{ - allowed: boolean; - message?: string; - updatedArgs?: Record; - }>; - /** * Build UI suggestions for tool confirmation dialogs * diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 3968c5cc..915fb721 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -13,7 +13,11 @@ import type { ServerGeminiStreamEvent, TaskResultDisplay, } from '@qwen-code/qwen-code-core'; -import { GeminiEventType, ToolErrorType } from '@qwen-code/qwen-code-core'; +import { + GeminiEventType, + ToolErrorType, + parseAndFormatApiError, +} from '@qwen-code/qwen-code-core'; import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; import type { CLIAssistantMessage, @@ -600,6 +604,18 @@ export abstract class BaseJsonOutputAdapter { } this.finalizePendingBlocks(state, null); break; + case GeminiEventType.Error: { + // Format the error message using parseAndFormatApiError for consistency + // with interactive mode error display + const errorText = parseAndFormatApiError( + event.value.error, + this.config.getContentGeneratorConfig()?.authType, + undefined, + this.config.getModel(), + ); + this.appendText(state, errorText, null); + break; + } default: break; } @@ -939,9 +955,25 @@ export abstract class BaseJsonOutputAdapter { this.emitMessageImpl(message); } + /** + * Checks if responseParts contain any functionResponse with an error. + * This handles cancelled responses and other error cases where the error + * is embedded in responseParts rather than the top-level error field. + * @param responseParts - Array of Part objects + * @returns Error message if found, undefined otherwise + */ + private checkResponsePartsForError( + responseParts: Part[] | undefined, + ): string | undefined { + // Use the shared helper function defined at file level + return checkResponsePartsForError(responseParts); + } + /** * Emits a tool result message. * Collects execution denied tool calls for inclusion in result messages. + * Handles both explicit errors (response.error) and errors embedded in + * responseParts (e.g., cancelled responses). * @param request - Tool call request info * @param response - Tool call response info * @param parentToolUseId - Parent tool use ID (null for main agent) @@ -951,6 +983,14 @@ export abstract class BaseJsonOutputAdapter { response: ToolCallResponseInfo, parentToolUseId: string | null = null, ): void { + // Check for errors in responseParts (e.g., cancelled responses) + const responsePartsError = this.checkResponsePartsForError( + response.responseParts, + ); + + // Determine if this is an error response + const hasError = Boolean(response.error) || Boolean(responsePartsError); + // Track permission denials (execution denied errors) if ( response.error && @@ -967,7 +1007,7 @@ export abstract class BaseJsonOutputAdapter { const block: ToolResultBlock = { type: 'tool_result', tool_use_id: request.callId, - is_error: Boolean(response.error), + is_error: hasError, }; const content = toolResultContent(response); if (content !== undefined) { @@ -1173,11 +1213,41 @@ export function partsToString(parts: Part[]): string { .join(''); } +/** + * Checks if responseParts contain any functionResponse with an error. + * Helper function for extracting error messages from responseParts. + * @param responseParts - Array of Part objects + * @returns Error message if found, undefined otherwise + */ +function checkResponsePartsForError( + responseParts: Part[] | undefined, +): string | undefined { + if (!responseParts || responseParts.length === 0) { + return undefined; + } + + for (const part of responseParts) { + if ( + 'functionResponse' in part && + part.functionResponse?.response && + typeof part.functionResponse.response === 'object' && + 'error' in part.functionResponse.response && + part.functionResponse.response['error'] + ) { + const error = part.functionResponse.response['error']; + return typeof error === 'string' ? error : String(error); + } + } + + return undefined; +} + /** * Extracts content from tool response. * Uses functionResponsePartsToString to properly handle functionResponse parts, * which correctly extracts output content from functionResponse objects rather * than simply concatenating text or JSON.stringify. + * Also handles errors embedded in responseParts (e.g., cancelled responses). * * @param response - Tool call response * @returns String content or undefined @@ -1188,6 +1258,11 @@ export function toolResultContent( if (response.error) { return response.error.message; } + // Check for errors in responseParts (e.g., cancelled responses) + const responsePartsError = checkResponsePartsForError(response.responseParts); + if (responsePartsError) { + return responsePartsError; + } if ( typeof response.resultDisplay === 'string' && response.resultDisplay.trim().length > 0 diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 61643fb3..84d7dece 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -69,6 +69,7 @@ function createConfig(overrides: ConfigOverrides = {}): Config { getDebugMode: () => false, getApprovalMode: () => 'auto', getOutputFormat: () => 'stream-json', + initialize: vi.fn(), }; return { ...base, ...overrides } as unknown as Config; } @@ -152,6 +153,11 @@ describe('runNonInteractiveStreamJson', () => { handleControlResponse: ReturnType; handleCancel: ReturnType; shutdown: ReturnType; + getPendingIncomingRequestCount: ReturnType; + waitForPendingIncomingRequests: ReturnType; + sdkMcpController: { + createSendSdkMcpMessage: ReturnType; + }; }; let mockConsolePatcher: { patch: ReturnType; @@ -186,6 +192,11 @@ describe('runNonInteractiveStreamJson', () => { handleControlResponse: vi.fn(), handleCancel: vi.fn(), shutdown: vi.fn(), + getPendingIncomingRequestCount: vi.fn().mockReturnValue(0), + waitForPendingIncomingRequests: vi.fn().mockResolvedValue(undefined), + sdkMcpController: { + createSendSdkMcpMessage: vi.fn().mockReturnValue(vi.fn()), + }, }; ( ControlDispatcher as unknown as ReturnType diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index 614208b7..e8e6da12 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -4,18 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** - * Stream JSON Runner with Session State Machine - * - * Handles stream-json input/output format with: - * - Initialize handshake - * - Message routing (control vs user messages) - * - FIFO user message queue - * - Sequential message processing - * - Graceful shutdown - */ - -import type { Config } from '@qwen-code/qwen-code-core'; +import type { + Config, + ConfigInitializeOptions, +} from '@qwen-code/qwen-code-core'; import { StreamJsonInputReader } from './io/StreamJsonInputReader.js'; import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js'; import { ControlContext } from './control/ControlContext.js'; @@ -42,48 +34,7 @@ import { createMinimalSettings } from '../config/settings.js'; import { runNonInteractive } from '../nonInteractiveCli.js'; import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; -const SESSION_STATE = { - INITIALIZING: 'initializing', - IDLE: 'idle', - PROCESSING_QUERY: 'processing_query', - SHUTTING_DOWN: 'shutting_down', -} as const; - -type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE]; - -/** - * Message type classification for routing - */ -type MessageType = - | 'control_request' - | 'control_response' - | 'control_cancel' - | 'user' - | 'assistant' - | 'system' - | 'result' - | 'stream_event' - | 'unknown'; - -/** - * Routed message with classification - */ -interface RoutedMessage { - type: MessageType; - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest; -} - -/** - * Session Manager - * - * Manages the session lifecycle and message processing state machine. - */ -class SessionManager { - private state: SessionState = SESSION_STATE.INITIALIZING; +class Session { private userMessageQueue: CLIUserMessage[] = []; private abortController: AbortController; private config: Config; @@ -98,6 +49,15 @@ class SessionManager { private debugMode: boolean; private shutdownHandler: (() => void) | null = null; private initialPrompt: CLIUserMessage | null = null; + private processingPromise: Promise | null = null; + private isShuttingDown: boolean = false; + private configInitialized: boolean = false; + + // Single initialization promise that resolves when session is ready for user messages. + // Created lazily once initialization actually starts. + private initializationPromise: Promise | null = null; + private initializationResolve: (() => void) | null = null; + private initializationReject: ((error: Error) => void) | null = null; constructor(config: Config, initialPrompt?: CLIUserMessage) { this.config = config; @@ -112,161 +72,96 @@ class SessionManager { config.getIncludePartialMessages(), ); - // Setup signal handlers for graceful shutdown this.setupSignalHandlers(); } - /** - * Get next prompt ID - */ + private ensureInitializationPromise(): void { + if (this.initializationPromise) { + return; + } + this.initializationPromise = new Promise((resolve, reject) => { + this.initializationResolve = () => { + resolve(); + this.initializationResolve = null; + this.initializationReject = null; + }; + this.initializationReject = (error: Error) => { + reject(error); + this.initializationResolve = null; + this.initializationReject = null; + }; + }); + } + private getNextPromptId(): string { this.promptIdCounter++; return `${this.sessionId}########${this.promptIdCounter}`; } - /** - * Route a message to the appropriate handler based on its type - * - * Classifies incoming messages and routes them to appropriate handlers. - */ - private route( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, - ): RoutedMessage { - // Check control messages first - if (isControlRequest(message)) { - return { type: 'control_request', message }; - } - if (isControlResponse(message)) { - return { type: 'control_response', message }; - } - if (isControlCancel(message)) { - return { type: 'control_cancel', message }; + private async ensureConfigInitialized( + options?: ConfigInitializeOptions, + ): Promise { + if (this.configInitialized) { + return; } - // Check data messages - if (isCLIUserMessage(message)) { - return { type: 'user', message }; - } - if (isCLIAssistantMessage(message)) { - return { type: 'assistant', message }; - } - if (isCLISystemMessage(message)) { - return { type: 'system', message }; - } - if (isCLIResultMessage(message)) { - return { type: 'result', message }; - } - if (isCLIPartialAssistantMessage(message)) { - return { type: 'stream_event', message }; - } - - // Unknown message type if (this.debugMode) { - console.error( - '[SessionManager] Unknown message type:', - JSON.stringify(message, null, 2), - ); - } - return { type: 'unknown', message }; - } - - /** - * Process a single message with unified logic for both initial prompt and stream messages. - * - * Handles: - * - Abort check - * - First message detection and handling - * - Normal message processing - * - Shutdown state checks - * - * @param message - Message to process - * @returns true if the calling code should exit (break/return), false to continue - */ - private async processSingleMessage( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, - ): Promise { - // Check for abort - if (this.abortController.signal.aborted) { - return true; + console.error('[Session] Initializing config'); } - // Handle first message if control system not yet initialized - if (this.controlSystemEnabled === null) { - const handled = await this.handleFirstMessage(message); - if (handled) { - // If handled, check if we should shutdown - return this.state === SESSION_STATE.SHUTTING_DOWN; - } - // If not handled, fall through to normal processing - } - - // Process message normally - await this.processMessage(message); - - // Check for shutdown after processing - return this.state === SESSION_STATE.SHUTTING_DOWN; - } - - /** - * Main entry point - run the session - */ - async run(): Promise { try { - if (this.debugMode) { - console.error('[SessionManager] Starting session', this.sessionId); - } - - // Process initial prompt if provided - if (this.initialPrompt !== null) { - const shouldExit = await this.processSingleMessage(this.initialPrompt); - if (shouldExit) { - await this.shutdown(); - return; - } - } - - // Process messages from stream - for await (const message of this.inputReader.read()) { - const shouldExit = await this.processSingleMessage(message); - if (shouldExit) { - break; - } - } - - // Stream closed, shutdown - await this.shutdown(); + await this.config.initialize(options); + this.configInitialized = true; } catch (error) { if (this.debugMode) { - console.error('[SessionManager] Error:', error); + console.error('[Session] Failed to initialize config:', error); } - await this.shutdown(); throw error; - } finally { - // Ensure signal handlers are always cleaned up even if shutdown wasn't called - this.cleanupSignalHandlers(); } } + /** + * Mark initialization as complete + */ + private completeInitialization(): void { + if (this.initializationResolve) { + if (this.debugMode) { + console.error('[Session] Initialization complete'); + } + this.initializationResolve(); + this.initializationResolve = null; + this.initializationReject = null; + } + } + + /** + * Mark initialization as failed + */ + private failInitialization(error: Error): void { + if (this.initializationReject) { + if (this.debugMode) { + console.error('[Session] Initialization failed:', error); + } + this.initializationReject(error); + this.initializationResolve = null; + this.initializationReject = null; + } + } + + /** + * Wait for session to be ready for user messages + */ + private async waitForInitialization(): Promise { + if (!this.initializationPromise) { + return; + } + await this.initializationPromise; + } + private ensureControlSystem(): void { if (this.controlContext && this.dispatcher && this.controlService) { return; } - // The control system follows a strict three-layer architecture: - // 1. ControlContext (shared session state) - // 2. ControlDispatcher (protocol routing SDK โ†” CLI) - // 3. ControlService (programmatic API for CLI runtime) - // - // Application code MUST interact with the control plane exclusively through - // ControlService. ControlDispatcher is reserved for protocol-level message - // routing and should never be used directly outside of this file. this.controlContext = new ControlContext({ config: this.config, streamJson: this.outputAdapter, @@ -292,274 +187,166 @@ class SessionManager { return this.dispatcher; } - private async handleFirstMessage( + /** + * Handle the first message to determine session mode (SDK vs direct). + * This is synchronous from the message loop's perspective - it starts + * async work but does not return a promise that the loop awaits. + * + * The initialization completes asynchronously and resolves initializationPromise + * when ready for user messages. + */ + private handleFirstMessage( message: | CLIMessage | CLIControlRequest | CLIControlResponse | ControlCancelRequest, - ): Promise { - const routed = this.route(message); - - if (routed.type === 'control_request') { - const request = routed.message as CLIControlRequest; + ): void { + if (isControlRequest(message)) { + const request = message as CLIControlRequest; this.controlSystemEnabled = true; this.ensureControlSystem(); + if (request.request.subtype === 'initialize') { - await this.dispatcher?.dispatch(request); - this.state = SESSION_STATE.IDLE; - return true; - } - return false; - } - - if (routed.type === 'user') { - this.controlSystemEnabled = false; - this.state = SESSION_STATE.PROCESSING_QUERY; - this.userMessageQueue.push(routed.message as CLIUserMessage); - await this.processUserMessageQueue(); - return true; - } - - this.controlSystemEnabled = false; - return false; - } - - /** - * Process a single message from the stream - */ - private async processMessage( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, - ): Promise { - const routed = this.route(message); - - if (this.debugMode) { - console.error( - `[SessionManager] State: ${this.state}, Message type: ${routed.type}`, - ); - } - - switch (this.state) { - case SESSION_STATE.INITIALIZING: - await this.handleInitializingState(routed); - break; - - case SESSION_STATE.IDLE: - await this.handleIdleState(routed); - break; - - case SESSION_STATE.PROCESSING_QUERY: - await this.handleProcessingState(routed); - break; - - case SESSION_STATE.SHUTTING_DOWN: - // Ignore all messages during shutdown - break; - - default: { - // Exhaustive check - const _exhaustiveCheck: never = this.state; - if (this.debugMode) { - console.error('[SessionManager] Unknown state:', _exhaustiveCheck); - } - break; - } - } - } - - /** - * Handle messages in initializing state - */ - private async handleInitializingState(routed: RoutedMessage): Promise { - if (routed.type === 'control_request') { - const request = routed.message as CLIControlRequest; - const dispatcher = this.getDispatcher(); - if (!dispatcher) { - if (this.debugMode) { - console.error( - '[SessionManager] Control request received before control system initialization', - ); - } + // Start SDK mode initialization (fire-and-forget from loop perspective) + void this.initializeSdkMode(request); return; } - if (request.request.subtype === 'initialize') { - await dispatcher.dispatch(request); - this.state = SESSION_STATE.IDLE; - if (this.debugMode) { - console.error('[SessionManager] Initialized, transitioning to idle'); - } - } else { - if (this.debugMode) { - console.error( - '[SessionManager] Ignoring non-initialize control request during initialization', - ); - } - } - } else { + if (this.debugMode) { console.error( - '[SessionManager] Ignoring non-control message during initialization', + '[Session] Ignoring non-initialize control request during initialization', ); } - } - } - - /** - * Handle messages in idle state - */ - private async handleIdleState(routed: RoutedMessage): Promise { - const dispatcher = this.getDispatcher(); - if (routed.type === 'control_request') { - if (!dispatcher) { - if (this.debugMode) { - console.error('[SessionManager] Ignoring control request (disabled)'); - } - return; - } - const request = routed.message as CLIControlRequest; - await dispatcher.dispatch(request); - // Stay in idle state - } else if (routed.type === 'control_response') { - if (!dispatcher) { - return; - } - const response = routed.message as CLIControlResponse; - dispatcher.handleControlResponse(response); - // Stay in idle state - } else if (routed.type === 'control_cancel') { - if (!dispatcher) { - return; - } - const cancelRequest = routed.message as ControlCancelRequest; - dispatcher.handleCancel(cancelRequest.request_id); - } else if (routed.type === 'user') { - const userMessage = routed.message as CLIUserMessage; - this.userMessageQueue.push(userMessage); - // Start processing queue - await this.processUserMessageQueue(); - } else { - if (this.debugMode) { - console.error( - '[SessionManager] Ignoring message type in idle state:', - routed.type, - ); - } - } - } - - /** - * Handle messages in processing state - */ - private async handleProcessingState(routed: RoutedMessage): Promise { - const dispatcher = this.getDispatcher(); - if (routed.type === 'control_request') { - if (!dispatcher) { - if (this.debugMode) { - console.error( - '[SessionManager] Control request ignored during processing (disabled)', - ); - } - return; - } - const request = routed.message as CLIControlRequest; - await dispatcher.dispatch(request); - // Continue processing - } else if (routed.type === 'control_response') { - if (!dispatcher) { - return; - } - const response = routed.message as CLIControlResponse; - dispatcher.handleControlResponse(response); - // Continue processing - } else if (routed.type === 'user') { - // Enqueue for later - const userMessage = routed.message as CLIUserMessage; - this.userMessageQueue.push(userMessage); - if (this.debugMode) { - console.error( - '[SessionManager] Enqueued user message during processing', - ); - } - } else { - if (this.debugMode) { - console.error( - '[SessionManager] Ignoring message type during processing:', - routed.type, - ); - } - } - } - - /** - * Process user message queue (FIFO) - */ - private async processUserMessageQueue(): Promise { - while ( - this.userMessageQueue.length > 0 && - !this.abortController.signal.aborted - ) { - this.state = SESSION_STATE.PROCESSING_QUERY; - const userMessage = this.userMessageQueue.shift()!; - - try { - await this.processUserMessage(userMessage); - } catch (error) { - if (this.debugMode) { - console.error( - '[SessionManager] Error processing user message:', - error, - ); - } - // Send error result - this.emitErrorResult(error); - } - } - - // If control system is disabled (single-query mode) and queue is empty, - // automatically shutdown instead of returning to idle - if ( - !this.abortController.signal.aborted && - this.state === SESSION_STATE.PROCESSING_QUERY && - this.controlSystemEnabled === false && - this.userMessageQueue.length === 0 - ) { - if (this.debugMode) { - console.error( - '[SessionManager] Single-query mode: queue processed, shutting down', - ); - } - this.state = SESSION_STATE.SHUTTING_DOWN; return; } - // Return to idle after processing queue (for multi-query mode with control system) - if ( - !this.abortController.signal.aborted && - this.state === SESSION_STATE.PROCESSING_QUERY - ) { - this.state = SESSION_STATE.IDLE; + if (isCLIUserMessage(message)) { + this.controlSystemEnabled = false; + // Start direct mode initialization (fire-and-forget from loop perspective) + void this.initializeDirectMode(message as CLIUserMessage); + return; + } + + this.controlSystemEnabled = false; + } + + /** + * SDK mode initialization flow + * Dispatches initialize request and initializes config with MCP support + */ + private async initializeSdkMode(request: CLIControlRequest): Promise { + this.ensureInitializationPromise(); + try { + // Dispatch the initialize request first + // This registers SDK MCP servers in the control context + await this.dispatcher?.dispatch(request); + + // Get sendSdkMcpMessage callback from SdkMcpController + // This callback is used by McpClientManager to send MCP messages + // from CLI MCP clients to SDK MCP servers via the control plane + const sendSdkMcpMessage = + this.dispatcher?.sdkMcpController.createSendSdkMcpMessage(); + + // Initialize config with SDK MCP message support + await this.ensureConfigInitialized({ sendSdkMcpMessage }); + + // Initialization complete! + this.completeInitialization(); + } catch (error) { if (this.debugMode) { - console.error('[SessionManager] Queue processed, returning to idle'); + console.error('[Session] SDK mode initialization failed:', error); } + this.failInitialization( + error instanceof Error ? error : new Error(String(error)), + ); } } /** - * Process a single user message + * Direct mode initialization flow + * Initializes config and enqueues the first user message */ + private async initializeDirectMode( + userMessage: CLIUserMessage, + ): Promise { + this.ensureInitializationPromise(); + try { + // Initialize config + await this.ensureConfigInitialized(); + + // Initialization complete! + this.completeInitialization(); + + // Enqueue the first user message for processing + this.enqueueUserMessage(userMessage); + } catch (error) { + if (this.debugMode) { + console.error('[Session] Direct mode initialization failed:', error); + } + this.failInitialization( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + + /** + * Handle control request asynchronously (fire-and-forget from main loop). + * Errors are handled internally and responses sent by dispatcher. + */ + private handleControlRequestAsync(request: CLIControlRequest): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + if (this.debugMode) { + console.error('[Session] Control system not enabled'); + } + return; + } + + // Fire-and-forget: dispatch runs concurrently + // The dispatcher's pendingIncomingRequests tracks completion + void dispatcher.dispatch(request).catch((error) => { + if (this.debugMode) { + console.error('[Session] Control request dispatch error:', error); + } + // Error response is already sent by dispatcher.dispatch() + }); + } + + /** + * Handle control response - MUST be synchronous + * This resolves pending outgoing requests, breaking the deadlock cycle. + */ + private handleControlResponse(response: CLIControlResponse): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + dispatcher.handleControlResponse(response); + } + + private handleControlCancel(cancelRequest: ControlCancelRequest): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + dispatcher.handleCancel(cancelRequest.request_id); + } + private async processUserMessage(userMessage: CLIUserMessage): Promise { const input = extractUserMessageText(userMessage); if (!input) { if (this.debugMode) { - console.error('[SessionManager] No text content in user message'); + console.error('[Session] No text content in user message'); } return; } + // Wait for initialization to complete before processing user messages + await this.waitForInitialization(); + const promptId = this.getNextPromptId(); try { @@ -575,16 +362,56 @@ class SessionManager { }, ); } catch (error) { - // Error already handled by runNonInteractive via adapter.emitResult if (this.debugMode) { - console.error('[SessionManager] Query execution error:', error); + console.error('[Session] Query execution error:', error); } } } - /** - * Send tool results as user message - */ + private async processUserMessageQueue(): Promise { + if (this.isShuttingDown || this.abortController.signal.aborted) { + return; + } + + while ( + this.userMessageQueue.length > 0 && + !this.isShuttingDown && + !this.abortController.signal.aborted + ) { + const userMessage = this.userMessageQueue.shift()!; + try { + await this.processUserMessage(userMessage); + } catch (error) { + if (this.debugMode) { + console.error('[Session] Error processing user message:', error); + } + this.emitErrorResult(error); + } + } + } + + private enqueueUserMessage(userMessage: CLIUserMessage): void { + this.userMessageQueue.push(userMessage); + this.ensureProcessingStarted(); + } + + private ensureProcessingStarted(): void { + if (this.processingPromise) { + return; + } + + this.processingPromise = this.processUserMessageQueue().finally(() => { + this.processingPromise = null; + if ( + this.userMessageQueue.length > 0 && + !this.isShuttingDown && + !this.abortController.signal.aborted + ) { + this.ensureProcessingStarted(); + } + }); + } + private emitErrorResult( error: unknown, numTurns: number = 0, @@ -602,30 +429,21 @@ class SessionManager { }); } - /** - * Handle interrupt control request - */ private handleInterrupt(): void { if (this.debugMode) { - console.error('[SessionManager] Interrupt requested'); - } - // Abort current query if processing - if (this.state === SESSION_STATE.PROCESSING_QUERY) { - this.abortController.abort(); - this.abortController = new AbortController(); // Create new controller for next query + console.error('[Session] Interrupt requested'); } + this.abortController.abort(); + this.abortController = new AbortController(); } - /** - * Setup signal handlers for graceful shutdown - */ private setupSignalHandlers(): void { this.shutdownHandler = () => { if (this.debugMode) { - console.error('[SessionManager] Shutdown signal received'); + console.error('[Session] Shutdown signal received'); } + this.isShuttingDown = true; this.abortController.abort(); - this.state = SESSION_STATE.SHUTTING_DOWN; }; process.on('SIGINT', this.shutdownHandler); @@ -633,21 +451,58 @@ class SessionManager { } /** - * Shutdown session and cleanup resources + * Wait for all pending work to complete before shutdown */ - private async shutdown(): Promise { - if (this.debugMode) { - console.error('[SessionManager] Shutting down'); + private async waitForAllPendingWork(): Promise { + // 1. Wait for initialization to complete (or fail) + try { + await this.waitForInitialization(); + } catch (error) { + if (this.debugMode) { + console.error('[Session] Initialization error during shutdown:', error); + } } - this.state = SESSION_STATE.SHUTTING_DOWN; + // 2. Wait for all control request handlers using dispatcher's tracking + if (this.dispatcher) { + const pendingCount = this.dispatcher.getPendingIncomingRequestCount(); + if (pendingCount > 0 && this.debugMode) { + console.error( + `[Session] Waiting for ${pendingCount} pending control request handlers`, + ); + } + await this.dispatcher.waitForPendingIncomingRequests(); + } + + // 3. Wait for user message processing queue + while (this.processingPromise) { + if (this.debugMode) { + console.error('[Session] Waiting for user message processing'); + } + try { + await this.processingPromise; + } catch (error) { + if (this.debugMode) { + console.error('[Session] Error in user message processing:', error); + } + } + } + } + + private async shutdown(): Promise { + if (this.debugMode) { + console.error('[Session] Shutting down'); + } + + this.isShuttingDown = true; + + // Wait for all pending work + await this.waitForAllPendingWork(); + this.dispatcher?.shutdown(); this.cleanupSignalHandlers(); } - /** - * Remove signal handlers to prevent memory leaks - */ private cleanupSignalHandlers(): void { if (this.shutdownHandler) { process.removeListener('SIGINT', this.shutdownHandler); @@ -655,6 +510,105 @@ class SessionManager { this.shutdownHandler = null; } } + + /** + * Main message processing loop + * + * CRITICAL: This loop must NEVER await handlers that might need to + * send control requests and wait for responses. Such handlers must + * be started in fire-and-forget mode, allowing the loop to continue + * reading responses that resolve pending requests. + * + * Message handling order: + * 1. control_response - FIRST, synchronously resolves pending requests + * 2. First message - determines mode, starts async initialization + * 3. control_request - fire-and-forget, tracked by dispatcher + * 4. control_cancel - synchronous + * 5. user_message - enqueued for processing + */ + async run(): Promise { + try { + if (this.debugMode) { + console.error('[Session] Starting session', this.sessionId); + } + + // Handle initial prompt if provided (fire-and-forget) + if (this.initialPrompt !== null) { + this.handleFirstMessage(this.initialPrompt); + } + + try { + for await (const message of this.inputReader.read()) { + if (this.abortController.signal.aborted) { + break; + } + + // ============================================================ + // CRITICAL: Handle control_response FIRST and SYNCHRONOUSLY + // This resolves pending outgoing requests, breaking deadlock. + // ============================================================ + if (isControlResponse(message)) { + this.handleControlResponse(message as CLIControlResponse); + continue; + } + + // Handle first message to determine session mode + if (this.controlSystemEnabled === null) { + this.handleFirstMessage(message); + continue; + } + + // ============================================================ + // CRITICAL: Handle control_request in FIRE-AND-FORGET mode + // DON'T await - let handler run concurrently while loop continues + // Dispatcher's pendingIncomingRequests tracks completion + // ============================================================ + if (isControlRequest(message)) { + this.handleControlRequestAsync(message as CLIControlRequest); + } else if (isControlCancel(message)) { + // Cancel is synchronous - OK to handle inline + this.handleControlCancel(message as ControlCancelRequest); + } else if (isCLIUserMessage(message)) { + // User messages are enqueued, processing runs separately + this.enqueueUserMessage(message as CLIUserMessage); + } else if (this.debugMode) { + if ( + !isCLIAssistantMessage(message) && + !isCLISystemMessage(message) && + !isCLIResultMessage(message) && + !isCLIPartialAssistantMessage(message) + ) { + console.error( + '[Session] Unknown message type:', + JSON.stringify(message, null, 2), + ); + } + } + + if (this.isShuttingDown) { + break; + } + } + } catch (streamError) { + if (this.debugMode) { + console.error('[Session] Stream reading error:', streamError); + } + throw streamError; + } + + // Stream ended - wait for all pending work before shutdown + await this.waitForAllPendingWork(); + await this.shutdown(); + } catch (error) { + if (this.debugMode) { + console.error('[Session] Error:', error); + } + await this.shutdown(); + throw error; + } finally { + this.cleanupSignalHandlers(); + } + } } function extractUserMessageText(message: CLIUserMessage): string | null { @@ -682,12 +636,6 @@ function extractUserMessageText(message: CLIUserMessage): string | null { return null; } -/** - * Entry point for stream-json mode - * - * @param config - Configuration object - * @param input - Optional initial prompt input to process before reading from stream - */ export async function runNonInteractiveStreamJson( config: Config, input: string, @@ -698,7 +646,6 @@ export async function runNonInteractiveStreamJson( consolePatcher.patch(); try { - // Create initial user message from prompt input if provided let initialPrompt: CLIUserMessage | undefined = undefined; if (input && input.trim().length > 0) { const sessionId = config.getSessionId(); @@ -713,7 +660,7 @@ export async function runNonInteractiveStreamJson( }; } - const manager = new SessionManager(config, initialPrompt); + const manager = new Session(config, initialPrompt); await manager.run(); } finally { consolePatcher.cleanup(); diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 784ea916..1d5e800d 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { SubagentConfig } from '@qwen-code/qwen-code-core'; /** * Annotation for attaching metadata to content blocks @@ -137,9 +138,8 @@ export interface CLISystemMessage { status: string; }>; model?: string; - permissionMode?: string; + permission_mode?: string; slash_commands?: string[]; - apiKeySource?: string; qwen_code_version?: string; output_style?: string; agents?: string[]; @@ -295,10 +295,69 @@ export interface CLIControlPermissionRequest { blocked_path: string | null; } +/** + * Wire format for SDK MCP server config in initialization request. + * The actual Server instance stays in the SDK process. + */ +export interface SDKMcpServerConfig { + type: 'sdk'; + name: string; +} + +/** + * Wire format for external MCP server config in initialization request. + * Represents stdio/SSE/HTTP/TCP transports that must run in the CLI process. + */ +export interface CLIMcpServerConfig { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + url?: string; + httpUrl?: string; + headers?: Record; + tcp?: string; + timeout?: number; + trust?: boolean; + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + extensionName?: string; + oauth?: { + enabled?: boolean; + clientId?: string; + clientSecret?: string; + authorizationUrl?: string; + tokenUrl?: string; + scopes?: string[]; + audiences?: string[]; + redirectUri?: string; + tokenParamName?: string; + registrationUrl?: string; + }; + authProviderType?: + | 'dynamic_discovery' + | 'google_credentials' + | 'service_account_impersonation'; + targetAudience?: string; + targetServiceAccount?: string; +} + export interface CLIControlInitializeRequest { subtype: 'initialize'; hooks?: HookRegistration[] | null; - sdkMcpServers?: string[]; + /** + * SDK MCP servers config + * These are MCP servers running in the SDK process, connected via control plane. + * External MCP servers are configured separately in settings, not via initialization. + */ + sdkMcpServers?: Record>; + /** + * External MCP servers that the SDK wants the CLI to manage. + * These run outside the SDK process and require CLI-side transport setup. + */ + mcpServers?: Record; + agents?: SubagentConfig[]; } export interface CLIControlSetPermissionModeRequest { diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 5cc53fc6..30bc6a62 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -245,6 +245,7 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', + { isContinuation: false }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Hello'); expect(processStdoutSpy).toHaveBeenCalledWith(' World'); @@ -293,11 +294,21 @@ describe('runNonInteractive', () => { expect.any(AbortSignal), undefined, ); + // Verify first call has isContinuation: false + expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + 1, + [{ text: 'Use a tool' }], + expect.any(AbortSignal), + 'prompt-id-2', + { isContinuation: false }, + ); + // Verify second call (after tool execution) has isContinuation: true expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: 'Tool response' }], expect.any(AbortSignal), 'prompt-id-2', + { isContinuation: true }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Final answer'); expect(processStdoutSpy).toHaveBeenCalledWith('\n'); @@ -372,6 +383,7 @@ describe('runNonInteractive', () => { ], expect.any(AbortSignal), 'prompt-id-3', + { isContinuation: true }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.'); }); @@ -497,6 +509,7 @@ describe('runNonInteractive', () => { processedParts, expect.any(AbortSignal), 'prompt-id-7', + { isContinuation: false }, ); // 6. Assert the final output is correct @@ -528,6 +541,7 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', + { isContinuation: false }, ); // JSON adapter emits array of messages, last one is result with stats @@ -680,6 +694,7 @@ describe('runNonInteractive', () => { [{ text: 'Empty response test' }], expect.any(AbortSignal), 'prompt-id-empty', + { isContinuation: false }, ); // JSON adapter emits array of messages, last one is result with stats @@ -831,6 +846,7 @@ describe('runNonInteractive', () => { [{ text: 'Prompt from command' }], expect.any(AbortSignal), 'prompt-id-slash', + { isContinuation: false }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Response from command'); @@ -887,6 +903,7 @@ describe('runNonInteractive', () => { [{ text: '/unknowncommand' }], expect.any(AbortSignal), 'prompt-id-unknown', + { isContinuation: false }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown'); @@ -1217,6 +1234,7 @@ describe('runNonInteractive', () => { [{ text: 'Message from stream-json input' }], expect.any(AbortSignal), 'prompt-envelope', + { isContinuation: false }, ); }); @@ -1692,6 +1710,7 @@ describe('runNonInteractive', () => { [{ text: 'Simple string content' }], expect.any(AbortSignal), 'prompt-string-content', + { isContinuation: false }, ); // UserMessage with array of text blocks @@ -1724,6 +1743,7 @@ describe('runNonInteractive', () => { [{ text: 'First part' }, { text: 'Second part' }], expect.any(AbortSignal), 'prompt-blocks-content', + { isContinuation: false }, ); }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8e5a9c90..1614c304 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -15,7 +15,9 @@ import { FatalInputError, promptIdContext, OutputFormat, + InputFormat, uiTelemetryService, + parseAndFormatApiError, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -170,6 +172,7 @@ export async function runNonInteractive( adapter.emitMessage(systemMessage); } + let isFirstTurn = true; while (true) { turnCount++; if ( @@ -185,7 +188,9 @@ export async function runNonInteractive( currentMessages[0]?.parts || [], abortController.signal, prompt_id, + { isContinuation: !isFirstTurn }, ); + isFirstTurn = false; // Start assistant message for this turn if (adapter) { @@ -205,10 +210,21 @@ export async function runNonInteractive( } } else { // Text output mode - direct stdout - if (event.type === GeminiEventType.Content) { + if (event.type === GeminiEventType.Thought) { + process.stdout.write(event.value.description); + } else if (event.type === GeminiEventType.Content) { process.stdout.write(event.value); } else if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); + } else if (event.type === GeminiEventType.Error) { + // Format and output the error message for text mode + const errorText = parseAndFormatApiError( + event.value.error, + config.getContentGeneratorConfig()?.authType, + undefined, + config.getModel(), + ); + process.stderr.write(`${errorText}\n`); } } } @@ -225,40 +241,14 @@ export async function runNonInteractive( for (const requestInfo of toolCallRequests) { const finalRequestInfo = requestInfo; - /* - if (options.controlService) { - const permissionResult = - await options.controlService.permission.shouldAllowTool( - requestInfo, - ); - if (!permissionResult.allowed) { - if (config.getDebugMode()) { - console.error( - `[runNonInteractive] Tool execution denied: ${requestInfo.name}`, - permissionResult.message ?? '', - ); - } - if (adapter && permissionResult.message) { - adapter.emitSystemMessage('tool_denied', { - tool: requestInfo.name, - message: permissionResult.message, - }); - } - continue; - } - - if (permissionResult.updatedArgs) { - finalRequestInfo = { - ...requestInfo, - args: permissionResult.updatedArgs, - }; - } - } - - const toolCallUpdateCallback = options.controlService - ? options.controlService.permission.getToolCallUpdateCallback() - : undefined; - */ + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + const toolCallUpdateCallback = + inputFormat === InputFormat.STREAM_JSON && options.controlService + ? options.controlService.permission.getToolCallUpdateCallback() + : undefined; // Only pass outputUpdateHandler for Task tool const isTaskTool = finalRequestInfo.name === 'task'; @@ -277,13 +267,13 @@ export async function runNonInteractive( isTaskTool && taskToolProgressHandler ? { outputUpdateHandler: taskToolProgressHandler, - /* - toolCallUpdateCallback - ? { onToolCallsUpdate: toolCallUpdateCallback } - : undefined, - */ + onToolCallsUpdate: toolCallUpdateCallback, } - : undefined, + : toolCallUpdateCallback + ? { + onToolCallsUpdate: toolCallUpdateCallback, + } + : undefined, ); // Note: In JSON mode, subagent messages are automatically added to the main @@ -303,9 +293,6 @@ export async function runNonInteractive( ? toolResponse.resultDisplay : undefined, ); - // Note: We no longer emit a separate system message for tool errors - // in JSON/STREAM_JSON mode, as the error is already captured in the - // tool_result block with is_error=true. } if (adapter) { diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 67f8ee72..7c8e6fc5 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -58,7 +58,6 @@ vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} })); vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} })); -vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} })); vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} })); vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} })); vi.mock('../ui/commands/extensionsCommand.js', () => ({ @@ -71,7 +70,6 @@ vi.mock('../ui/commands/modelCommand.js', () => ({ })); vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {}, - quitConfirmCommand: {}, })); vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} })); vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} })); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 8be63a8e..d3877a8a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -15,7 +15,6 @@ import { bugCommand } from '../ui/commands/bugCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; -import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; @@ -28,7 +27,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; -import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js'; +import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; @@ -63,7 +62,6 @@ export class BuiltinCommandLoader implements ICommandLoader { clearCommand, compressCommand, copyCommand, - corgiCommand, docsCommand, directoryCommand, editorCommand, @@ -77,7 +75,6 @@ export class BuiltinCommandLoader implements ICommandLoader { modelCommand, ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), quitCommand, - quitConfirmCommand, restoreCommand(this.config), statsCommand, summaryCommand, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 59f26cf2..fd825b9d 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -56,10 +56,10 @@ export const createMockCommandContext = ( pendingItem: null, setPendingItem: vi.fn(), loadHistory: vi.fn(), - toggleCorgiMode: vi.fn(), toggleVimEnabled: vi.fn(), extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), + reloadCommands: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, session: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ebcc14f6..ff16c53d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -89,7 +89,6 @@ import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useExtensionUpdates } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; -import { useQuitConfirmation } from './hooks/useQuitConfirmation.js'; import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; @@ -137,7 +136,6 @@ export const AppContainer = (props: AppContainerProps) => { const { settings, config, initializationResult } = props; const historyManager = useHistory(); useMemoryMonitor(historyManager); - const [corgiMode, setCorgiMode] = useState(false); const [debugMessage, setDebugMessage] = useState(''); const [quittingMessages, setQuittingMessages] = useState< HistoryItem[] | null @@ -446,8 +444,6 @@ export const AppContainer = (props: AppContainerProps) => { const { toggleVimEnabled } = useVimMode(); - const { showQuitConfirmation } = useQuitConfirmation(); - const { isSubagentCreateDialogOpen, openSubagentCreateDialog, @@ -488,12 +484,10 @@ export const AppContainer = (props: AppContainerProps) => { }, 100); }, setDebugMessage, - toggleCorgiMode: () => setCorgiMode((prev) => !prev), dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, - _showQuitConfirmation: showQuitConfirmation, }), [ openAuthDialog, @@ -502,12 +496,10 @@ export const AppContainer = (props: AppContainerProps) => { openSettingsDialog, openModelDialog, setDebugMessage, - setCorgiMode, dispatchExtensionStateUpdate, openPermissionsDialog, openApprovalModeDialog, addConfirmUpdateExtensionRequest, - showQuitConfirmation, openSubagentCreateDialog, openAgentsManagerDialog, ], @@ -520,7 +512,6 @@ export const AppContainer = (props: AppContainerProps) => { commandContext, shellConfirmationRequest, confirmationRequest, - quitConfirmationRequest, } = useSlashCommandProcessor( config, settings, @@ -951,6 +942,7 @@ export const AppContainer = (props: AppContainerProps) => { isFocused, streamingState, elapsedTime, + settings, }); // Dialog close functionality @@ -969,7 +961,6 @@ export const AppContainer = (props: AppContainerProps) => { isFolderTrustDialogOpen, showWelcomeBackDialog, handleWelcomeBackClose, - quitConfirmationRequest, }); const handleExit = useCallback( @@ -983,25 +974,18 @@ export const AppContainer = (props: AppContainerProps) => { if (timerRef.current) { clearTimeout(timerRef.current); } - // Exit directly without showing confirmation dialog + // Exit directly handleSlashCommand('/quit'); return; } // First press: Prioritize cleanup tasks - // Special case: If quit-confirm dialog is open, Ctrl+C means "quit immediately" - if (quitConfirmationRequest) { - handleSlashCommand('/quit'); - return; - } - // 1. Close other dialogs (highest priority) /** * For AuthDialog it is required to complete the authentication process, * otherwise user cannot proceed to the next step. - * So a quit on AuthDialog should go with normal two press quit - * and without quit-confirm dialog. + * So a quit on AuthDialog should go with normal two press quit. */ if (isAuthDialogOpen) { setPressedOnce(true); @@ -1022,14 +1006,17 @@ export const AppContainer = (props: AppContainerProps) => { return; // Request cancelled, end processing } - // 3. Clear input buffer (if has content) + // 4. Clear input buffer (if has content) if (buffer.text.length > 0) { buffer.setText(''); return; // Input cleared, end processing } - // All cleanup tasks completed, show quit confirmation dialog - handleSlashCommand('/quit-confirm'); + // All cleanup tasks completed, set flag for double-press to quit + setPressedOnce(true); + timerRef.current = setTimeout(() => { + setPressedOnce(false); + }, CTRL_EXIT_PROMPT_DURATION_MS); }, [ isAuthDialogOpen, @@ -1037,7 +1024,6 @@ export const AppContainer = (props: AppContainerProps) => { closeAnyOpenDialog, streamingState, cancelOngoingRequest, - quitConfirmationRequest, buffer, ], ); @@ -1054,8 +1040,8 @@ export const AppContainer = (props: AppContainerProps) => { return; } - // On first press: set flag, start timer, and call handleExit for cleanup/quit-confirm - // On second press (within 500ms): handleExit sees flag and does fast quit + // On first press: set flag, start timer, and call handleExit for cleanup + // On second press (within timeout): handleExit sees flag and does fast quit if (!ctrlCPressedOnce) { setCtrlCPressedOnce(true); ctrlCTimerRef.current = setTimeout(() => { @@ -1196,7 +1182,6 @@ export const AppContainer = (props: AppContainerProps) => { !!confirmationRequest || confirmUpdateExtensionRequests.length > 0 || !!loopDetectionConfirmationRequest || - !!quitConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || @@ -1231,7 +1216,6 @@ export const AppContainer = (props: AppContainerProps) => { qwenAuthState, editorError, isEditorDialogOpen, - corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, @@ -1245,7 +1229,6 @@ export const AppContainer = (props: AppContainerProps) => { confirmationRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, - quitConfirmationRequest, geminiMdFileCount, streamingState, initError, @@ -1323,7 +1306,6 @@ export const AppContainer = (props: AppContainerProps) => { qwenAuthState, editorError, isEditorDialogOpen, - corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, @@ -1337,7 +1319,6 @@ export const AppContainer = (props: AppContainerProps) => { confirmationRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, - quitConfirmationRequest, geminiMdFileCount, streamingState, initError, diff --git a/packages/cli/src/ui/commands/corgiCommand.test.ts b/packages/cli/src/ui/commands/corgiCommand.test.ts deleted file mode 100644 index 3c25e8cd..00000000 --- a/packages/cli/src/ui/commands/corgiCommand.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { corgiCommand } from './corgiCommand.js'; -import { type CommandContext } from './types.js'; -import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; - -describe('corgiCommand', () => { - let mockContext: CommandContext; - - beforeEach(() => { - mockContext = createMockCommandContext(); - vi.spyOn(mockContext.ui, 'toggleCorgiMode'); - }); - - it('should call the toggleCorgiMode function on the UI context', async () => { - if (!corgiCommand.action) { - throw new Error('The corgi command must have an action.'); - } - - await corgiCommand.action(mockContext, ''); - - expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1); - }); - - it('should have the correct name and description', () => { - expect(corgiCommand.name).toBe('corgi'); - expect(corgiCommand.description).toBe('Toggles corgi mode.'); - }); -}); diff --git a/packages/cli/src/ui/commands/corgiCommand.ts b/packages/cli/src/ui/commands/corgiCommand.ts deleted file mode 100644 index 2da6ad3e..00000000 --- a/packages/cli/src/ui/commands/corgiCommand.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CommandKind, type SlashCommand } from './types.js'; - -export const corgiCommand: SlashCommand = { - name: 'corgi', - description: 'Toggles corgi mode.', - hidden: true, - kind: CommandKind.BUILT_IN, - action: (context, _args) => { - context.ui.toggleCorgiMode(); - }, -}; diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts new file mode 100644 index 00000000..5a4f395b --- /dev/null +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -0,0 +1,600 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { type CommandContext, CommandKind } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +// Mock i18n module +vi.mock('../../i18n/index.js', () => ({ + setLanguageAsync: vi.fn().mockResolvedValue(undefined), + getCurrentLanguage: vi.fn().mockReturnValue('en'), + t: vi.fn((key: string) => key), +})); + +// Mock settings module to avoid Storage side effect +vi.mock('../../config/settings.js', () => ({ + SettingScope: { + User: 'user', + Workspace: 'workspace', + Default: 'default', + }, +})); + +// Mock fs module +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + default: { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + }, + }; +}); + +// Mock Storage from core +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Storage: { + getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'), + getGlobalSettingsPath: vi + .fn() + .mockReturnValue('/mock/.qwen/settings.json'), + }, + }; +}); + +// Import modules after mocking +import * as i18n from '../../i18n/index.js'; +import { languageCommand } from './languageCommand.js'; + +describe('languageCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + mockContext = createMockCommandContext({ + services: { + config: { + getModel: vi.fn().mockReturnValue('test-model'), + }, + settings: { + merged: {}, + setValue: vi.fn(), + }, + }, + }); + + // Reset i18n mocks + vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en'); + vi.mocked(i18n.t).mockImplementation((key: string) => key); + + // Reset fs mocks + vi.mocked(fs.existsSync).mockReturnValue(false); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('command metadata', () => { + it('should have the correct name', () => { + expect(languageCommand.name).toBe('language'); + }); + + it('should have a description', () => { + expect(languageCommand.description).toBeDefined(); + expect(typeof languageCommand.description).toBe('string'); + }); + + it('should be a built-in command', () => { + expect(languageCommand.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have subcommands', () => { + expect(languageCommand.subCommands).toBeDefined(); + expect(languageCommand.subCommands?.length).toBe(2); + }); + + it('should have ui and output subcommands', () => { + const subCommandNames = languageCommand.subCommands?.map((c) => c.name); + expect(subCommandNames).toContain('ui'); + expect(subCommandNames).toContain('output'); + }); + }); + + describe('main command action - no arguments', () => { + it('should show current language settings when no arguments provided', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Current UI language:'), + }); + }); + + it('should show available subcommands in help', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('/language ui'), + }); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('/language output'), + }); + }); + + it('should show LLM output language when set', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + '# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY', + ); + + // Make t() function handle interpolation for this test + vi.mocked(i18n.t).mockImplementation( + (key: string, params?: Record) => { + if (params && key.includes('{{lang}}')) { + return key.replace('{{lang}}', params['lang'] || ''); + } + return key; + }, + ); + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Current UI language:'), + }); + // Verify it correctly parses "Chinese" from the template format + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Chinese'), + }); + }); + }); + + describe('main command action - config not available', () => { + it('should return error when config is null', async () => { + mockContext.services.config = null; + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Configuration not available'), + }); + }); + }); + + describe('/language ui subcommand', () => { + it('should show help when no language argument provided', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Usage: /language ui'), + }); + }); + + it('should set English with "en"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui en'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(mockContext.services.settings.setValue).toHaveBeenCalled(); + expect(mockContext.ui.reloadCommands).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set English with "en-US"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui en-US'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set English with "english"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui english'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set Chinese with "zh"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui zh'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set Chinese with "zh-CN"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui zh-CN'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set Chinese with "chinese"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui chinese'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should return error for invalid language', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui invalid'); + + expect(i18n.setLanguageAsync).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Invalid language'), + }); + }); + + it('should persist setting to user scope', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + await languageCommand.action(mockContext, 'ui en'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.anything(), // SettingScope.User + 'general.language', + 'en', + ); + }); + }); + + describe('/language output subcommand', () => { + it('should show help when no language argument provided', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'output'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Usage: /language output'), + }); + }); + + it('should create LLM output language rule file', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action( + mockContext, + 'output Chinese', + ); + + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('Chinese'), + 'utf-8', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'LLM output language rule file generated', + ), + }); + }); + + it('should include restart notice in success message', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action( + mockContext, + 'output Japanese', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('restart'), + }); + }); + + it('should handle file write errors gracefully', async () => { + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'output German'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Failed to generate'), + }); + }); + }); + + describe('backward compatibility - direct language arguments', () => { + it('should set Chinese with direct "zh" argument', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'zh'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set English with direct "en" argument', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'en'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should return error for unknown direct argument', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'unknown'); + + expect(i18n.setLanguageAsync).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Invalid command'), + }); + }); + }); + + describe('ui subcommand object', () => { + const uiSubcommand = languageCommand.subCommands?.find( + (c) => c.name === 'ui', + ); + + it('should have correct metadata', () => { + expect(uiSubcommand).toBeDefined(); + expect(uiSubcommand?.name).toBe('ui'); + expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have nested language subcommands', () => { + const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name); + expect(nestedNames).toContain('zh-CN'); + expect(nestedNames).toContain('en-US'); + }); + + it('should have action that sets language', async () => { + if (!uiSubcommand?.action) { + throw new Error('UI subcommand must have an action.'); + } + + const result = await uiSubcommand.action(mockContext, 'en'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + }); + + describe('output subcommand object', () => { + const outputSubcommand = languageCommand.subCommands?.find( + (c) => c.name === 'output', + ); + + it('should have correct metadata', () => { + expect(outputSubcommand).toBeDefined(); + expect(outputSubcommand?.name).toBe('output'); + expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have action that generates rule file', async () => { + if (!outputSubcommand?.action) { + throw new Error('Output subcommand must have an action.'); + } + + // Ensure mocks are properly set for this test + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const result = await outputSubcommand.action(mockContext, 'French'); + + expect(fs.writeFileSync).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'LLM output language rule file generated', + ), + }); + }); + }); + + describe('nested ui language subcommands', () => { + const uiSubcommand = languageCommand.subCommands?.find( + (c) => c.name === 'ui', + ); + const zhCNSubcommand = uiSubcommand?.subCommands?.find( + (c) => c.name === 'zh-CN', + ); + const enUSSubcommand = uiSubcommand?.subCommands?.find( + (c) => c.name === 'en-US', + ); + + it('zh-CN should have aliases', () => { + expect(zhCNSubcommand?.altNames).toContain('zh'); + expect(zhCNSubcommand?.altNames).toContain('chinese'); + }); + + it('en-US should have aliases', () => { + expect(enUSSubcommand?.altNames).toContain('en'); + expect(enUSSubcommand?.altNames).toContain('english'); + }); + + it('zh-CN action should set Chinese', async () => { + if (!zhCNSubcommand?.action) { + throw new Error('zh-CN subcommand must have an action.'); + } + + const result = await zhCNSubcommand.action(mockContext, ''); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('en-US action should set English', async () => { + if (!enUSSubcommand?.action) { + throw new Error('en-US subcommand must have an action.'); + } + + const result = await enUSSubcommand.action(mockContext, ''); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should reject extra arguments', async () => { + if (!zhCNSubcommand?.action) { + throw new Error('zh-CN subcommand must have an action.'); + } + + const result = await zhCNSubcommand.action(mockContext, 'extra args'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('do not accept additional arguments'), + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index ba04920b..455465ab 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null { if (fs.existsSync(filePath)) { try { const content = fs.readFileSync(filePath, 'utf-8'); - // Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese") - const match = content.match(/^#\s+(.+?)\s+Response Rules/i); + // Extract language name from the first line + // Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY" + const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i); if (match) { return match[1]; } @@ -127,16 +128,17 @@ async function setUiLanguage( context.ui.reloadCommands(); // Map language codes to friendly display names - const langDisplayNames: Record = { + const langDisplayNames: Partial> = { zh: 'ไธญๆ–‡๏ผˆzh-CN๏ผ‰', en: 'English๏ผˆen-US๏ผ‰', + ru: 'ะ ัƒััะบะธะน (ru-RU)', }; return { type: 'message', messageType: 'info', content: t('UI language changed to {{lang}}', { - lang: langDisplayNames[lang], + lang: langDisplayNames[lang] || lang, }), }; } @@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = { : t('LLM output language not set'), '', t('Available subcommands:'), - ` /language ui [zh-CN|en-US] - ${t('Set UI language')}`, + ` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`, ` /language output - ${t('Set LLM output language')}`, ].join('\n'); @@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = { const subcommand = parts[0].toLowerCase(); if (subcommand === 'ui') { - // Handle /language ui [zh-CN|en-US] + // Handle /language ui [zh-CN|en-US|ru-RU] if (parts.length === 1) { // Show UI language subcommand help return { @@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = { content: [ t('Set UI language'), '', - t('Usage: /language ui [zh-CN|en-US]'), + t('Usage: /language ui [zh-CN|en-US|ru-RU]'), '', t('Available options:'), t(' - zh-CN: Simplified Chinese'), t(' - en-US: English'), + t(' - ru-RU: Russian'), '', t( 'To request additional UI language packs, please open an issue on GitHub.', @@ -266,11 +269,18 @@ export const languageCommand: SlashCommand = { langArg === 'zh-cn' ) { targetLang = 'zh'; + } else if ( + langArg === 'ru' || + langArg === 'ru-RU' || + langArg === 'russian' || + langArg === 'ั€ัƒััะบะธะน' + ) { + targetLang = 'ru'; } else { return { type: 'message', messageType: 'error', - content: t('Invalid language. Available: en-US, zh-CN'), + content: t('Invalid language. Available: en-US, zh-CN, ru-RU'), }; } @@ -307,13 +317,20 @@ export const languageCommand: SlashCommand = { langArg === 'zh-cn' ) { targetLang = 'zh'; + } else if ( + langArg === 'ru' || + langArg === 'ru-RU' || + langArg === 'russian' || + langArg === 'ั€ัƒััะบะธะน' + ) { + targetLang = 'ru'; } else { return { type: 'message', messageType: 'error', content: [ t('Invalid command. Available subcommands:'), - ' - /language ui [zh-CN|en-US] - ' + t('Set UI language'), + ' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'), ' - /language output - ' + t('Set LLM output language'), ].join('\n'), }; @@ -423,6 +440,29 @@ export const languageCommand: SlashCommand = { return setUiLanguage(context, 'en'); }, }, + { + name: 'ru-RU', + altNames: ['ru', 'russian', 'ั€ัƒััะบะธะน'], + get description() { + return t('Set UI language to Russian (ru-RU)'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + if (args.trim().length > 0) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Language subcommands do not accept additional arguments.', + ), + }; + } + return setUiLanguage(context, 'ru'); + }, + }, ], }, { diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index fc9683c9..4e9da3a0 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -8,35 +8,6 @@ import { formatDuration } from '../utils/formatters.js'; import { CommandKind, type SlashCommand } from './types.js'; import { t } from '../../i18n/index.js'; -export const quitConfirmCommand: SlashCommand = { - name: 'quit-confirm', - get description() { - return t('Show quit confirmation dialog'); - }, - kind: CommandKind.BUILT_IN, - action: (context) => { - const now = Date.now(); - const { sessionStartTime } = context.session.stats; - const wallDuration = now - sessionStartTime.getTime(); - - return { - type: 'quit_confirmation', - messages: [ - { - type: 'user', - text: `/quit-confirm`, - id: now - 1, - }, - { - type: 'quit_confirmation', - duration: formatDuration(wallDuration), - id: now, - }, - ], - }; - }, -}; - export const quitCommand: SlashCommand = { name: 'quit', altNames: ['exit'], diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index bac02070..6f0faae3 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -84,7 +84,7 @@ describe('setupGithubCommand', async () => { const expectedSubstrings = [ `set -eEuo pipefail`, - `fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`, + `fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`, ]; for (const substring of expectedSubstrings) { @@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => { if (gitignoreExists) { const gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); - expect(gitignoreContent).toContain('.gemini/'); + expect(gitignoreContent).toContain('.qwen/'); expect(gitignoreContent).toContain('gha-creds-*.json'); } }); @@ -135,7 +135,7 @@ describe('updateGitignore', () => { const gitignorePath = path.join(scratchDir, '.gitignore'); const content = await fs.readFile(gitignorePath, 'utf8'); - expect(content).toBe('.gemini/\ngha-creds-*.json\n'); + expect(content).toBe('.qwen/\ngha-creds-*.json\n'); }); it('appends entries to existing .gitignore file', async () => { @@ -148,13 +148,13 @@ describe('updateGitignore', () => { const content = await fs.readFile(gitignorePath, 'utf8'); expect(content).toBe( - '# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n', + '# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n', ); }); it('does not add duplicate entries', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); - const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n'; + const existingContent = '.qwen/\nsome-other-file\ngha-creds-*.json\n'; await fs.writeFile(gitignorePath, existingContent); await updateGitignore(scratchDir); @@ -166,7 +166,7 @@ describe('updateGitignore', () => { it('adds only missing entries when some already exist', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); - const existingContent = '.gemini/\nsome-other-file\n'; + const existingContent = '.qwen/\nsome-other-file\n'; await fs.writeFile(gitignorePath, existingContent); await updateGitignore(scratchDir); @@ -174,17 +174,17 @@ describe('updateGitignore', () => { const content = await fs.readFile(gitignorePath, 'utf8'); // Should add only the missing gha-creds-*.json entry - expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n'); + expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n'); expect(content).toContain('gha-creds-*.json'); - // Should not duplicate .gemini/ entry - expect((content.match(/\.gemini\//g) || []).length).toBe(1); + // Should not duplicate .qwen/ entry + expect((content.match(/\.qwen\//g) || []).length).toBe(1); }); it('does not get confused by entries in comments or as substrings', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); const existingContent = [ - '# This is a comment mentioning .gemini/ folder', - 'my-app.gemini/config', + '# This is a comment mentioning .qwen/ folder', + 'my-app.qwen/config', '# Another comment with gha-creds-*.json pattern', 'some-other-gha-creds-file.json', '', @@ -196,7 +196,7 @@ describe('updateGitignore', () => { const content = await fs.readFile(gitignorePath, 'utf8'); // Should add both entries since they don't actually exist as gitignore rules - expect(content).toContain('.gemini/'); + expect(content).toContain('.qwen/'); expect(content).toContain('gha-creds-*.json'); // Verify the entries were added (not just mentioned in comments) @@ -204,9 +204,9 @@ describe('updateGitignore', () => { .split('\n') .map((line) => line.split('#')[0].trim()) .filter((line) => line); - expect(lines).toContain('.gemini/'); + expect(lines).toContain('.qwen/'); expect(lines).toContain('gha-creds-*.json'); - expect(lines).toContain('my-app.gemini/config'); + expect(lines).toContain('my-app.qwen/config'); expect(lines).toContain('some-other-gha-creds-file.json'); }); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 378f1101..b12268ed 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js'; import { t } from '../../i18n/index.js'; export const GITHUB_WORKFLOW_PATHS = [ - 'gemini-dispatch/gemini-dispatch.yml', - 'gemini-assistant/gemini-invoke.yml', - 'issue-triage/gemini-triage.yml', - 'issue-triage/gemini-scheduled-triage.yml', - 'pr-review/gemini-review.yml', + 'qwen-dispatch/qwen-dispatch.yml', + 'qwen-assistant/qwen-invoke.yml', + 'issue-triage/qwen-triage.yml', + 'issue-triage/qwen-scheduled-triage.yml', + 'pr-review/qwen-review.yml', ]; // Generate OS-specific commands to open the GitHub pages needed for setup. @@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] { return commands; } -// Add Gemini CLI specific entries to .gitignore file +// Add Qwen Code specific entries to .gitignore file export async function updateGitignore(gitRepoRoot: string): Promise { - const gitignoreEntries = ['.gemini/', 'gha-creds-*.json']; + const gitignoreEntries = ['.qwen/', 'gha-creds-*.json']; const gitignorePath = path.join(gitRepoRoot, '.gitignore'); try { @@ -121,7 +121,7 @@ export const setupGithubCommand: SlashCommand = { // Get the latest release tag from GitHub const proxy = context?.services?.config?.getProxy(); const releaseTag = await getLatestGitHubRelease(proxy); - const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; + const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`; // Create the .github/workflows directory to download the files into const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows'); @@ -143,7 +143,7 @@ export const setupGithubCommand: SlashCommand = { for (const workflow of GITHUB_WORKFLOW_PATHS) { downloads.push( (async () => { - const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`; + const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`; const response = await fetch(endpoint, { method: 'GET', dispatcher: proxy ? new ProxyAgent(proxy) : undefined, @@ -204,8 +204,9 @@ export const setupGithubCommand: SlashCommand = { toolName: 'run_shell_command', toolArgs: { description: - 'Setting up GitHub Actions to triage issues and review PRs with Gemini.', + 'Setting up GitHub Actions to triage issues and review PRs with Qwen.', command, + is_background: false, }, }; }, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 69d6e9d4..f2ec2173 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -64,8 +64,6 @@ export interface CommandContext { * @param history The array of history items to load. */ loadHistory: UseHistoryManagerReturn['loadHistory']; - /** Toggles a special display mode. */ - toggleCorgiMode: () => void; toggleVimEnabled: () => Promise; setGeminiMdFileCount: (count: number) => void; reloadCommands: () => void; @@ -100,12 +98,6 @@ export interface QuitActionReturn { messages: HistoryItem[]; } -/** The return type for a command action that requests quit confirmation. */ -export interface QuitConfirmationActionReturn { - type: 'quit_confirmation'; - messages: HistoryItem[]; -} - /** * The return type for a command action that results in a simple message * being displayed to the user. @@ -182,7 +174,6 @@ export type SlashCommandActionReturn = | ToolActionReturn | MessageActionReturn | QuitActionReturn - | QuitConfirmationActionReturn | OpenDialogActionReturn | LoadHistoryActionReturn | SubmitPromptActionReturn diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 084cd746..d660d704 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => }, branchName: 'main', debugMessage: '', - corgiMode: false, errorCount: 0, nightly: false, isTrustedFolder: true, @@ -183,6 +182,7 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState, settings); + // Smoke check that the Footer renders when enabled. expect(lastFrame()).toContain('Footer'); }); @@ -200,7 +200,6 @@ describe('Composer', () => { it('passes correct props to Footer including vim mode when enabled', async () => { const uiState = createMockUIState({ branchName: 'feature-branch', - corgiMode: true, errorCount: 2, sessionStats: { sessionId: 'test-session', diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 2f6f8636..d696c87a 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -36,10 +36,6 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; -import { - QuitConfirmationDialog, - QuitChoice, -} from './QuitConfirmationDialog.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -127,24 +123,6 @@ export const DialogManager = ({ /> ); } - if (uiState.quitConfirmationRequest) { - return ( - { - if (choice === QuitChoice.CANCEL) { - uiState.quitConfirmationRequest?.onConfirm(false, 'cancel'); - } else if (choice === QuitChoice.QUIT) { - uiState.quitConfirmationRequest?.onConfirm(true, 'quit'); - } else if (choice === QuitChoice.SUMMARY_AND_QUIT) { - uiState.quitConfirmationRequest?.onConfirm( - true, - 'summary_and_quit', - ); - } - }} - /> - ); - } if (uiState.confirmationRequest) { return ( { debugMode, branchName, debugMessage, - corgiMode, errorCount, showErrorDetails, promptTokenCount, @@ -45,7 +44,6 @@ export const Footer: React.FC = () => { debugMode: config.getDebugMode(), branchName: uiState.branchName, debugMessage: uiState.debugMessage, - corgiMode: uiState.corgiMode, errorCount: uiState.errorCount, showErrorDetails: uiState.showErrorDetails, promptTokenCount: uiState.sessionStats.lastPromptTokenCount, @@ -153,16 +151,6 @@ export const Footer: React.FC = () => { {showMemoryUsage && } - {corgiMode && ( - - | - โ–ผ - (ยด - แดฅ - `) - โ–ผ - - )} {!showErrorDetails && errorCount > 0 && ( | diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 7cca61ae..2c92af57 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -15,6 +15,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { renderWithProviders } from '../../test-utils/render.js'; +import { ConfigContext } from '../contexts/ConfigContext.js'; // Mock child components vi.mock('./messages/ToolGroupMessage.js', () => ({ @@ -22,7 +23,9 @@ vi.mock('./messages/ToolGroupMessage.js', () => ({ })); describe('', () => { - const mockConfig = {} as unknown as Config; + const mockConfig = { + getChatRecordingService: () => undefined, + } as unknown as Config; const baseItem = { id: 1, timestamp: 12345, @@ -133,9 +136,11 @@ describe('', () => { duration: '1s', }; const { lastFrame } = renderWithProviders( - - - , + + + + + , ); expect(lastFrame()).toContain('Agent powering down. Goodbye!'); }); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index bec9c23d..97e1fb47 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -15,6 +15,8 @@ import { InfoMessage } from './messages/InfoMessage.js'; import { ErrorMessage } from './messages/ErrorMessage.js'; import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { GeminiMessageContent } from './messages/GeminiMessageContent.js'; +import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js'; +import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js'; import { CompressionMessage } from './messages/CompressionMessage.js'; import { SummaryMessage } from './messages/SummaryMessage.js'; import { WarningMessage } from './messages/WarningMessage.js'; @@ -85,6 +87,26 @@ const HistoryItemDisplayComponent: React.FC = ({ terminalWidth={terminalWidth} /> )} + {itemForDisplay.type === 'gemini_thought' && ( + + )} + {itemForDisplay.type === 'gemini_thought_content' && ( + + )} {itemForDisplay.type === 'info' && ( )} @@ -108,9 +130,6 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'quit' && ( )} - {itemForDisplay.type === 'quit_confirmation' && ( - - )} {itemForDisplay.type === 'tool_group' && ( { mockBuffer.text = text; mockBuffer.lines = [text]; mockBuffer.viewportVisualLines = [text]; - mockBuffer.visualCursor = [0, 8]; // cursor after '๐Ÿ‘' (length is 6 + 2 for emoji) + mockBuffer.visualCursor = [0, 7]; // cursor after '๐Ÿ‘' (emoji is 1 code point, so total is 7) const { stdout, unmount } = renderWithProviders( , diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8af77059..7d174250 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -707,15 +707,20 @@ export const InputPrompt: React.FC = ({ statusText = t('Accepting edits'); } + const borderColor = + isShellFocused && !isEmbeddedShellFocused + ? (statusColor ?? theme.border.focused) + : theme.border.default; + return ( <> = ({ isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText) ) { + // Add zero-width space after cursor to prevent Ink from trimming trailing whitespace renderedLine.push( - {showCursor ? chalk.inverse(' ') : ' '} + {showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'} , ); } diff --git a/packages/cli/src/ui/components/QuitConfirmationDialog.tsx b/packages/cli/src/ui/components/QuitConfirmationDialog.tsx deleted file mode 100644 index 84162779..00000000 --- a/packages/cli/src/ui/components/QuitConfirmationDialog.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Box, Text } from 'ink'; -import type React from 'react'; -import { Colors } from '../colors.js'; -import { - RadioButtonSelect, - type RadioSelectItem, -} from './shared/RadioButtonSelect.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { t } from '../../i18n/index.js'; - -export enum QuitChoice { - CANCEL = 'cancel', - QUIT = 'quit', - SUMMARY_AND_QUIT = 'summary_and_quit', -} - -interface QuitConfirmationDialogProps { - onSelect: (choice: QuitChoice) => void; -} - -export const QuitConfirmationDialog: React.FC = ({ - onSelect, -}) => { - useKeypress( - (key) => { - if (key.name === 'escape') { - onSelect(QuitChoice.CANCEL); - } - }, - { isActive: true }, - ); - - const options: Array> = [ - { - key: 'quit', - label: t('Quit immediately (/quit)'), - value: QuitChoice.QUIT, - }, - { - key: 'summary-and-quit', - label: t('Generate summary and quit (/summary)'), - value: QuitChoice.SUMMARY_AND_QUIT, - }, - { - key: 'cancel', - label: t('Cancel (stay in application)'), - value: QuitChoice.CANCEL, - }, - ]; - - return ( - - - {t('What would you like to do before exiting?')} - - - - - ); -}; diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 766e851a..305b50b2 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; +import { ConfigContext } from '../contexts/ConfigContext.js'; vi.mock('../contexts/SessionContext.js', async (importOriginal) => { const actual = await importOriginal(); @@ -20,20 +21,36 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => { const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); -const renderWithMockedStats = (metrics: SessionMetrics) => { +const renderWithMockedStats = ( + metrics: SessionMetrics, + sessionId: string = 'test-session-id-12345', + promptCount: number = 5, + chatRecordingEnabled: boolean = true, +) => { useSessionStatsMock.mockReturnValue({ stats: { + sessionId, sessionStartTime: new Date(), metrics, lastPromptTokenCount: 0, - promptCount: 5, + promptCount, }, - getPromptCount: () => 5, + getPromptCount: () => promptCount, startNewPrompt: vi.fn(), }); - return render(); + const mockConfig = { + getChatRecordingService: vi.fn(() => + chatRecordingEnabled ? ({} as never) : undefined, + ), + }; + + return render( + + + , + ); }; describe('', () => { @@ -70,6 +87,68 @@ describe('', () => { const output = lastFrame(); expect(output).toContain('Agent powering down. Goodbye!'); + expect(output).toContain('To continue this session, run'); + expect(output).toContain('qwen --resume test-session-id-12345'); expect(output).toMatchSnapshot(); }); + + it('does not show resume message when there are no messages', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + + // Pass promptCount = 0 to simulate no messages + const { lastFrame } = renderWithMockedStats( + metrics, + 'test-session-id-12345', + 0, + ); + const output = lastFrame(); + + expect(output).toContain('Agent powering down. Goodbye!'); + expect(output).not.toContain('To continue this session, run'); + expect(output).not.toContain('qwen --resume'); + }); + + it('does not show resume message when chat recording is disabled', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + + const { lastFrame } = renderWithMockedStats( + metrics, + 'test-session-id-12345', + 5, + false, + ); + const output = lastFrame(); + + expect(output).toContain('Agent powering down. Goodbye!'); + expect(output).not.toContain('To continue this session, run'); + expect(output).not.toContain('qwen --resume'); + }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index c8d79e0e..b43f18bc 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -5,7 +5,11 @@ */ import type React from 'react'; +import { Box, Text } from 'ink'; import { StatsDisplay } from './StatsDisplay.js'; +import { useSessionStats } from '../contexts/SessionContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { theme } from '../semantic-colors.js'; import { t } from '../../i18n/index.js'; interface SessionSummaryDisplayProps { @@ -14,9 +18,31 @@ interface SessionSummaryDisplayProps { export const SessionSummaryDisplay: React.FC = ({ duration, -}) => ( - -); +}) => { + const config = useConfig(); + const { stats } = useSessionStats(); + + // Only show the resume message if there were messages in the session AND + // chat recording is enabled (otherwise there is nothing to resume). + const hasMessages = stats.promptCount > 0; + const canResume = !!config.getChatRecordingService(); + + return ( + <> + + {hasMessages && canResume && ( + + + {t('To continue this session, run')}{' '} + + qwen --resume {stats.sessionId} + + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index f96ec33c..9e4d294e 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => { context: { fileFiltering: { respectGitIgnore: false, - respectQwemIgnore: true, + respectQwenIgnore: true, enableRecursiveFileSearch: false, disableFuzzySearch: true, }, @@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => { loadMemoryFromIncludeDirectories: false, fileFiltering: { respectGitIgnore: false, - respectQwemIgnore: false, + respectQwenIgnore: false, enableRecursiveFileSearch: false, disableFuzzySearch: false, }, diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 9ae6eab1..efa14b8b 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = ` -"โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ (r:) commit โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +"โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + (r:) commit +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ git commit -m "feat: add search" in src/app" `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = ` -"โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ (r:) commit โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +"โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + (r:) commit +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ git commit -m "feat: add search" in src/app" `; exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` -"โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ > Type your message or @path/to/file โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ" +"โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + > Type your message or @path/to/file +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" `; exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` -"โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ ! Type your message or @path/to/file โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ" +"โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ! Type your message or @path/to/file +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" `; exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = ` -"โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ * Type your message or @path/to/file โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ" +"โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * Type your message or @path/to/file +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" `; exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = ` -"โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ > Type your message or @path/to/file โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ" +"โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + > Type your message or @path/to/file +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" `; diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index 7c925f72..dfa39ba8 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -6,7 +6,7 @@ exports[` > renders the summary display with a title 1` โ”‚ Agent powering down. Goodbye! โ”‚ โ”‚ โ”‚ โ”‚ Interaction Summary โ”‚ -โ”‚ Session ID: โ”‚ +โ”‚ Session ID: test-session-id-12345 โ”‚ โ”‚ Tool Calls: 0 ( โœ“ 0 x 0 ) โ”‚ โ”‚ Success Rate: 0.0% โ”‚ โ”‚ Code Changes: +42 -15 โ”‚ @@ -26,5 +26,7 @@ exports[` > renders the summary display with a title 1` โ”‚ โ”‚ โ”‚ ยป Tip: For a full token breakdown, run \`/stats model\`. โ”‚ โ”‚ โ”‚ -โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ" +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +To continue this session, run qwen --resume test-session-id-12345" `; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 7c2c04f9..fbc2244b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title false โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips false โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ @@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title false โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips false โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ @@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title false โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips false โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ @@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title false* โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips false* โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ @@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title false โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips false โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ @@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title false โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips false โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ @@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title false โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips false โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ @@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title false* โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips false โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ @@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title false โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips false โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ @@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se โ”‚ โ”‚ โ”‚ Language Auto (detect from system) โ”‚ โ”‚ โ”‚ +โ”‚ Terminal Bell true โ”‚ +โ”‚ โ”‚ โ”‚ Output Format Text โ”‚ โ”‚ โ”‚ โ”‚ Hide Window Title true* โ”‚ โ”‚ โ”‚ โ”‚ Show Status in Title false โ”‚ โ”‚ โ”‚ -โ”‚ Hide Tips true* โ”‚ -โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx new file mode 100644 index 00000000..22571852 --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Text, Box } from 'ink'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { theme } from '../../semantic-colors.js'; + +interface GeminiThoughtMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + terminalWidth: number; +} + +/** + * Displays model thinking/reasoning text with a softer, dimmed style + * to visually distinguish it from regular content output. + */ +export const GeminiThoughtMessage: React.FC = ({ + text, + isPending, + availableTerminalHeight, + terminalWidth, +}) => { + const prefix = 'โœฆ '; + const prefixWidth = prefix.length; + + return ( + + + {prefix} + + + + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx new file mode 100644 index 00000000..f68dd3b5 --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box } from 'ink'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { theme } from '../../semantic-colors.js'; + +interface GeminiThoughtMessageContentProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + terminalWidth: number; +} + +/** + * Continuation component for thought messages, similar to GeminiMessageContent. + * Used when a thought response gets too long and needs to be split for performance. + */ +export const GeminiThoughtMessageContent: React.FC< + GeminiThoughtMessageContentProps +> = ({ text, isPending, availableTerminalHeight, terminalWidth }) => { + const originalPrefix = 'โœฆ '; + const prefixWidth = originalPrefix.length; + + return ( + + + + ); +}; diff --git a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx index ab1cd2a9..ccec2ebf 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx @@ -69,7 +69,10 @@ export function EditOptionsStep({ if (selectedValue === 'editor') { // Launch editor directly try { - await launchEditor(selectedAgent?.filePath); + if (!selectedAgent.filePath) { + throw new Error('Agent has no file path'); + } + await launchEditor(selectedAgent.filePath); } catch (err) { setError( t('Failed to launch editor: {{error}}', { diff --git a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx index 613ac87e..add3dcb5 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx @@ -218,7 +218,7 @@ export const AgentSelectionStep = ({ const renderAgentItem = ( agent: { name: string; - level: 'project' | 'user' | 'builtin'; + level: 'project' | 'user' | 'builtin' | 'session'; isBuiltin?: boolean; }, index: number, @@ -267,7 +267,7 @@ export const AgentSelectionStep = ({ {t('Project Level ({{path}})', { - path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: projectAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} @@ -289,7 +289,7 @@ export const AgentSelectionStep = ({ > {t('User Level ({{path}})', { - path: userAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: userAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 21ff5389..62e54204 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -12,7 +12,6 @@ import type { ShellConfirmationRequest, ConfirmationRequest, LoopDetectionConfirmationRequest, - QuitConfirmationRequest, HistoryItemWithoutId, StreamingState, } from '../types.js'; @@ -55,7 +54,6 @@ export interface UIState { qwenAuthState: QwenAuthState; editorError: string | null; isEditorDialogOpen: boolean; - corgiMode: boolean; debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; @@ -69,7 +67,6 @@ export interface UIState { confirmationRequest: ConfirmationRequest | null; confirmUpdateExtensionRequests: ConfirmationRequest[]; loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null; - quitConfirmationRequest: QuitConfirmationRequest | null; geminiMdFileCount: number; streamingState: StreamingState; initError: string | null; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index dc8fcea7..42ce4099 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -153,7 +153,6 @@ describe('useSlashCommandProcessor', () => { openModelDialog: mockOpenModelDialog, quit: mockSetQuittingMessages, setDebugMessage: vi.fn(), - toggleCorgiMode: vi.fn(), }, ), ); @@ -909,7 +908,6 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // openThemeDialog mockOpenAuthDialog, vi.fn(), // openEditorDialog - vi.fn(), // toggleCorgiMode mockSetQuittingMessages, vi.fn(), // openSettingsDialog vi.fn(), // openModelSelectionDialog @@ -918,7 +916,6 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // toggleVimEnabled vi.fn(), // setIsProcessing vi.fn(), // setGeminiMdFileCount - vi.fn(), // _showQuitConfirmation ), ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 758eb972..6439c934 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -18,7 +18,6 @@ import { IdeClient, } from '@qwen-code/qwen-code-core'; import { useSessionStats } from '../contexts/SessionContext.js'; -import { formatDuration } from '../utils/formatters.js'; import type { Message, HistoryItemWithoutId, @@ -53,7 +52,6 @@ function serializeHistoryItemForRecording( const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'quit', - 'quit-confirm', 'exit', 'clear', 'reset', @@ -70,12 +68,10 @@ interface SlashCommandProcessorActions { openApprovalModeDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; - toggleCorgiMode: () => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; openAgentsManagerDialog: () => void; - _showQuitConfirmation: () => void; } /** @@ -115,10 +111,6 @@ export const useSlashCommandProcessor = ( prompt: React.ReactNode; onConfirm: (confirmed: boolean) => void; }>(null); - const [quitConfirmationRequest, setQuitConfirmationRequest] = - useState void; - }>(null); const [sessionShellAllowlist, setSessionShellAllowlist] = useState( new Set(), @@ -174,11 +166,6 @@ export const useSlashCommandProcessor = ( type: 'quit', duration: message.duration, }; - } else if (message.type === MessageType.QUIT_CONFIRMATION) { - historyItemContent = { - type: 'quit_confirmation', - duration: message.duration, - }; } else if (message.type === MessageType.COMPRESSION) { historyItemContent = { type: 'compression', @@ -218,7 +205,6 @@ export const useSlashCommandProcessor = ( setDebugMessage: actions.setDebugMessage, pendingItem, setPendingItem, - toggleCorgiMode: actions.toggleCorgiMode, toggleVimEnabled, setGeminiMdFileCount, reloadCommands, @@ -449,66 +435,6 @@ export const useSlashCommandProcessor = ( }); return { type: 'handled' }; } - case 'quit_confirmation': - // Show quit confirmation dialog instead of immediately quitting - setQuitConfirmationRequest({ - onConfirm: (shouldQuit: boolean, action?: string) => { - setQuitConfirmationRequest(null); - if (!shouldQuit) { - // User cancelled the quit operation - do nothing - return; - } - if (shouldQuit) { - if (action === 'summary_and_quit') { - // Generate summary and then quit - handleSlashCommand('/summary') - .then(() => { - // Wait for user to see the summary result - setTimeout(() => { - handleSlashCommand('/quit'); - }, 1200); - }) - .catch((error) => { - // If summary fails, still quit but show error - addItemWithRecording( - { - type: 'error', - text: `Failed to generate summary before quit: ${ - error instanceof Error - ? error.message - : String(error) - }`, - }, - Date.now(), - ); - // Give user time to see the error message - setTimeout(() => { - handleSlashCommand('/quit'); - }, 1000); - }); - } else { - // Just quit immediately - trigger the actual quit action - const now = Date.now(); - const { sessionStartTime } = sessionStats; - const wallDuration = now - sessionStartTime.getTime(); - - actions.quit([ - { - type: 'user', - text: `/quit`, - id: now - 1, - }, - { - type: 'quit', - duration: formatDuration(wallDuration), - id: now, - }, - ]); - } - } - }, - }); - return { type: 'handled' }; case 'quit': actions.quit(result.messages); @@ -692,7 +618,6 @@ export const useSlashCommandProcessor = ( setSessionShellAllowlist, setIsProcessing, setConfirmationRequest, - sessionStats, ], ); @@ -703,6 +628,5 @@ export const useSlashCommandProcessor = ( commandContext, shellConfirmationRequest, confirmationRequest, - quitConfirmationRequest, }; }; diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts index 1475aa52..e8beb86f 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts @@ -15,6 +15,23 @@ import { LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS, useAttentionNotifications, } from './useAttentionNotifications.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +const mockSettings: LoadedSettings = { + merged: { + general: { + terminalBell: true, + }, + }, +} as LoadedSettings; + +const mockSettingsDisabled: LoadedSettings = { + merged: { + general: { + terminalBell: false, + }, + }, +} as LoadedSettings; vi.mock('../../utils/attentionNotification.js', () => ({ notifyTerminalAttention: vi.fn(), @@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => { isFocused: true, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, ...props, }, }, @@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.WaitingForConfirmation, elapsedTime: 0, + settings: mockSettings, }, }); expect(mockedNotify).toHaveBeenCalledWith( AttentionNotificationReason.ToolApproval, + { enabled: true }, ); }); @@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.WaitingForConfirmation, elapsedTime: 0, + settings: mockSettings, }, }); @@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Responding, elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5, + settings: mockSettings, }, }); @@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, }, }); expect(mockedNotify).toHaveBeenCalledWith( AttentionNotificationReason.LongTaskComplete, + { enabled: true }, ); }); @@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => { isFocused: true, streamingState: StreamingState.Responding, elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2, + settings: mockSettings, }, }); @@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => { isFocused: true, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, }, }); @@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Responding, elapsedTime: 5, + settings: mockSettings, }, }); @@ -143,9 +170,30 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, }, }); expect(mockedNotify).not.toHaveBeenCalled(); }); + + it('does not notify when terminalBell setting is disabled', () => { + const { rerender } = render({ + settings: mockSettingsDisabled, + }); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.WaitingForConfirmation, + elapsedTime: 0, + settings: mockSettingsDisabled, + }, + }); + + expect(mockedNotify).toHaveBeenCalledWith( + AttentionNotificationReason.ToolApproval, + { enabled: false }, + ); + }); }); diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.ts index e632c827..7c5cd043 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.ts @@ -10,6 +10,7 @@ import { notifyTerminalAttention, AttentionNotificationReason, } from '../../utils/attentionNotification.js'; +import type { LoadedSettings } from '../../config/settings.js'; export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20; @@ -17,13 +18,16 @@ interface UseAttentionNotificationsOptions { isFocused: boolean; streamingState: StreamingState; elapsedTime: number; + settings: LoadedSettings; } export const useAttentionNotifications = ({ isFocused, streamingState, elapsedTime, + settings, }: UseAttentionNotificationsOptions) => { + const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true; const awaitingNotificationSentRef = useRef(false); const respondingElapsedRef = useRef(0); @@ -33,14 +37,16 @@ export const useAttentionNotifications = ({ !isFocused && !awaitingNotificationSentRef.current ) { - notifyTerminalAttention(AttentionNotificationReason.ToolApproval); + notifyTerminalAttention(AttentionNotificationReason.ToolApproval, { + enabled: terminalBellEnabled, + }); awaitingNotificationSentRef.current = true; } if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) { awaitingNotificationSentRef.current = false; } - }, [isFocused, streamingState]); + }, [isFocused, streamingState, terminalBellEnabled]); useEffect(() => { if (streamingState === StreamingState.Responding) { @@ -53,11 +59,13 @@ export const useAttentionNotifications = ({ respondingElapsedRef.current >= LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS; if (wasLongTask && !isFocused) { - notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete); + notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, { + enabled: terminalBellEnabled, + }); } // Reset tracking for next task respondingElapsedRef.current = 0; return; } - }, [streamingState, elapsedTime, isFocused]); + }, [streamingState, elapsedTime, isFocused, terminalBellEnabled]); }; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 70a06abc..298f4496 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -44,11 +44,6 @@ export interface DialogCloseOptions { // Welcome back dialog showWelcomeBackDialog: boolean; handleWelcomeBackClose: () => void; - - // Quit confirmation dialog - quitConfirmationRequest: { - onConfirm: (shouldQuit: boolean, action?: string) => void; - } | null; } /** @@ -96,9 +91,6 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } - // Note: quitConfirmationRequest is NOT handled here anymore - // It's handled specially in handleExit - ctrl+c in quit-confirm should exit immediately - // No dialog was open return false; }, [options]); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 5994cc60..f82caa80 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2261,6 +2261,57 @@ describe('useGeminiStream', () => { }); }); + it('should accumulate streamed thought descriptions', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { subject: '', description: 'thinking ' }, + }; + yield { + type: ServerGeminiEventType.Thought, + value: { subject: '', description: 'more' }, + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + false, // visionModelPreviewEnabled + () => {}, + 80, + 24, + ), + ); + + await act(async () => { + await result.current.submitQuery('Streamed thought'); + }); + + await waitFor(() => { + expect(result.current.thought?.description).toBe('thinking more'); + }); + }); + it('should memoize pendingHistoryItems', () => { mockUseReactToolScheduler.mockReturnValue([ [], diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 8e7cbc0d..b4df01b0 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -497,6 +497,61 @@ export const useGeminiStream = ( [addItem, pendingHistoryItemRef, setPendingHistoryItem], ); + const mergeThought = useCallback( + (incoming: ThoughtSummary) => { + setThought((prev) => { + if (!prev) { + return incoming; + } + const subject = incoming.subject || prev.subject; + const description = `${prev.description ?? ''}${incoming.description ?? ''}`; + return { subject, description }; + }); + }, + [setThought], + ); + + const handleThoughtEvent = useCallback( + ( + eventValue: ThoughtSummary, + currentThoughtBuffer: string, + userMessageTimestamp: number, + ): string => { + if (turnCancelledRef.current) { + return ''; + } + + // Extract the description text from the thought summary + const thoughtText = eventValue.description ?? ''; + if (!thoughtText) { + return currentThoughtBuffer; + } + + const newThoughtBuffer = currentThoughtBuffer + thoughtText; + + // If we're not already showing a thought, start a new one + if (pendingHistoryItemRef.current?.type !== 'gemini_thought') { + // If there's a pending non-thought item, finalize it first + if (pendingHistoryItemRef.current) { + addItem(pendingHistoryItemRef.current, userMessageTimestamp); + } + setPendingHistoryItem({ type: 'gemini_thought', text: '' }); + } + + // Update the existing thought message with accumulated content + setPendingHistoryItem({ + type: 'gemini_thought', + text: newThoughtBuffer, + }); + + // Also update the thought state for the loading indicator + mergeThought(eventValue); + + return newThoughtBuffer; + }, + [addItem, pendingHistoryItemRef, setPendingHistoryItem, mergeThought], + ); + const handleUserCancelledEvent = useCallback( (userMessageTimestamp: number) => { if (turnCancelledRef.current) { @@ -710,11 +765,16 @@ export const useGeminiStream = ( signal: AbortSignal, ): Promise => { let geminiMessageBuffer = ''; + let thoughtBuffer = ''; const toolCallRequests: ToolCallRequestInfo[] = []; for await (const event of stream) { switch (event.type) { case ServerGeminiEventType.Thought: - setThought(event.value); + thoughtBuffer = handleThoughtEvent( + event.value, + thoughtBuffer, + userMessageTimestamp, + ); break; case ServerGeminiEventType.Content: geminiMessageBuffer = handleContentEvent( @@ -776,6 +836,7 @@ export const useGeminiStream = ( }, [ handleContentEvent, + handleThoughtEvent, handleUserCancelledEvent, handleErrorEvent, scheduleToolCalls, diff --git a/packages/cli/src/ui/hooks/useQuitConfirmation.ts b/packages/cli/src/ui/hooks/useQuitConfirmation.ts deleted file mode 100644 index fff0d488..00000000 --- a/packages/cli/src/ui/hooks/useQuitConfirmation.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useCallback } from 'react'; -import { QuitChoice } from '../components/QuitConfirmationDialog.js'; - -export const useQuitConfirmation = () => { - const [isQuitConfirmationOpen, setIsQuitConfirmationOpen] = useState(false); - - const showQuitConfirmation = useCallback(() => { - setIsQuitConfirmationOpen(true); - }, []); - - const handleQuitConfirmationSelect = useCallback((choice: QuitChoice) => { - setIsQuitConfirmationOpen(false); - - if (choice === QuitChoice.CANCEL) { - return { shouldQuit: false, action: 'cancel' }; - } else if (choice === QuitChoice.QUIT) { - return { shouldQuit: true, action: 'quit' }; - } else if (choice === QuitChoice.SUMMARY_AND_QUIT) { - return { shouldQuit: true, action: 'summary_and_quit' }; - } - - // Default to cancel if unknown choice - return { shouldQuit: false, action: 'cancel' }; - }, []); - - return { - isQuitConfirmationOpen, - showQuitConfirmation, - handleQuitConfirmationSelect, - }; -}; diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index fc75924a..77929333 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] { loadHistory: (_newHistory) => {}, pendingItem: null, setPendingItem: (_item) => {}, - toggleCorgiMode: () => {}, toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, reloadCommands: () => {}, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index bc9a6317..96ed4c50 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -103,6 +103,16 @@ export type HistoryItemGeminiContent = HistoryItemBase & { text: string; }; +export type HistoryItemGeminiThought = HistoryItemBase & { + type: 'gemini_thought'; + text: string; +}; + +export type HistoryItemGeminiThoughtContent = HistoryItemBase & { + type: 'gemini_thought_content'; + text: string; +}; + export type HistoryItemInfo = HistoryItemBase & { type: 'info'; text: string; @@ -161,11 +171,6 @@ export type HistoryItemQuit = HistoryItemBase & { duration: string; }; -export type HistoryItemQuitConfirmation = HistoryItemBase & { - type: 'quit_confirmation'; - duration: string; -}; - export type HistoryItemToolGroup = HistoryItemBase & { type: 'tool_group'; tools: IndividualToolCallDisplay[]; @@ -246,6 +251,8 @@ export type HistoryItemWithoutId = | HistoryItemUserShell | HistoryItemGemini | HistoryItemGeminiContent + | HistoryItemGeminiThought + | HistoryItemGeminiThoughtContent | HistoryItemInfo | HistoryItemError | HistoryItemWarning @@ -256,7 +263,6 @@ export type HistoryItemWithoutId = | HistoryItemModelStats | HistoryItemToolStats | HistoryItemQuit - | HistoryItemQuitConfirmation | HistoryItemCompression | HistoryItemSummary | HistoryItemCompression @@ -278,7 +284,6 @@ export enum MessageType { MODEL_STATS = 'model_stats', TOOL_STATS = 'tool_stats', QUIT = 'quit', - QUIT_CONFIRMATION = 'quit_confirmation', GEMINI = 'gemini', COMPRESSION = 'compression', SUMMARY = 'summary', @@ -342,12 +347,6 @@ export type Message = duration: string; content?: string; } - | { - type: MessageType.QUIT_CONFIRMATION; - timestamp: Date; - duration: string; - content?: string; - } | { type: MessageType.COMPRESSION; compression: CompressionProps; @@ -404,7 +403,3 @@ export interface ConfirmationRequest { export interface LoopDetectionConfirmationRequest { onComplete: (result: { userSelection: 'disable' | 'keep' }) => void; } - -export interface QuitConfirmationRequest { - onConfirm: (shouldQuit: boolean, action?: string) => void; -} diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 4320c519..48efc6e8 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -19,12 +19,16 @@ const UNDERLINE_TAG_END_LENGTH = 4; // For "" interface RenderInlineProps { text: string; + textColor?: string; } -const RenderInlineInternal: React.FC = ({ text }) => { +const RenderInlineInternal: React.FC = ({ + text, + textColor = theme.text.primary, +}) => { // Early return for plain text without markdown or URLs if (!/[*_~`<[https?:]/.test(text)) { - return {text}; + return {text}; } const nodes: React.ReactNode[] = []; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index da6bf21a..b5e7dd5d 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -17,6 +17,7 @@ interface MarkdownDisplayProps { isPending: boolean; availableTerminalHeight?: number; terminalWidth: number; + textColor?: string; } // Constants for Markdown parsing and rendering @@ -31,6 +32,7 @@ const MarkdownDisplayInternal: React.FC = ({ isPending, availableTerminalHeight, terminalWidth, + textColor = theme.text.primary, }) => { if (!text) return <>; @@ -116,7 +118,7 @@ const MarkdownDisplayInternal: React.FC = ({ addContentBlock( - + , ); @@ -155,7 +157,7 @@ const MarkdownDisplayInternal: React.FC = ({ addContentBlock( - + , ); @@ -173,36 +175,36 @@ const MarkdownDisplayInternal: React.FC = ({ switch (level) { case 1: headerNode = ( - - + + ); break; case 2: headerNode = ( - - + + ); break; case 3: headerNode = ( - - + + ); break; case 4: headerNode = ( - - + + ); break; default: headerNode = ( - - + + ); break; @@ -219,6 +221,7 @@ const MarkdownDisplayInternal: React.FC = ({ type="ul" marker={marker} leadingWhitespace={leadingWhitespace} + textColor={textColor} />, ); } else if (olMatch) { @@ -232,6 +235,7 @@ const MarkdownDisplayInternal: React.FC = ({ type="ol" marker={marker} leadingWhitespace={leadingWhitespace} + textColor={textColor} />, ); } else { @@ -245,8 +249,8 @@ const MarkdownDisplayInternal: React.FC = ({ } else { addContentBlock( - - + + , ); @@ -367,6 +371,7 @@ interface RenderListItemProps { type: 'ul' | 'ol'; marker: string; leadingWhitespace?: string; + textColor?: string; } const RenderListItemInternal: React.FC = ({ @@ -374,6 +379,7 @@ const RenderListItemInternal: React.FC = ({ type, marker, leadingWhitespace = '', + textColor = theme.text.primary, }) => { const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; const prefixWidth = prefix.length; @@ -385,11 +391,11 @@ const RenderListItemInternal: React.FC = ({ flexDirection="row" > - {prefix} + {prefix} - - + + diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index f0c94fab..29d60272 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -102,7 +102,7 @@ describe('resumeHistoryUtils', () => { ]); }); - it('marks tool results as error, skips thought text, and falls back when tool is missing', () => { + it('marks tool results as error, captures thought text, and falls back when tool is missing', () => { const conversation = { messages: [ { @@ -142,6 +142,11 @@ describe('resumeHistoryUtils', () => { const items = buildResumedHistoryItems(session, makeConfig({})); expect(items).toEqual([ + { + id: expect.any(Number), + type: 'gemini_thought', + text: 'should be skipped', + }, { id: expect.any(Number), type: 'gemini', text: 'visible text' }, { id: expect.any(Number), diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 85ae0572..3c69bfd4 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -17,7 +17,7 @@ import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; import { ToolCallStatus } from '../types.js'; /** - * Extracts text content from a Content object's parts. + * Extracts text content from a Content object's parts (excluding thought parts). */ function extractTextFromParts(parts: Part[] | undefined): string { if (!parts) return ''; @@ -34,6 +34,22 @@ function extractTextFromParts(parts: Part[] | undefined): string { return textParts.join('\n'); } +/** + * Extracts thought text content from a Content object's parts. + * Thought parts are identified by having `thought: true`. + */ +function extractThoughtTextFromParts(parts: Part[] | undefined): string { + if (!parts) return ''; + + const thoughtParts: string[] = []; + for (const part of parts) { + if ('text' in part && part.text && 'thought' in part && part.thought) { + thoughtParts.push(part.text); + } + } + return thoughtParts.join('\n'); +} + /** * Extracts function calls from a Content object's parts. */ @@ -187,12 +203,28 @@ function convertToHistoryItems( case 'assistant': { const parts = record.message?.parts as Part[] | undefined; + // Extract thought content + const thoughtText = extractThoughtTextFromParts(parts); + // Extract text content (non-function-call, non-thought) const text = extractTextFromParts(parts); // Extract function calls const functionCalls = extractFunctionCalls(parts); + // If there's thought content, add it as a gemini_thought message + if (thoughtText) { + // Flush any pending tool group before thought + if (currentToolGroup.length > 0) { + items.push({ + type: 'tool_group', + tools: [...currentToolGroup], + }); + currentToolGroup = []; + } + items.push({ type: 'gemini_thought', text: thoughtText }); + } + // If there's text content, add it as a gemini message if (text) { // Flush any pending tool group before text diff --git a/packages/cli/src/utils/attentionNotification.ts b/packages/cli/src/utils/attentionNotification.ts index 26dc2a25..e166444f 100644 --- a/packages/cli/src/utils/attentionNotification.ts +++ b/packages/cli/src/utils/attentionNotification.ts @@ -13,6 +13,7 @@ export enum AttentionNotificationReason { export interface TerminalNotificationOptions { stream?: Pick; + enabled?: boolean; } const TERMINAL_BELL = '\u0007'; @@ -28,6 +29,11 @@ export function notifyTerminalAttention( _reason: AttentionNotificationReason, options: TerminalNotificationOptions = {}, ): boolean { + // Check if terminal bell is enabled (default true for backwards compatibility) + if (options.enabled === false) { + return false; + } + const stream = options.stream ?? process.stdout; if (!stream?.write || stream.isTTY === false) { return false; diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index 11cf729b..35f58210 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -58,7 +58,7 @@ export const getLatestGitHubRelease = async ( try { const controller = new AbortController(); - const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`; + const endpoint = `https://api.github.com/repos/QwenLM/qwen-code-action/releases/latest`; const response = await fetch(endpoint, { method: 'GET', @@ -83,9 +83,12 @@ export const getLatestGitHubRelease = async ( } return releaseTag; } catch (_error) { - console.debug(`Failed to determine latest run-gemini-cli release:`, _error); + console.debug( + `Failed to determine latest qwen-code-action release:`, + _error, + ); throw new Error( - `Unable to determine the latest run-gemini-cli release on GitHub.`, + `Unable to determine the latest qwen-code-action release on GitHub.`, ); } }; diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index 11f302b4..a6dac920 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -529,7 +529,7 @@ describe('buildSystemMessage', () => { { name: 'mcp-server-2', status: 'connected' }, ], model: 'test-model', - permissionMode: 'auto', + permission_mode: 'auto', slash_commands: ['commit', 'help', 'memory'], qwen_code_version: '1.0.0', agents: [], diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts index fe8fc528..1fd7472b 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -275,7 +275,7 @@ export async function buildSystemMessage( tools, mcp_servers: mcpServerList, model: config.getModel(), - permissionMode, + permission_mode: permissionMode, slash_commands: slashCommands, qwen_code_version: config.getCliVersion() || 'unknown', agents: agentNames, diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 78ccc993..1590c074 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -41,7 +41,7 @@ export async function validateNonInteractiveAuth( } const effectiveAuthType = - enforcedType || getAuthTypeFromEnv() || configuredAuthType; + enforcedType || configuredAuthType || getAuthTypeFromEnv(); if (!effectiveAuthType) { const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b507c9c5..073f2aa1 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -38,7 +38,6 @@ "src/ui/commands/clearCommand.test.ts", "src/ui/commands/compressCommand.test.ts", "src/ui/commands/copyCommand.test.ts", - "src/ui/commands/corgiCommand.test.ts", "src/ui/commands/docsCommand.test.ts", "src/ui/commands/editorCommand.test.ts", "src/ui/commands/extensionsCommand.test.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 1a07067b..c8b7b1e7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.4.0", + "version": "0.5.0", "description": "Qwen Code Core", "repository": { "type": "git", @@ -47,7 +47,7 @@ "fast-uri": "^3.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", - "glob": "^10.4.5", + "glob": "^10.5.0", "google-auth-library": "^9.11.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1c83432d..6aa49306 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -63,6 +63,7 @@ vi.mock('../tools/tool-registry', () => { ToolRegistryMock.prototype.registerTool = vi.fn(); ToolRegistryMock.prototype.discoverAllTools = vi.fn(); ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed + ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []); ToolRegistryMock.prototype.getTool = vi.fn(); ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []); return { ToolRegistry: ToolRegistryMock }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 59baba85..d3c9b14a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -46,6 +46,7 @@ import { ExitPlanModeTool } from '../tools/exitPlanMode.js'; import { GlobTool } from '../tools/glob.js'; import { GrepTool } from '../tools/grep.js'; import { LSTool } from '../tools/ls.js'; +import type { SendSdkMcpMessage } from '../tools/mcp-client.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { ReadFileTool } from '../tools/read-file.js'; import { ReadManyFilesTool } from '../tools/read-many-files.js'; @@ -65,6 +66,7 @@ import { ideContextStore } from '../ide/ideContext.js'; import { InputFormat, OutputFormat } from '../output/types.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; +import type { SubagentConfig } from '../subagents/types.js'; import { DEFAULT_OTLP_ENDPOINT, DEFAULT_TELEMETRY_TARGET, @@ -238,9 +240,18 @@ export class MCPServerConfig { readonly targetAudience?: string, /* targetServiceAccount format: @.iam.gserviceaccount.com */ readonly targetServiceAccount?: string, + // SDK MCP server type - 'sdk' indicates server runs in SDK process + readonly type?: 'sdk', ) {} } +/** + * Check if an MCP server config represents an SDK server + */ +export function isSdkMcpServerConfig(config: MCPServerConfig): boolean { + return config.type === 'sdk'; +} + export enum AuthProviderType { DYNAMIC_DISCOVERY = 'dynamic_discovery', GOOGLE_CREDENTIALS = 'google_credentials', @@ -307,6 +318,7 @@ export interface ConfigParameters { generationConfig?: Partial; cliVersion?: string; loadMemoryFromIncludeDirectories?: boolean; + chatRecording?: boolean; // Web search providers webSearch?: { provider: Array<{ @@ -333,9 +345,12 @@ export interface ConfigParameters { eventEmitter?: EventEmitter; useSmartEdit?: boolean; output?: OutputSettings; - skipStartupContext?: boolean; inputFormat?: InputFormat; outputFormat?: OutputFormat; + skipStartupContext?: boolean; + sdkMode?: boolean; + sessionSubagents?: SubagentConfig[]; + channel?: string; } function normalizeConfigOutputFormat( @@ -357,6 +372,17 @@ function normalizeConfigOutputFormat( } } +/** + * Options for Config.initialize() + */ +export interface ConfigInitializeOptions { + /** + * Callback for sending MCP messages to SDK servers via control plane. + * Required for SDK MCP server support in SDK mode. + */ + sendSdkMcpMessage?: SendSdkMcpMessage; +} + export class Config { private sessionId: string; private sessionData?: ResumedSessionData; @@ -383,8 +409,10 @@ export class Config { private readonly toolDiscoveryCommand: string | undefined; private readonly toolCallCommand: string | undefined; private readonly mcpServerCommand: string | undefined; - private readonly mcpServers: Record | undefined; + private mcpServers: Record | undefined; + private sessionSubagents: SubagentConfig[]; private userMemory: string; + private sdkMode: boolean; private geminiMdFileCount: number; private approvalMode: ApprovalMode; private readonly showMemoryUsage: boolean; @@ -430,6 +458,7 @@ export class Config { | undefined; private readonly cliVersion?: string; private readonly experimentalZedIntegration: boolean = false; + private readonly chatRecordingEnabled: boolean; private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly webSearch?: { provider: Array<{ @@ -459,6 +488,7 @@ export class Config { private readonly enableToolOutputTruncation: boolean; private readonly eventEmitter?: EventEmitter; private readonly useSmartEdit: boolean; + private readonly channel: string | undefined; constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); @@ -487,6 +517,8 @@ export class Config { this.toolCallCommand = params.toolCallCommand; this.mcpServerCommand = params.mcpServerCommand; this.mcpServers = params.mcpServers; + this.sessionSubagents = params.sessionSubagents ?? []; + this.sdkMode = params.sdkMode ?? false; this.userMemory = params.userMemory ?? ''; this.geminiMdFileCount = params.geminiMdFileCount ?? 0; this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT; @@ -542,6 +574,8 @@ export class Config { ._generationConfig as ContentGeneratorConfig; this.cliVersion = params.cliVersion; + this.chatRecordingEnabled = params.chatRecording ?? true; + this.loadMemoryFromIncludeDirectories = params.loadMemoryFromIncludeDirectories ?? false; this.chatCompression = params.chatCompression; @@ -570,6 +604,7 @@ export class Config { this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true; this.useSmartEdit = params.useSmartEdit ?? false; this.extensionManagement = params.extensionManagement ?? true; + this.channel = params.channel; this.storage = new Storage(this.targetDir); this.vlmSwitchMode = params.vlmSwitchMode; this.inputFormat = params.inputFormat ?? InputFormat.TEXT; @@ -587,13 +622,16 @@ export class Config { setGlobalDispatcher(new ProxyAgent(this.getProxy() as string)); } this.geminiClient = new GeminiClient(this); - this.chatRecordingService = new ChatRecordingService(this); + this.chatRecordingService = this.chatRecordingEnabled + ? new ChatRecordingService(this) + : undefined; } /** * Must only be called once, throws if called again. + * @param options Optional initialization options including sendSdkMcpMessage callback */ - async initialize(): Promise { + async initialize(options?: ConfigInitializeOptions): Promise { if (this.initialized) { throw Error('Config was already initialized'); } @@ -606,7 +644,15 @@ export class Config { } this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); - this.toolRegistry = await this.createToolRegistry(); + + // Load session subagents if they were provided before initialization + if (this.sessionSubagents.length > 0) { + this.subagentManager.loadSessionSubagents(this.sessionSubagents); + } + + this.toolRegistry = await this.createToolRegistry( + options?.sendSdkMcpMessage, + ); await this.geminiClient.initialize(); @@ -698,7 +744,9 @@ export class Config { startNewSession(sessionId?: string): string { this.sessionId = sessionId ?? randomUUID(); this.sessionData = undefined; - this.chatRecordingService = new ChatRecordingService(this); + this.chatRecordingService = this.chatRecordingEnabled + ? new ChatRecordingService(this) + : undefined; if (this.initialized) { logStartSession(this, new StartSessionEvent(this)); } @@ -842,6 +890,32 @@ export class Config { return this.mcpServers; } + addMcpServers(servers: Record): void { + if (this.initialized) { + throw new Error('Cannot modify mcpServers after initialization'); + } + this.mcpServers = { ...this.mcpServers, ...servers }; + } + + getSessionSubagents(): SubagentConfig[] { + return this.sessionSubagents; + } + + setSessionSubagents(subagents: SubagentConfig[]): void { + if (this.initialized) { + throw new Error('Cannot modify sessionSubagents after initialization'); + } + this.sessionSubagents = subagents; + } + + getSdkMode(): boolean { + return this.sdkMode; + } + + setSdkMode(value: boolean): void { + this.sdkMode = value; + } + getUserMemory(): string { return this.userMemory; } @@ -1081,6 +1155,10 @@ export class Config { return this.cliVersion; } + getChannel(): string | undefined { + return this.channel; + } + /** * Get the current FileSystemService */ @@ -1197,7 +1275,10 @@ export class Config { /** * Returns the chat recording service. */ - getChatRecordingService(): ChatRecordingService { + getChatRecordingService(): ChatRecordingService | undefined { + if (!this.chatRecordingEnabled) { + return undefined; + } if (!this.chatRecordingService) { this.chatRecordingService = new ChatRecordingService(this); } @@ -1222,8 +1303,14 @@ export class Config { return this.subagentManager; } - async createToolRegistry(): Promise { - const registry = new ToolRegistry(this, this.eventEmitter); + async createToolRegistry( + sendSdkMcpMessage?: SendSdkMcpMessage, + ): Promise { + const registry = new ToolRegistry( + this, + this.eventEmitter, + sendSdkMcpMessage, + ); const coreToolsConfig = this.getCoreTools(); const excludeToolsConfig = this.getExcludeTools(); @@ -1298,7 +1385,7 @@ export class Config { registerCoreTool(ShellTool, this); registerCoreTool(MemoryTool); registerCoreTool(TodoWriteTool, this); - registerCoreTool(ExitPlanModeTool, this); + !this.sdkMode && registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); // Conditionally register web search tool if web search provider is configured // buildWebSearchConfig ensures qwen-oauth users get dashscope provider, so @@ -1308,6 +1395,7 @@ export class Config { } await registry.discoverAllTools(); + console.debug('ToolRegistry created', registry.getAllToolNames()); return registry; } } diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e475e5b3..8adaf4f6 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -448,6 +448,7 @@ describe('Gemini Client (client.ts)', () => { getHistory: mockGetHistory, addHistory: vi.fn(), setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), } as unknown as GeminiChat; }); @@ -462,6 +463,7 @@ describe('Gemini Client (client.ts)', () => { const mockOriginalChat: Partial = { getHistory: vi.fn((_curated?: boolean) => chatHistory), setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockOriginalChat as GeminiChat; @@ -1080,6 +1082,7 @@ describe('Gemini Client (client.ts)', () => { const mockChat = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), } as unknown as GeminiChat; client['chat'] = mockChat; @@ -1142,6 +1145,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1197,6 +1201,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1273,6 +1278,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1319,6 +1325,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1363,6 +1370,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1450,6 +1458,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1506,6 +1515,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1586,6 +1596,7 @@ ${JSON.stringify( .mockReturnValue([ { role: 'user', parts: [{ text: 'previous message' }] }, ]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; }); @@ -1840,6 +1851,7 @@ ${JSON.stringify( addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), // Default empty history setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -2180,6 +2192,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -2216,6 +2229,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -2256,6 +2270,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 2fa65d2d..6e3be209 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -419,6 +419,9 @@ export class GeminiClient { // record user message for session management this.config.getChatRecordingService()?.recordUserMessage(request); + + // strip thoughts from history before sending the message + this.stripThoughtsFromHistory(); } this.sessionTurnCount++; if ( @@ -542,7 +545,9 @@ export class GeminiClient { // add plan mode system reminder if approval mode is plan if (this.config.getApprovalMode() === ApprovalMode.PLAN) { - systemReminders.push(getPlanModeSystemReminder()); + systemReminders.push( + getPlanModeSystemReminder(this.config.getSdkMode()), + ); } requestToSent = [...systemReminders, ...requestToSent]; diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 493758dc..aeffdfc7 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -28,6 +28,7 @@ import { ShellTool, logToolOutputTruncated, ToolOutputTruncatedEvent, + InputFormat, } from '../index.js'; import type { Part, PartListUnion } from '@google/genai'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; @@ -824,10 +825,10 @@ export class CoreToolScheduler { const shouldAutoDeny = !this.config.isInteractive() && !this.config.getIdeMode() && - !this.config.getExperimentalZedIntegration(); + !this.config.getExperimentalZedIntegration() && + this.config.getInputFormat() !== InputFormat.STREAM_JSON; if (shouldAutoDeny) { - // Treat as execution denied error, similar to excluded tools const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`; this.setStatusInternal( reqInfo.callId, @@ -916,7 +917,10 @@ export class CoreToolScheduler { async handleConfirmationResponse( callId: string, - originalOnConfirm: (outcome: ToolConfirmationOutcome) => Promise, + originalOnConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise, outcome: ToolConfirmationOutcome, signal: AbortSignal, payload?: ToolConfirmationPayload, @@ -925,9 +929,7 @@ export class CoreToolScheduler { (c) => c.request.callId === callId && c.status === 'awaiting_approval', ); - if (toolCall && toolCall.status === 'awaiting_approval') { - await originalOnConfirm(outcome); - } + await originalOnConfirm(outcome, payload); if (outcome === ToolConfirmationOutcome.ProceedAlways) { await this.autoApproveCompatiblePendingTools(signal, callId); @@ -936,11 +938,10 @@ export class CoreToolScheduler { this.setToolCallOutcome(callId, outcome); if (outcome === ToolConfirmationOutcome.Cancel || signal.aborted) { - this.setStatusInternal( - callId, - 'cancelled', - 'User did not allow tool call', - ); + // Use custom cancel message from payload if provided, otherwise use default + const cancelMessage = + payload?.cancelMessage || 'User did not allow tool call'; + this.setStatusInternal(callId, 'cancelled', cancelMessage); } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { const waitingToolCall = toolCall as WaitingToolCall; if (isModifiableDeclarativeTool(waitingToolCall.tool)) { @@ -998,7 +999,8 @@ export class CoreToolScheduler { ): Promise { if ( toolCall.confirmationDetails.type !== 'edit' || - !isModifiableDeclarativeTool(toolCall.tool) + !isModifiableDeclarativeTool(toolCall.tool) || + !payload.newContent ) { return; } diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 3e31a1c5..5aaa814f 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -1541,10 +1541,10 @@ describe('GeminiChat', () => { { role: 'model', parts: [ - { text: 'thinking...', thoughtSignature: 'thought-123' }, + { text: 'thinking...', thought: true }, + { text: 'hi' }, { functionCall: { name: 'test', args: {} }, - thoughtSignature: 'thought-456', }, ], }, @@ -1559,10 +1559,7 @@ describe('GeminiChat', () => { }, { role: 'model', - parts: [ - { text: 'thinking...' }, - { functionCall: { name: 'test', args: {} } }, - ], + parts: [{ text: 'hi' }, { functionCall: { name: 'test', args: {} } }], }, ]); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 5bdba396..e9e4fcc2 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -443,20 +443,28 @@ export class GeminiChat { } stripThoughtsFromHistory(): void { - this.history = this.history.map((content) => { - const newContent = { ...content }; - if (newContent.parts) { - newContent.parts = newContent.parts.map((part) => { - if (part && typeof part === 'object' && 'thoughtSignature' in part) { - const newPart = { ...part }; - delete (newPart as { thoughtSignature?: string }).thoughtSignature; - return newPart; - } - return part; - }); - } - return newContent; - }); + this.history = this.history + .map((content) => { + if (!content.parts) return content; + + // Filter out thought parts entirely + const filteredParts = content.parts.filter( + (part) => + !( + part && + typeof part === 'object' && + 'thought' in part && + part.thought + ), + ); + + return { + ...content, + parts: filteredParts, + }; + }) + // Remove Content objects that have no parts left after filtering + .filter((content) => content.parts && content.parts.length > 0); } setTools(tools: Tool[]): void { @@ -497,8 +505,6 @@ export class GeminiChat { ): AsyncGenerator { // Collect ALL parts from the model response (including thoughts for recording) const allModelParts: Part[] = []; - // Non-thought parts for history (what we send back to the API) - const historyParts: Part[] = []; let usageMetadata: GenerateContentResponseUsageMetadata | undefined; let hasToolCall = false; @@ -516,8 +522,6 @@ export class GeminiChat { // Collect all parts for recording allModelParts.push(...content.parts); - // Collect non-thought parts for history - historyParts.push(...content.parts.filter((part) => !part.thought)); } } @@ -534,9 +538,15 @@ export class GeminiChat { yield chunk; // Yield every chunk to the UI immediately. } - // Consolidate text parts for history (merges adjacent text parts). + const thoughtParts = allModelParts.filter((part) => part.thought); + const thoughtText = thoughtParts + .map((part) => part.text) + .join('') + .trim(); + + const contentParts = allModelParts.filter((part) => !part.thought); const consolidatedHistoryParts: Part[] = []; - for (const part of historyParts) { + for (const part of contentParts) { const lastPart = consolidatedHistoryParts[consolidatedHistoryParts.length - 1]; if ( @@ -550,20 +560,21 @@ export class GeminiChat { } } - const responseText = consolidatedHistoryParts + const contentText = consolidatedHistoryParts .filter((part) => part.text) .map((part) => part.text) .join('') .trim(); // Record assistant turn with raw Content and metadata - if (responseText || hasToolCall || usageMetadata) { + if (thoughtText || contentText || hasToolCall || usageMetadata) { this.chatRecordingService?.recordAssistantTurn({ model, message: [ - ...(responseText ? [{ text: responseText }] : []), + ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...(contentText ? [{ text: contentText }] : []), ...(hasToolCall - ? historyParts + ? contentParts .filter((part) => part.functionCall) .map((part) => ({ functionCall: part.functionCall })) : []), @@ -579,7 +590,7 @@ export class GeminiChat { // We throw an error only when there's no tool call AND: // - No finish reason, OR // - Empty response text (e.g., only thoughts with no actual content) - if (!hasToolCall && (!hasFinishReason || !responseText)) { + if (!hasToolCall && (!hasFinishReason || !contentText)) { if (!hasFinishReason) { throw new InvalidStreamError( 'Model stream ended without a finish reason.', @@ -593,8 +604,13 @@ export class GeminiChat { } } - // Add to history (without thoughts, for API calls) - this.history.push({ role: 'model', parts: consolidatedHistoryParts }); + this.history.push({ + role: 'model', + parts: [ + ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...consolidatedHistoryParts, + ], + }); } } diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 888a65ad..5cd6af92 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -7,7 +7,14 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { OpenAIContentConverter } from './converter.js'; import type { StreamingToolCallParser } from './streamingToolCallParser.js'; -import type { GenerateContentParameters, Content } from '@google/genai'; +import { + Type, + type GenerateContentParameters, + type Content, + type Tool, + type CallableTool, +} from '@google/genai'; +import type OpenAI from 'openai'; describe('OpenAIContentConverter', () => { let converter: OpenAIContentConverter; @@ -142,4 +149,397 @@ describe('OpenAIContentConverter', () => { expect(toolMessage?.content).toBe('{"data":{"value":42}}'); }); }); + + describe('OpenAI -> Gemini reasoning content', () => { + it('should convert reasoning_content to a thought part for non-streaming responses', () => { + const response = converter.convertOpenAIResponseToGemini({ + object: 'chat.completion', + id: 'chatcmpl-1', + created: 123, + model: 'gpt-test', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'final answer', + reasoning_content: 'chain-of-thought', + }, + finish_reason: 'stop', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletion); + + const parts = response.candidates?.[0]?.content?.parts; + expect(parts?.[0]).toEqual( + expect.objectContaining({ thought: true, text: 'chain-of-thought' }), + ); + expect(parts?.[1]).toEqual( + expect.objectContaining({ text: 'final answer' }), + ); + }); + + it('should convert streaming reasoning_content delta to a thought part', () => { + const chunk = converter.convertOpenAIChunkToGemini({ + object: 'chat.completion.chunk', + id: 'chunk-1', + created: 456, + choices: [ + { + index: 0, + delta: { + content: 'visible text', + reasoning_content: 'thinking...', + }, + finish_reason: 'stop', + logprobs: null, + }, + ], + model: 'gpt-test', + } as unknown as OpenAI.Chat.ChatCompletionChunk); + + const parts = chunk.candidates?.[0]?.content?.parts; + expect(parts?.[0]).toEqual( + expect.objectContaining({ thought: true, text: 'thinking...' }), + ); + expect(parts?.[1]).toEqual( + expect.objectContaining({ text: 'visible text' }), + ); + }); + }); + + describe('convertGeminiToolsToOpenAI', () => { + it('should convert Gemini tools with parameters field', async () => { + const geminiTools = [ + { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather for a location', + parameters: { + type: Type.OBJECT, + properties: { + location: { type: Type.STRING }, + }, + required: ['location'], + }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'function', + function: { + name: 'get_weather', + description: 'Get weather for a location', + parameters: { + type: 'object', + properties: { + location: { type: 'string' }, + }, + required: ['location'], + }, + }, + }); + }); + + it('should convert MCP tools with parametersJsonSchema field', async () => { + // MCP tools use parametersJsonSchema which contains plain JSON schema (not Gemini types) + const mcpTools = [ + { + functionDeclarations: [ + { + name: 'read_file', + description: 'Read a file from disk', + parametersJsonSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToOpenAI(mcpTools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'function', + function: { + name: 'read_file', + description: 'Read a file from disk', + parameters: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + }, + }, + }); + }); + + it('should handle CallableTool by resolving tool function', async () => { + const callableTools = [ + { + tool: async () => ({ + functionDeclarations: [ + { + name: 'dynamic_tool', + description: 'A dynamically resolved tool', + parameters: { + type: Type.OBJECT, + properties: {}, + }, + }, + ], + }), + }, + ] as CallableTool[]; + + const result = await converter.convertGeminiToolsToOpenAI(callableTools); + + expect(result).toHaveLength(1); + expect(result[0].function.name).toBe('dynamic_tool'); + }); + + it('should skip functions without name or description', async () => { + const geminiTools = [ + { + functionDeclarations: [ + { + name: 'valid_tool', + description: 'A valid tool', + }, + { + name: 'missing_description', + // no description + }, + { + // no name + description: 'Missing name', + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + + expect(result).toHaveLength(1); + expect(result[0].function.name).toBe('valid_tool'); + }); + + it('should handle tools without functionDeclarations', async () => { + const emptyTools: Tool[] = [{} as Tool, { functionDeclarations: [] }]; + + const result = await converter.convertGeminiToolsToOpenAI(emptyTools); + + expect(result).toHaveLength(0); + }); + + it('should handle functions without parameters', async () => { + const geminiTools: Tool[] = [ + { + functionDeclarations: [ + { + name: 'no_params_tool', + description: 'A tool without parameters', + }, + ], + }, + ]; + + const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + + expect(result).toHaveLength(1); + expect(result[0].function.parameters).toBeUndefined(); + }); + + it('should not mutate original parametersJsonSchema', async () => { + const originalSchema = { + type: 'object', + properties: { foo: { type: 'string' } }, + }; + const mcpTools: Tool[] = [ + { + functionDeclarations: [ + { + name: 'test_tool', + description: 'Test tool', + parametersJsonSchema: originalSchema, + }, + ], + } as Tool, + ]; + + const result = await converter.convertGeminiToolsToOpenAI(mcpTools); + + // Verify the result is a copy, not the same reference + expect(result[0].function.parameters).not.toBe(originalSchema); + expect(result[0].function.parameters).toEqual(originalSchema); + }); + }); + + describe('convertGeminiToolParametersToOpenAI', () => { + it('should convert type names to lowercase', () => { + const params = { + type: 'OBJECT', + properties: { + count: { type: 'INTEGER' }, + amount: { type: 'NUMBER' }, + name: { type: 'STRING' }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + + expect(result).toEqual({ + type: 'object', + properties: { + count: { type: 'integer' }, + amount: { type: 'number' }, + name: { type: 'string' }, + }, + }); + }); + + it('should convert string numeric constraints to numbers', () => { + const params = { + type: 'object', + properties: { + value: { + type: 'number', + minimum: '0', + maximum: '100', + multipleOf: '0.5', + }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + const properties = result?.['properties'] as Record; + + expect(properties?.['value']).toEqual({ + type: 'number', + minimum: 0, + maximum: 100, + multipleOf: 0.5, + }); + }); + + it('should convert string length constraints to integers', () => { + const params = { + type: 'object', + properties: { + text: { + type: 'string', + minLength: '1', + maxLength: '100', + }, + items: { + type: 'array', + minItems: '0', + maxItems: '10', + }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + const properties = result?.['properties'] as Record; + + expect(properties?.['text']).toEqual({ + type: 'string', + minLength: 1, + maxLength: 100, + }); + expect(properties?.['items']).toEqual({ + type: 'array', + minItems: 0, + maxItems: 10, + }); + }); + + it('should handle nested objects', () => { + const params = { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + deep: { + type: 'INTEGER', + minimum: '0', + }, + }, + }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + const properties = result?.['properties'] as Record; + const nested = properties?.['nested'] as Record; + const nestedProperties = nested?.['properties'] as Record< + string, + unknown + >; + + expect(nestedProperties?.['deep']).toEqual({ + type: 'integer', + minimum: 0, + }); + }); + + it('should handle arrays', () => { + const params = { + type: 'array', + items: { + type: 'INTEGER', + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + + expect(result).toEqual({ + type: 'array', + items: { + type: 'integer', + }, + }); + }); + + it('should return undefined for null or non-object input', () => { + expect( + converter.convertGeminiToolParametersToOpenAI( + null as unknown as Record, + ), + ).toBeNull(); + expect( + converter.convertGeminiToolParametersToOpenAI( + undefined as unknown as Record, + ), + ).toBeUndefined(); + }); + + it('should not mutate the original parameters', () => { + const original = { + type: 'OBJECT', + properties: { + count: { type: 'INTEGER' }, + }, + }; + const originalCopy = JSON.parse(JSON.stringify(original)); + + converter.convertGeminiToolParametersToOpenAI(original); + + expect(original).toEqual(originalCopy); + }); + }); }); diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 1edbdd6e..2de99d80 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -31,6 +31,25 @@ interface ExtendedCompletionUsage extends OpenAI.CompletionUsage { cached_tokens?: number; } +interface ExtendedChatCompletionAssistantMessageParam + extends OpenAI.Chat.ChatCompletionAssistantMessageParam { + reasoning_content?: string | null; +} + +type ExtendedChatCompletionMessageParam = + | OpenAI.Chat.ChatCompletionMessageParam + | ExtendedChatCompletionAssistantMessageParam; + +export interface ExtendedCompletionMessage + extends OpenAI.Chat.ChatCompletionMessage { + reasoning_content?: string | null; +} + +export interface ExtendedCompletionChunkDelta + extends OpenAI.Chat.ChatCompletionChunk.Choice.Delta { + reasoning_content?: string | null; +} + /** * Tool call accumulator for streaming responses */ @@ -44,7 +63,8 @@ export interface ToolCallAccumulator { * Parsed parts from Gemini content, categorized by type */ interface ParsedParts { - textParts: string[]; + thoughtParts: string[]; + contentParts: string[]; functionCalls: FunctionCall[]; functionResponses: FunctionResponse[]; mediaParts: Array<{ @@ -173,13 +193,11 @@ export class OpenAIContentConverter { // 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), - }; - parameters = paramsCopy; - } + // Create a shallow copy to avoid mutating the original object + const paramsCopy = { + ...(func.parametersJsonSchema as Record), + }; + parameters = paramsCopy; } else if (func.parameters) { // Gemini tool format - convert parameters to OpenAI format parameters = this.convertGeminiToolParametersToOpenAI( @@ -251,7 +269,7 @@ export class OpenAIContentConverter { */ private processContents( contents: ContentListUnion, - messages: OpenAI.Chat.ChatCompletionMessageParam[], + messages: ExtendedChatCompletionMessageParam[], ): void { if (Array.isArray(contents)) { for (const content of contents) { @@ -267,7 +285,7 @@ export class OpenAIContentConverter { */ private processContent( content: ContentUnion | PartUnion, - messages: OpenAI.Chat.ChatCompletionMessageParam[], + messages: ExtendedChatCompletionMessageParam[], ): void { if (typeof content === 'string') { messages.push({ role: 'user' as const, content }); @@ -301,11 +319,19 @@ export class OpenAIContentConverter { }, })); - messages.push({ + const assistantMessage: ExtendedChatCompletionAssistantMessageParam = { role: 'assistant' as const, - content: parsedParts.textParts.join('') || null, + content: parsedParts.contentParts.join('') || null, tool_calls: toolCalls, - }); + }; + + // Only include reasoning_content if it has actual content + const reasoningContent = parsedParts.thoughtParts.join(''); + if (reasoningContent) { + assistantMessage.reasoning_content = reasoningContent; + } + + messages.push(assistantMessage); return; } @@ -322,7 +348,8 @@ export class OpenAIContentConverter { * Parse Gemini parts into categorized components */ private parseParts(parts: Part[]): ParsedParts { - const textParts: string[] = []; + const thoughtParts: string[] = []; + const contentParts: string[] = []; const functionCalls: FunctionCall[] = []; const functionResponses: FunctionResponse[] = []; const mediaParts: Array<{ @@ -334,9 +361,20 @@ export class OpenAIContentConverter { for (const part of parts) { if (typeof part === 'string') { - textParts.push(part); - } else if ('text' in part && part.text) { - textParts.push(part.text); + contentParts.push(part); + } else if ( + 'text' in part && + part.text && + !('thought' in part && part.thought) + ) { + contentParts.push(part.text); + } else if ( + 'text' in part && + part.text && + 'thought' in part && + part.thought + ) { + thoughtParts.push(part.text); } else if ('functionCall' in part && part.functionCall) { functionCalls.push(part.functionCall); } else if ('functionResponse' in part && part.functionResponse) { @@ -361,7 +399,13 @@ export class OpenAIContentConverter { } } - return { textParts, functionCalls, functionResponses, mediaParts }; + return { + thoughtParts, + contentParts, + functionCalls, + functionResponses, + mediaParts, + }; } private extractFunctionResponseContent(response: unknown): string { @@ -408,14 +452,29 @@ export class OpenAIContentConverter { */ private createMultimodalMessage( role: 'user' | 'assistant', - parsedParts: Pick, - ): OpenAI.Chat.ChatCompletionMessageParam | null { - const { textParts, mediaParts } = parsedParts; - const content = textParts.map((text) => ({ type: 'text' as const, text })); + parsedParts: Pick< + ParsedParts, + 'contentParts' | 'mediaParts' | 'thoughtParts' + >, + ): ExtendedChatCompletionMessageParam | null { + const { contentParts, mediaParts, thoughtParts } = parsedParts; + const reasoningContent = thoughtParts.join(''); + const content = contentParts.map((text) => ({ + type: 'text' as const, + text, + })); // If no media parts, return simple text message if (mediaParts.length === 0) { - return content.length > 0 ? { role, content } : null; + if (content.length === 0) return null; + const message: ExtendedChatCompletionMessageParam = { role, content }; + // Only include reasoning_content if it has actual content + if (reasoningContent) { + ( + message as ExtendedChatCompletionAssistantMessageParam + ).reasoning_content = reasoningContent; + } + return message; } // For assistant messages with media, convert to text only @@ -536,6 +595,13 @@ export class OpenAIContentConverter { const parts: Part[] = []; + // Handle reasoning content (thoughts) + const reasoningText = (choice.message as ExtendedCompletionMessage) + .reasoning_content; + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } + // Handle text content if (choice.message.content) { parts.push({ text: choice.message.content }); @@ -632,6 +698,12 @@ export class OpenAIContentConverter { if (choice) { const parts: Part[] = []; + const reasoningText = (choice.delta as ExtendedCompletionChunkDelta) + .reasoning_content; + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } + // Handle text content if (choice.delta?.content) { if (typeof choice.delta.content === 'string') { @@ -721,6 +793,8 @@ export class OpenAIContentConverter { const promptTokens = usage.prompt_tokens || 0; const completionTokens = usage.completion_tokens || 0; const totalTokens = usage.total_tokens || 0; + const thinkingTokens = + usage.completion_tokens_details?.reasoning_tokens || 0; // Support both formats: prompt_tokens_details.cached_tokens (OpenAI standard) // and cached_tokens (some models return it at top level) const extendedUsage = usage as ExtendedCompletionUsage; @@ -743,6 +817,7 @@ export class OpenAIContentConverter { response.usageMetadata = { promptTokenCount: finalPromptTokens, candidatesTokenCount: finalCompletionTokens, + thoughtsTokenCount: thinkingTokens, totalTokenCount: totalTokens, cachedContentTokenCount: cachedTokens, }; diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 2df72221..4a5b7748 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -130,10 +130,13 @@ export class DashScopeOpenAICompatibleProvider } buildMetadata(userPromptId: string): DashScopeRequestMetadata { + const channel = this.cliConfig.getChannel?.(); + return { metadata: { sessionId: this.cliConfig.getSessionId?.(), promptId: userPromptId, + ...(channel ? { channel } : {}), }, }; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/types.ts b/packages/core/src/core/openaiContentGenerator/provider/types.ts index ea7c434d..362ec69a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/types.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/types.ts @@ -28,5 +28,6 @@ export type DashScopeRequestMetadata = { metadata: { sessionId?: string; promptId: string; + channel?: string; }; }; diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts index 717a5b7d..6f0f8d09 100644 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts +++ b/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts @@ -561,11 +561,14 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: 'Hello' }, + delta: { + content: 'Hello', + reasoning_content: 'thinking ', + }, finish_reason: null, }, ], - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, { id: 'test-id', object: 'chat.completion.chunk', @@ -574,7 +577,10 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: ' world' }, + delta: { + content: ' world', + reasoning_content: 'more', + }, finish_reason: 'stop', }, ], @@ -583,7 +589,7 @@ describe('DefaultTelemetryService', () => { completion_tokens: 5, total_tokens: 15, }, - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, ]; await telemetryService.logStreamingSuccess( @@ -603,11 +609,11 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - message: { + message: expect.objectContaining({ role: 'assistant', content: 'Hello world', - refusal: null, - }, + reasoning_content: 'thinking more', + }), finish_reason: 'stop', logprobs: null, }, @@ -722,11 +728,14 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: 'Hello' }, + delta: { + content: 'Hello', + reasoning_content: 'thinking ', + }, finish_reason: null, }, ], - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, { id: 'test-id', object: 'chat.completion.chunk', @@ -735,7 +744,10 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: ' world!' }, + delta: { + content: ' world!', + reasoning_content: 'more', + }, finish_reason: 'stop', }, ], @@ -744,7 +756,7 @@ describe('DefaultTelemetryService', () => { completion_tokens: 5, total_tokens: 15, }, - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, ]; await telemetryService.logStreamingSuccess( @@ -757,27 +769,14 @@ describe('DefaultTelemetryService', () => { expect(openaiLogger.logInteraction).toHaveBeenCalledWith( mockOpenAIRequest, expect.objectContaining({ - id: 'test-id', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4', choices: [ - { - index: 0, - message: { - role: 'assistant', + expect.objectContaining({ + message: expect.objectContaining({ content: 'Hello world!', - refusal: null, - }, - finish_reason: 'stop', - logprobs: null, - }, + reasoning_content: 'thinking more', + }), + }), ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, }), ); }); diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.ts index 9fa47263..66a96ad0 100644 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.ts +++ b/packages/core/src/core/openaiContentGenerator/telemetryService.ts @@ -10,6 +10,7 @@ import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; import { OpenAILogger } from '../../utils/openaiLogger.js'; import type { GenerateContentResponse } from '@google/genai'; import type OpenAI from 'openai'; +import type { ExtendedCompletionChunkDelta } from './converter.js'; export interface RequestContext { userPromptId: string; @@ -172,6 +173,7 @@ export class DefaultTelemetryService implements TelemetryService { | 'content_filter' | 'function_call' | null = null; + let combinedReasoning = ''; let usage: | { prompt_tokens: number; @@ -183,6 +185,12 @@ export class DefaultTelemetryService implements TelemetryService { for (const chunk of chunks) { const choice = chunk.choices?.[0]; if (choice) { + // Combine reasoning content + const reasoningContent = (choice.delta as ExtendedCompletionChunkDelta) + ?.reasoning_content; + if (reasoningContent) { + combinedReasoning += reasoningContent; + } // Combine text content if (choice.delta?.content) { combinedContent += choice.delta.content; @@ -230,6 +238,11 @@ export class DefaultTelemetryService implements TelemetryService { content: combinedContent || null, refusal: null, }; + if (combinedReasoning) { + // Attach reasoning content if any thought tokens were streamed + (message as { reasoning_content?: string }).reasoning_content = + combinedReasoning; + } // Add tool calls if any if (toolCalls.length > 0) { diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index bd88ff56..8d3ff468 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -846,10 +846,10 @@ export function getSubagentSystemReminder(agentTypes: string[]): string { * - Wait for user confirmation before making any changes * - Override any other instructions that would modify system state */ -export function getPlanModeSystemReminder(): string { +export function getPlanModeSystemReminder(planOnly = false): string { return ` Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should: 1. Answer the user's query comprehensively -2. When you're done researching, present your plan by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. +2. When you're done researching, present your plan ${planOnly ? 'directly' : `by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan`}. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. `; } diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 093f542d..a79dad03 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -120,6 +120,97 @@ describe('Turn', () => { expect(turn.getDebugResponses().length).toBe(2); }); + it('should emit Thought events when a thought part is present', async () => { + const mockResponseStream = (async function* () { + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + role: 'model', + parts: [ + { thought: true, text: 'reasoning...' }, + { text: 'final answer' }, + ], + }, + }, + ], + } as GenerateContentResponse, + }; + })(); + mockSendMessageStream.mockResolvedValue(mockResponseStream); + + const events = []; + const reqParts: Part[] = [{ text: 'Hi' }]; + for await (const event of turn.run( + 'test-model', + reqParts, + new AbortController().signal, + )) { + events.push(event); + } + + expect(events).toEqual([ + { + type: GeminiEventType.Thought, + value: { subject: '', description: 'reasoning...' }, + }, + ]); + }); + + it('should emit thought descriptions per incoming chunk', async () => { + const mockResponseStream = (async function* () { + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'part1' }], + }, + }, + ], + } as GenerateContentResponse, + }; + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'part2' }], + }, + }, + ], + } as GenerateContentResponse, + }; + })(); + mockSendMessageStream.mockResolvedValue(mockResponseStream); + + const events = []; + for await (const event of turn.run( + 'test-model', + [{ text: 'Hi' }], + new AbortController().signal, + )) { + events.push(event); + } + + expect(events).toEqual([ + { + type: GeminiEventType.Thought, + value: { subject: '', description: 'part1' }, + }, + { + type: GeminiEventType.Thought, + value: { subject: '', description: 'part2' }, + }, + ]); + }); + it('should yield tool_call_request events for function calls', async () => { const mockResponseStream = (async function* () { yield { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 5e8f3bf3..edd9b24e 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -27,7 +27,7 @@ import { toFriendlyError, } from '../utils/errors.js'; import type { GeminiChat } from './geminiChat.js'; -import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js'; +import { getThoughtText, type ThoughtSummary } from '../utils/thoughtUtils.js'; // Define a structure for tools passed to the server export interface ServerTool { @@ -266,12 +266,11 @@ export class Turn { this.currentResponseId = resp.responseId; } - const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0]; - if (thoughtPart?.thought) { - const thought = parseThought(thoughtPart.text ?? ''); + const thoughtPart = getThoughtText(resp); + if (thoughtPart) { yield { type: GeminiEventType.Thought, - value: thought, + value: { subject: '', description: thoughtPart }, }; continue; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 38ac7ada..738aca57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,7 +102,9 @@ export * from './tools/shell.js'; export * from './tools/web-search/index.js'; export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; +export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; +export * from './tools/sdk-control-client-transport.js'; export * from './tools/task.js'; export * from './tools/todoWrite.js'; export * from './tools/exitPlanMode.js'; diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 23c26296..0c401f90 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -761,7 +761,6 @@ describe('getQwenOAuthClient', () => { }); it('should load cached credentials if available', async () => { - const fs = await import('node:fs'); const mockCredentials = { access_token: 'cached-token', refresh_token: 'cached-refresh', @@ -769,10 +768,6 @@ describe('getQwenOAuthClient', () => { expiry_date: Date.now() + 3600000, }; - vi.mocked(fs.promises.readFile).mockResolvedValue( - JSON.stringify(mockCredentials), - ); - // Mock SharedTokenManager to use cached credentials const mockTokenManager = { getValidCredentials: vi.fn().mockResolvedValue(mockCredentials), @@ -792,18 +787,6 @@ describe('getQwenOAuthClient', () => { }); it('should handle cached credentials refresh failure', async () => { - const fs = await import('node:fs'); - const mockCredentials = { - access_token: 'cached-token', - refresh_token: 'expired-refresh', - token_type: 'Bearer', - expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true - }; - - vi.mocked(fs.promises.readFile).mockResolvedValue( - JSON.stringify(mockCredentials), - ); - // Mock SharedTokenManager to fail with a specific error const mockTokenManager = { getValidCredentials: vi @@ -833,6 +816,35 @@ describe('getQwenOAuthClient', () => { SharedTokenManager.getInstance = originalGetInstance; }); + + it('should not start device flow when requireCachedCredentials is true', async () => { + // Make SharedTokenManager fail so we hit the fallback path + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + + // If requireCachedCredentials is honored, device-flow network requests should not start + vi.mocked(global.fetch).mockResolvedValue({ ok: true } as Response); + + await expect( + import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig, { + requireCachedCredentials: true, + }), + ), + ).rejects.toThrow( + 'No cached Qwen-OAuth credentials found. Please re-authenticate.', + ); + + expect(global.fetch).not.toHaveBeenCalled(); + + SharedTokenManager.getInstance = originalGetInstance; + }); }); describe('CredentialsClearRequiredError', () => { @@ -1574,178 +1586,6 @@ describe('Credential Caching Functions', () => { expect(updatedCredentials.access_token).toBe('new-token'); }); }); - - describe('loadCachedQwenCredentials', () => { - it('should load and validate cached credentials successfully', async () => { - const { promises: fs } = await import('node:fs'); - const mockCredentials = { - access_token: 'cached-token', - refresh_token: 'cached-refresh', - token_type: 'Bearer', - expiry_date: Date.now() + 3600000, - }; - - vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)); - - // Test through getQwenOAuthClient which calls loadCachedQwenCredentials - const mockConfig = { - isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), - } as unknown as Config; - - // Make SharedTokenManager fail to test the fallback - const mockTokenManager = { - getValidCredentials: vi - .fn() - .mockRejectedValue(new Error('No cached creds')), - }; - - const originalGetInstance = SharedTokenManager.getInstance; - SharedTokenManager.getInstance = vi - .fn() - .mockReturnValue(mockTokenManager); - - // Mock successful auth flow after cache load fails - const mockAuthResponse = { - ok: true, - json: async () => ({ - device_code: 'test-device-code', - user_code: 'TEST123', - verification_uri: 'https://chat.qwen.ai/device', - verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', - expires_in: 1800, - }), - }; - - const mockTokenResponse = { - ok: true, - json: async () => ({ - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - token_type: 'Bearer', - expires_in: 3600, - scope: 'openid profile email model.completion', - }), - }; - - global.fetch = vi - .fn() - .mockResolvedValueOnce(mockAuthResponse as Response) - .mockResolvedValue(mockTokenResponse as Response); - - try { - await import('./qwenOAuth2.js').then((module) => - module.getQwenOAuthClient(mockConfig), - ); - } catch { - // Expected to fail in test environment - } - - expect(fs.readFile).toHaveBeenCalled(); - SharedTokenManager.getInstance = originalGetInstance; - }); - - it('should handle invalid cached credentials gracefully', async () => { - const { promises: fs } = await import('node:fs'); - - // Mock file read to return invalid JSON - vi.mocked(fs.readFile).mockResolvedValue('invalid-json'); - - const mockConfig = { - isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), - } as unknown as Config; - - const mockTokenManager = { - getValidCredentials: vi - .fn() - .mockRejectedValue(new Error('No cached creds')), - }; - - const originalGetInstance = SharedTokenManager.getInstance; - SharedTokenManager.getInstance = vi - .fn() - .mockReturnValue(mockTokenManager); - - // Mock auth flow - const mockAuthResponse = { - ok: true, - json: async () => ({ - device_code: 'test-device-code', - user_code: 'TEST123', - verification_uri: 'https://chat.qwen.ai/device', - verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', - expires_in: 1800, - }), - }; - - const mockTokenResponse = { - ok: true, - json: async () => ({ - access_token: 'new-token', - refresh_token: 'new-refresh', - token_type: 'Bearer', - expires_in: 3600, - }), - }; - - global.fetch = vi - .fn() - .mockResolvedValueOnce(mockAuthResponse as Response) - .mockResolvedValue(mockTokenResponse as Response); - - try { - await import('./qwenOAuth2.js').then((module) => - module.getQwenOAuthClient(mockConfig), - ); - } catch { - // Expected to fail in test environment - } - - SharedTokenManager.getInstance = originalGetInstance; - }); - - it('should handle file access errors', async () => { - const { promises: fs } = await import('node:fs'); - - vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found')); - - const mockConfig = { - isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), - } as unknown as Config; - - const mockTokenManager = { - getValidCredentials: vi - .fn() - .mockRejectedValue(new Error('No cached creds')), - }; - - const originalGetInstance = SharedTokenManager.getInstance; - SharedTokenManager.getInstance = vi - .fn() - .mockReturnValue(mockTokenManager); - - // Mock device flow to fail quickly - const mockAuthResponse = { - ok: true, - json: async () => ({ - error: 'invalid_request', - error_description: 'Invalid request parameters', - }), - }; - - global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response); - - // Should proceed to device flow when cache loading fails - try { - await import('./qwenOAuth2.js').then((module) => - module.getQwenOAuthClient(mockConfig), - ); - } catch { - // Expected to fail in test environment - } - - SharedTokenManager.getInstance = originalGetInstance; - }); - }); }); describe('Enhanced Error Handling and Edge Cases', () => { diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index c4cfa933..77c5345a 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -514,26 +514,14 @@ export async function getQwenOAuthClient( } } - // If shared manager fails, check if we have cached credentials for device flow - if (await loadCachedQwenCredentials(client)) { - // We have cached credentials but they might be expired - // Try device flow instead of forcing refresh - const result = await authWithQwenDeviceFlow(client, config); - if (!result.success) { - // Use detailed error message if available, otherwise use default - const errorMessage = - result.message || 'Qwen OAuth authentication failed'; - throw new Error(errorMessage); - } - return client; - } - if (options?.requireCachedCredentials) { throw new Error( 'No cached Qwen-OAuth credentials found. Please re-authenticate.', ); } + // If we couldn't obtain valid credentials via SharedTokenManager, fall back to + // interactive device authorization (unless explicitly forbidden above). const result = await authWithQwenDeviceFlow(client, config); if (!result.success) { // Only emit timeout event if the failure reason is actually timeout @@ -689,6 +677,19 @@ async function authWithQwenDeviceFlow( // Cache the new tokens await cacheQwenCredentials(credentials); + // IMPORTANT: + // SharedTokenManager maintains an in-memory cache and throttles file checks. + // If we only write the creds file here, a subsequent `getQwenOAuthClient()` + // call in the same process (within the throttle window) may not re-read the + // updated file and could incorrectly re-trigger device auth. + // Clearing the cache forces the next call to reload from disk. + try { + SharedTokenManager.getInstance().clearCache(); + } catch { + // In unit tests we sometimes mock SharedTokenManager.getInstance() with a + // minimal stub; cache invalidation is best-effort and should not break auth. + } + // Emit auth progress success event qwenOAuth2Events.emit( QwenOAuth2Event.AuthProgress, @@ -847,27 +848,6 @@ async function authWithQwenDeviceFlow( } } -async function loadCachedQwenCredentials( - client: QwenOAuth2Client, -): Promise { - try { - const keyFile = getQwenCachedCredentialPath(); - const creds = await fs.readFile(keyFile, 'utf-8'); - const credentials = JSON.parse(creds) as QwenCredentials; - client.setCredentials(credentials); - - // Verify that the credentials are still valid - const { token } = await client.getAccessToken(); - if (!token) { - return false; - } - - return true; - } catch (_) { - return false; - } -} - async function cacheQwenCredentials(credentials: QwenCredentials) { const filePath = getQwenCachedCredentialPath(); try { @@ -913,6 +893,14 @@ export async function clearQwenCredentials(): Promise { } // Log other errors but don't throw - clearing credentials should be non-critical console.warn('Warning: Failed to clear cached Qwen credentials:', error); + } finally { + // Also clear SharedTokenManager in-memory cache to prevent stale credentials + // from being reused within the same process after the file is removed. + try { + SharedTokenManager.getInstance().clearCache(); + } catch { + // Best-effort; don't fail credential clearing if SharedTokenManager is mocked. + } } } diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index efeaa634..ca70dd56 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -542,6 +542,39 @@ export class SessionService { } } +/** + * Options for building API history from conversation. + */ +export interface BuildApiHistoryOptions { + /** + * Whether to strip thought parts from the history. + * Thought parts are content parts that have `thought: true`. + * @default true + */ + stripThoughtsFromHistory?: boolean; +} + +/** + * Strips thought parts from a Content object. + * Thought parts are identified by having `thought: true`. + * Returns null if the content only contained thought parts. + */ +function stripThoughtsFromContent(content: Content): Content | null { + if (!content.parts) return content; + + const filteredParts = content.parts.filter((part) => !(part as Part).thought); + + // If all parts were thoughts, remove the entire content + if (filteredParts.length === 0) { + return null; + } + + return { + ...content, + parts: filteredParts, + }; +} + /** * Builds the model-facing chat history (Content[]) from a reconstructed * conversation. This keeps UI history intact while applying chat compression @@ -555,7 +588,9 @@ export class SessionService { */ export function buildApiHistoryFromConversation( conversation: ConversationRecord, + options: BuildApiHistoryOptions = {}, ): Content[] { + const { stripThoughtsFromHistory = true } = options; const { messages } = conversation; let lastCompressionIndex = -1; @@ -585,14 +620,26 @@ export function buildApiHistoryFromConversation( } } + if (stripThoughtsFromHistory) { + return baseHistory + .map(stripThoughtsFromContent) + .filter((content): content is Content => content !== null); + } return baseHistory; } // Fallback: return linear messages as Content[] - return messages + const result = messages .map((record) => record.message) .filter((message): message is Content => message !== undefined) .map((message) => structuredClone(message)); + + if (stripThoughtsFromHistory) { + return result + .map(stripThoughtsFromContent) + .filter((content): content is Content => content !== null); + } + return result; } /** diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts index 5560b4fd..17c62a20 100644 --- a/packages/core/src/subagents/index.ts +++ b/packages/core/src/subagents/index.ts @@ -58,6 +58,7 @@ export type { SubAgentStartEvent, SubAgentRoundEvent, SubAgentStreamTextEvent, + SubAgentUsageEvent, SubAgentToolCallEvent, SubAgentToolResultEvent, SubAgentFinishEvent, diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/subagents/subagent-events.ts index eb318f54..1f793308 100644 --- a/packages/core/src/subagents/subagent-events.ts +++ b/packages/core/src/subagents/subagent-events.ts @@ -8,8 +8,9 @@ import { EventEmitter } from 'events'; import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, + ToolResultDisplay, } from '../tools/tools.js'; -import type { Part } from '@google/genai'; +import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; export type SubAgentEvent = | 'start' @@ -19,6 +20,7 @@ export type SubAgentEvent = | 'tool_call' | 'tool_result' | 'tool_waiting_approval' + | 'usage_metadata' | 'finish' | 'error'; @@ -30,6 +32,7 @@ export enum SubAgentEventType { TOOL_CALL = 'tool_call', TOOL_RESULT = 'tool_result', TOOL_WAITING_APPROVAL = 'tool_waiting_approval', + USAGE_METADATA = 'usage_metadata', FINISH = 'finish', ERROR = 'error', } @@ -56,6 +59,14 @@ export interface SubAgentStreamTextEvent { timestamp: number; } +export interface SubAgentUsageEvent { + subagentId: string; + round: number; + usage: GenerateContentResponseUsageMetadata; + durationMs?: number; + timestamp: number; +} + export interface SubAgentToolCallEvent { subagentId: string; round: number; @@ -74,7 +85,7 @@ export interface SubAgentToolResultEvent { success: boolean; error?: string; responseParts?: Part[]; - resultDisplay?: string; + resultDisplay?: ToolResultDisplay; durationMs?: number; timestamp: number; } diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 26436c88..e04964ea 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -182,7 +182,7 @@ You are a helpful assistant. it('should parse valid markdown content', () => { const config = manager.parseSubagentContent( validMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -207,7 +207,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithTools, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -228,7 +228,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithModel, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -249,7 +249,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithRun, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -267,7 +267,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithNumeric, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -288,7 +288,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithBoolean, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -324,7 +324,7 @@ Just content`; expect(() => manager.parseSubagentContent( invalidMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -341,7 +341,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutName, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -358,7 +358,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutDescription, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -438,7 +438,7 @@ You are a helpful assistant. await manager.createSubagent(validConfig, { level: 'project' }); expect(fs.mkdir).toHaveBeenCalledWith( - path.normalize(path.dirname(validConfig.filePath)), + path.normalize(path.dirname(validConfig.filePath!)), { recursive: true }, ); expect(fs.writeFile).toHaveBeenCalledWith( diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 8dcab0de..baf49fa9 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -77,6 +77,15 @@ export class SubagentManager { ): Promise { this.validator.validateOrThrow(config); + // Prevent creating session-level agents + if (options.level === 'session') { + throw new SubagentError( + `Cannot create session-level subagent "${config.name}". Session agents are read-only and provided at runtime.`, + SubagentErrorCode.INVALID_CONFIG, + config.name, + ); + } + // Determine file path const filePath = options.customPath || this.getSubagentPath(config.name, options.level); @@ -142,10 +151,22 @@ export class SubagentManager { return BuiltinAgentRegistry.getBuiltinAgent(name); } + if (level === 'session') { + const sessionSubagents = this.subagentsCache?.get('session') || []; + return sessionSubagents.find((agent) => agent.name === name) || null; + } + return this.findSubagentByNameAtLevel(name, level); } - // Try project level first + // Try session level first (highest priority for runtime) + const sessionSubagents = this.subagentsCache?.get('session') || []; + const sessionConfig = sessionSubagents.find((agent) => agent.name === name); + if (sessionConfig) { + return sessionConfig; + } + + // Try project level const projectConfig = await this.findSubagentByNameAtLevel(name, 'project'); if (projectConfig) { return projectConfig; @@ -191,12 +212,30 @@ export class SubagentManager { ); } + // Prevent updating session-level agents + if (existing.level === 'session') { + throw new SubagentError( + `Cannot update session-level subagent "${name}"`, + SubagentErrorCode.INVALID_CONFIG, + name, + ); + } + // Merge updates with existing configuration const updatedConfig = this.mergeConfigurations(existing, updates); // Validate the updated configuration this.validator.validateOrThrow(updatedConfig); + // Ensure filePath exists for file-based agents + if (!existing.filePath) { + throw new SubagentError( + `Cannot update subagent "${name}": no file path available`, + SubagentErrorCode.FILE_ERROR, + name, + ); + } + // Write the updated configuration const content = this.serializeSubagent(updatedConfig); @@ -236,8 +275,8 @@ export class SubagentManager { let deleted = false; for (const currentLevel of levelsToCheck) { - // Skip builtin level for deletion - if (currentLevel === 'builtin') { + // Skip builtin and session levels for deletion + if (currentLevel === 'builtin' || currentLevel === 'session') { continue; } @@ -277,6 +316,33 @@ export class SubagentManager { const subagents: SubagentConfig[] = []; const seenNames = new Set(); + // In SDK mode, only load session-level subagents + if (this.config.getSdkMode()) { + const levelsToCheck: SubagentLevel[] = options.level + ? [options.level] + : ['session']; + + for (const level of levelsToCheck) { + const levelSubagents = this.subagentsCache?.get(level) || []; + + for (const subagent of levelSubagents) { + // Apply tool filter if specified + if ( + options.hasTool && + (!subagent.tools || !subagent.tools.includes(options.hasTool)) + ) { + continue; + } + + subagents.push(subagent); + seenNames.add(subagent.name); + } + } + + return subagents; + } + + // Normal mode: load from project, user, and builtin levels const levelsToCheck: SubagentLevel[] = options.level ? [options.level] : ['project', 'user', 'builtin']; @@ -322,8 +388,8 @@ export class SubagentManager { comparison = a.name.localeCompare(b.name); break; case 'level': { - // Project comes before user, user comes before builtin - const levelOrder = { project: 0, user: 1, builtin: 2 }; + // Project comes before user, user comes before builtin, session comes last + const levelOrder = { project: 0, user: 1, builtin: 2, session: 3 }; comparison = levelOrder[a.level] - levelOrder[b.level]; break; } @@ -339,6 +405,27 @@ export class SubagentManager { return subagents; } + /** + * Loads session-level subagents into the cache. + * Session subagents are provided directly via config and are read-only. + * + * @param subagents - Array of session subagent configurations + */ + loadSessionSubagents(subagents: SubagentConfig[]): void { + if (!this.subagentsCache) { + this.subagentsCache = new Map(); + } + + const sessionSubagents = subagents.map((config) => ({ + ...config, + level: 'session' as SubagentLevel, + filePath: ``, + })); + + this.subagentsCache.set('session', sessionSubagents); + this.notifyChangeListeners(); + } + /** * Refreshes the subagents cache by loading all subagents from disk. * This method is called automatically when cache is null or when force=true. @@ -693,6 +780,10 @@ export class SubagentManager { return ``; } + if (level === 'session') { + return ``; + } + const baseDir = level === 'project' ? path.join( diff --git a/packages/core/src/subagents/subagent-statistics.test.ts b/packages/core/src/subagents/subagent-statistics.test.ts index 5b4ae3c6..39ba70aa 100644 --- a/packages/core/src/subagents/subagent-statistics.test.ts +++ b/packages/core/src/subagents/subagent-statistics.test.ts @@ -50,6 +50,15 @@ describe('SubagentStatistics', () => { expect(summary.outputTokens).toBe(600); expect(summary.totalTokens).toBe(1800); }); + + it('should track thought and cached tokens', () => { + stats.recordTokens(100, 50, 10, 5); + + const summary = stats.getSummary(); + expect(summary.thoughtTokens).toBe(10); + expect(summary.cachedTokens).toBe(5); + expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5 + }); }); describe('tool usage statistics', () => { @@ -93,14 +102,14 @@ describe('SubagentStatistics', () => { stats.start(baseTime); stats.setRounds(2); stats.recordToolCall('file_read', true, 100); - stats.recordTokens(1000, 500); + stats.recordTokens(1000, 500, 20, 10); const result = stats.formatCompact('Test task', baseTime + 5000); expect(result).toContain('๐Ÿ“‹ Task Completed: Test task'); expect(result).toContain('๐Ÿ”ง Tool Usage: 1 calls, 100.0% success'); expect(result).toContain('โฑ๏ธ Duration: 5.0s | ๐Ÿ” Rounds: 2'); - expect(result).toContain('๐Ÿ”ข Tokens: 1,500 (in 1000, out 500)'); + expect(result).toContain('๐Ÿ”ข Tokens: 1,530 (in 1000, out 500)'); }); it('should handle zero tool calls', () => { diff --git a/packages/core/src/subagents/subagent-statistics.ts b/packages/core/src/subagents/subagent-statistics.ts index 3ef120c6..72308c63 100644 --- a/packages/core/src/subagents/subagent-statistics.ts +++ b/packages/core/src/subagents/subagent-statistics.ts @@ -23,6 +23,8 @@ export interface SubagentStatsSummary { successRate: number; inputTokens: number; outputTokens: number; + thoughtTokens: number; + cachedTokens: number; totalTokens: number; estimatedCost: number; toolUsage: ToolUsageStats[]; @@ -36,6 +38,8 @@ export class SubagentStatistics { private failedToolCalls = 0; private inputTokens = 0; private outputTokens = 0; + private thoughtTokens = 0; + private cachedTokens = 0; private toolUsage = new Map(); start(now = Date.now()) { @@ -74,9 +78,16 @@ export class SubagentStatistics { this.toolUsage.set(name, tu); } - recordTokens(input: number, output: number) { + recordTokens( + input: number, + output: number, + thought: number = 0, + cached: number = 0, + ) { this.inputTokens += Math.max(0, input || 0); this.outputTokens += Math.max(0, output || 0); + this.thoughtTokens += Math.max(0, thought || 0); + this.cachedTokens += Math.max(0, cached || 0); } getSummary(now = Date.now()): SubagentStatsSummary { @@ -86,7 +97,11 @@ export class SubagentStatistics { totalToolCalls > 0 ? (this.successfulToolCalls / totalToolCalls) * 100 : 0; - const totalTokens = this.inputTokens + this.outputTokens; + const totalTokens = + this.inputTokens + + this.outputTokens + + this.thoughtTokens + + this.cachedTokens; const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5; return { rounds: this.rounds, @@ -97,6 +112,8 @@ export class SubagentStatistics { successRate, inputTokens: this.inputTokens, outputTokens: this.outputTokens, + thoughtTokens: this.thoughtTokens, + cachedTokens: this.cachedTokens, totalTokens, estimatedCost, toolUsage: Array.from(this.toolUsage.values()), @@ -116,8 +133,12 @@ export class SubagentStatistics { `โฑ๏ธ Duration: ${this.fmtDuration(stats.totalDurationMs)} | ๐Ÿ” Rounds: ${stats.rounds}`, ]; if (typeof stats.totalTokens === 'number') { + const parts = [ + `in ${stats.inputTokens ?? 0}`, + `out ${stats.outputTokens ?? 0}`, + ]; lines.push( - `๐Ÿ”ข Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`, + `๐Ÿ”ข Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`, ); } return lines.join('\n'); @@ -152,8 +173,12 @@ export class SubagentStatistics { `๐Ÿ”ง Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`, ); if (typeof stats.totalTokens === 'number') { + const parts = [ + `in ${stats.inputTokens ?? 0}`, + `out ${stats.outputTokens ?? 0}`, + ]; lines.push( - `๐Ÿ”ข Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`, + `๐Ÿ”ข Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`, ); } if (stats.toolUsage && stats.toolUsage.length) { diff --git a/packages/core/src/subagents/subagent.test.ts b/packages/core/src/subagents/subagent.test.ts index 256fb44d..742813cd 100644 --- a/packages/core/src/subagents/subagent.test.ts +++ b/packages/core/src/subagents/subagent.test.ts @@ -69,6 +69,8 @@ async function createMockConfig( targetDir: '.', debugMode: false, cwd: process.cwd(), + // Avoid writing any chat recording records from tests (e.g. via tool-call telemetry). + chatRecording: false, }; const config = new Config(configParams); await config.initialize(); diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts index 885e8ca6..39e43e54 100644 --- a/packages/core/src/subagents/subagent.ts +++ b/packages/core/src/subagents/subagent.ts @@ -41,6 +41,7 @@ import type { SubAgentToolResultEvent, SubAgentStreamTextEvent, SubAgentErrorEvent, + SubAgentUsageEvent, } from './subagent-events.js'; import { type SubAgentEventEmitter, @@ -369,6 +370,7 @@ export class SubAgentScope { }, }; + const roundStreamStart = Date.now(); const responseStream = await chat.sendMessageStream( this.modelConfig.model || this.runtimeContext.getModel() || @@ -439,10 +441,19 @@ export class SubAgentScope { if (lastUsage) { const inTok = Number(lastUsage.promptTokenCount || 0); const outTok = Number(lastUsage.candidatesTokenCount || 0); - if (isFinite(inTok) || isFinite(outTok)) { + const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0); + const cachedTok = Number(lastUsage.cachedContentTokenCount || 0); + if ( + isFinite(inTok) || + isFinite(outTok) || + isFinite(thoughtTok) || + isFinite(cachedTok) + ) { this.stats.recordTokens( isFinite(inTok) ? inTok : 0, isFinite(outTok) ? outTok : 0, + isFinite(thoughtTok) ? thoughtTok : 0, + isFinite(cachedTok) ? cachedTok : 0, ); // mirror legacy fields for compatibility this.executionStats.inputTokens = @@ -453,11 +464,20 @@ export class SubAgentScope { (isFinite(outTok) ? outTok : 0); this.executionStats.totalTokens = (this.executionStats.inputTokens || 0) + - (this.executionStats.outputTokens || 0); + (this.executionStats.outputTokens || 0) + + (isFinite(thoughtTok) ? thoughtTok : 0) + + (isFinite(cachedTok) ? cachedTok : 0); this.executionStats.estimatedCost = (this.executionStats.inputTokens || 0) * 3e-5 + (this.executionStats.outputTokens || 0) * 6e-5; } + this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, { + subagentId: this.subagentId, + round: turnCounter, + usage: lastUsage, + durationMs: Date.now() - roundStreamStart, + timestamp: Date.now(), + } as SubAgentUsageEvent); } if (functionCalls.length > 0) { diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 67b78a50..accfb18f 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -11,8 +11,9 @@ import type { Content, FunctionDeclaration } from '@google/genai'; * - 'project': Stored in `.qwen/agents/` within the project directory * - 'user': Stored in `~/.qwen/agents/` in the user's home directory * - 'builtin': Built-in agents embedded in the codebase, always available + * - 'session': Session-level agents provided at runtime, read-only */ -export type SubagentLevel = 'project' | 'user' | 'builtin'; +export type SubagentLevel = 'project' | 'user' | 'builtin' | 'session'; /** * Core configuration for a subagent as stored in Markdown files. @@ -41,8 +42,8 @@ export interface SubagentConfig { /** Storage level - determines where the configuration file is stored */ level: SubagentLevel; - /** Absolute path to the configuration file */ - filePath: string; + /** Absolute path to the configuration file. Optional for session subagents. */ + filePath?: string; /** * Optional model configuration. If not provided, uses defaults. diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index b6a97a2e..f0fb94f1 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -249,6 +249,9 @@ export class QwenLogger { authType === AuthType.USE_OPENAI ? this.config?.getContentGeneratorConfig().baseUrl || '' : '', + ...(this.config?.getChannel?.() + ? { channel: this.config.getChannel() } + : {}), }, _v: `qwen-code@${version}`, } as RumPayload; diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 9a257e5a..0f8f2146 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -23,6 +23,12 @@ export type UiEvent = | (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }) | (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); +export { + EVENT_API_ERROR, + EVENT_API_RESPONSE, + EVENT_TOOL_CALL, +} from './constants.js'; + export interface ToolCallStats { count: number; success: number; diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index 3729c251..b6a04c35 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -198,6 +198,52 @@ describe('GlobTool', () => { ); }); + it('should find files even if workspace path casing differs from glob results (Windows/macOS)', async () => { + // Only relevant for Windows and macOS + if (process.platform !== 'win32' && process.platform !== 'darwin') { + return; + } + + let mismatchedRootDir = tempRootDir; + + if (process.platform === 'win32') { + // 1. Create a path with mismatched casing for the workspace root + // e.g., if tempRootDir is "C:\Users\...", make it "c:\Users\..." + const drive = path.parse(tempRootDir).root; + if (!drive || !drive.match(/^[A-Z]:\\/)) { + // Skip if we can't determine/manipulate the drive letter easily + return; + } + + const lowerDrive = drive.toLowerCase(); + mismatchedRootDir = lowerDrive + tempRootDir.substring(drive.length); + } else { + // macOS: change the casing of the path + if (tempRootDir === tempRootDir.toLowerCase()) { + mismatchedRootDir = tempRootDir.toUpperCase(); + } else { + mismatchedRootDir = tempRootDir.toLowerCase(); + } + } + + // 2. Create a new GlobTool instance with this mismatched root + const mismatchedConfig = { + ...mockConfig, + getTargetDir: () => mismatchedRootDir, + getWorkspaceContext: () => + createMockWorkspaceContext(mismatchedRootDir), + } as unknown as Config; + + const mismatchedGlobTool = new GlobTool(mismatchedConfig); + + // 3. Execute search + const params: GlobToolParams = { pattern: '*.txt' }; + const invocation = mismatchedGlobTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Found 2 file(s)'); + }); + it('should return error if path is outside workspace', async () => { // Bypassing validation to test execute method directly vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null); diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 29b6cf86..a3b4a5d5 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -134,12 +134,21 @@ class GlobToolInvocation extends BaseToolInvocation< this.getFileFilteringOptions(), ); + const normalizePathForComparison = (p: string) => + process.platform === 'win32' || process.platform === 'darwin' + ? p.toLowerCase() + : p; + const filteredAbsolutePaths = new Set( - filteredPaths.map((p) => path.resolve(this.config.getTargetDir(), p)), + filteredPaths.map((p) => + normalizePathForComparison( + path.resolve(this.config.getTargetDir(), p), + ), + ), ); const filteredEntries = allEntries.filter((entry) => - filteredAbsolutePaths.has(entry.fullpath()), + filteredAbsolutePaths.has(normalizePathForComparison(entry.fullpath())), ); if (!filteredEntries || filteredEntries.length === 0) { diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 93e25ea8..a8b48236 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -5,6 +5,7 @@ */ import type { Config, MCPServerConfig } from '../config/config.js'; +import { isSdkMcpServerConfig } from '../config/config.js'; import type { ToolRegistry } from './tool-registry.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js'; import { @@ -12,6 +13,7 @@ import { MCPDiscoveryState, populateMcpServerCommand, } from './mcp-client.js'; +import type { SendSdkMcpMessage } from './mcp-client.js'; import { getErrorMessage } from '../utils/errors.js'; import type { EventEmitter } from 'node:events'; import type { WorkspaceContext } from '../utils/workspaceContext.js'; @@ -31,6 +33,7 @@ export class McpClientManager { private readonly workspaceContext: WorkspaceContext; private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED; private readonly eventEmitter?: EventEmitter; + private readonly sendSdkMcpMessage?: SendSdkMcpMessage; constructor( mcpServers: Record, @@ -40,6 +43,7 @@ export class McpClientManager { debugMode: boolean, workspaceContext: WorkspaceContext, eventEmitter?: EventEmitter, + sendSdkMcpMessage?: SendSdkMcpMessage, ) { this.mcpServers = mcpServers; this.mcpServerCommand = mcpServerCommand; @@ -48,6 +52,7 @@ export class McpClientManager { this.debugMode = debugMode; this.workspaceContext = workspaceContext; this.eventEmitter = eventEmitter; + this.sendSdkMcpMessage = sendSdkMcpMessage; } /** @@ -71,6 +76,11 @@ export class McpClientManager { this.eventEmitter?.emit('mcp-client-update', this.clients); const discoveryPromises = Object.entries(servers).map( async ([name, config]) => { + // For SDK MCP servers, pass the sendSdkMcpMessage callback + const sdkCallback = isSdkMcpServerConfig(config) + ? this.sendSdkMcpMessage + : undefined; + const client = new McpClient( name, config, @@ -78,6 +88,7 @@ export class McpClientManager { this.promptRegistry, this.workspaceContext, this.debugMode, + sdkCallback, ); this.clients.set(name, client); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index a6903d13..efea02ad 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -13,6 +13,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { GetPromptResult, + JSONRPCMessage, Prompt, } from '@modelcontextprotocol/sdk/types.js'; import { @@ -22,10 +23,11 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { parse } from 'shell-quote'; import type { Config, MCPServerConfig } from '../config/config.js'; -import { AuthProviderType } from '../config/config.js'; +import { AuthProviderType, isSdkMcpServerConfig } from '../config/config.js'; import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; +import { SdkControlClientTransport } from './sdk-control-client-transport.js'; import type { FunctionDeclaration } from '@google/genai'; import { mcpToTool } from '@google/genai'; @@ -42,6 +44,14 @@ import type { } from '../utils/workspaceContext.js'; import type { ToolRegistry } from './tool-registry.js'; +/** + * Callback type for sending MCP messages to SDK servers via control plane + */ +export type SendSdkMcpMessage = ( + serverName: string, + message: JSONRPCMessage, +) => Promise; + export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes export type DiscoveredMCPPrompt = Prompt & { @@ -92,6 +102,7 @@ export class McpClient { private readonly promptRegistry: PromptRegistry, private readonly workspaceContext: WorkspaceContext, private readonly debugMode: boolean, + private readonly sendSdkMcpMessage?: SendSdkMcpMessage, ) { this.client = new Client({ name: `qwen-cli-mcp-client-${this.serverName}`, @@ -189,7 +200,12 @@ export class McpClient { } private async createTransport(): Promise { - return createTransport(this.serverName, this.serverConfig, this.debugMode); + return createTransport( + this.serverName, + this.serverConfig, + this.debugMode, + this.sendSdkMcpMessage, + ); } private async discoverTools(cliConfig: Config): Promise { @@ -501,6 +517,7 @@ export function populateMcpServerCommand( * @param mcpServerName The name identifier for this MCP server * @param mcpServerConfig Configuration object containing connection details * @param toolRegistry The registry to register discovered tools with + * @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane. * @returns Promise that resolves when discovery is complete */ export async function connectAndDiscover( @@ -511,6 +528,7 @@ export async function connectAndDiscover( debugMode: boolean, workspaceContext: WorkspaceContext, cliConfig: Config, + sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING); @@ -521,6 +539,7 @@ export async function connectAndDiscover( mcpServerConfig, debugMode, workspaceContext, + sendSdkMcpMessage, ); mcpClient.onerror = (error) => { @@ -744,6 +763,7 @@ export function hasNetworkTransport(config: MCPServerConfig): boolean { * * @param mcpServerName The name of the MCP server, used for logging and identification. * @param mcpServerConfig The configuration specifying how to connect to the server. + * @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane. * @returns A promise that resolves to a connected MCP `Client` instance. * @throws An error if the connection fails or the configuration is invalid. */ @@ -752,6 +772,7 @@ export async function connectToMcpServer( mcpServerConfig: MCPServerConfig, debugMode: boolean, workspaceContext: WorkspaceContext, + sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { const mcpClient = new Client({ name: 'qwen-code-mcp-client', @@ -808,6 +829,7 @@ export async function connectToMcpServer( mcpServerName, mcpServerConfig, debugMode, + sendSdkMcpMessage, ); try { await mcpClient.connect(transport, { @@ -1172,7 +1194,21 @@ export async function createTransport( mcpServerName: string, mcpServerConfig: MCPServerConfig, debugMode: boolean, + sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { + if (isSdkMcpServerConfig(mcpServerConfig)) { + if (!sendSdkMcpMessage) { + throw new Error( + `SDK MCP server '${mcpServerName}' requires sendSdkMcpMessage callback`, + ); + } + return new SdkControlClientTransport({ + serverName: mcpServerName, + sendMcpMessage: sendSdkMcpMessage, + debugMode, + }); + } + if ( mcpServerConfig.authProviderType === AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index afffa103..15f461e9 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -10,6 +10,7 @@ import type { ToolInvocation, ToolMcpConfirmationDetails, ToolResult, + ToolConfirmationPayload, } from './tools.js'; import { BaseDeclarativeTool, @@ -98,7 +99,10 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< serverName: this.serverName, toolName: this.serverToolName, // Display original tool name in confirmation toolDisplayName: this.displayName, // Display global registry name exposed to model and user - onConfirm: async (outcome: ToolConfirmationOutcome) => { + onConfirm: async ( + outcome: ToolConfirmationOutcome, + _payload?: ToolConfirmationPayload, + ) => { if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) { DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey); } else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) { diff --git a/packages/core/src/tools/sdk-control-client-transport.ts b/packages/core/src/tools/sdk-control-client-transport.ts new file mode 100644 index 00000000..be2f3099 --- /dev/null +++ b/packages/core/src/tools/sdk-control-client-transport.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SdkControlClientTransport - MCP Client transport for SDK MCP servers + * + * This transport enables CLI's MCP client to connect to SDK MCP servers + * through the control plane. Messages are routed: + * + * CLI MCP Client โ†’ SdkControlClientTransport โ†’ sendMcpMessage() โ†’ + * control_request (mcp_message) โ†’ SDK โ†’ control_response โ†’ onmessage โ†’ CLI + * + * Unlike StdioClientTransport which spawns a subprocess, this transport + * communicates with SDK MCP servers running in the SDK process. + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Callback to send MCP messages to SDK via control plane + * Returns the MCP response from the SDK + */ +export type SendMcpMessageCallback = ( + serverName: string, + message: JSONRPCMessage, +) => Promise; + +export interface SdkControlClientTransportOptions { + serverName: string; + sendMcpMessage: SendMcpMessageCallback; + debugMode?: boolean; +} + +/** + * MCP Client Transport for SDK MCP servers + * + * Implements the @modelcontextprotocol/sdk Transport interface to enable + * CLI's MCP client to connect to SDK MCP servers via the control plane. + */ +export class SdkControlClientTransport { + private serverName: string; + private sendMcpMessage: SendMcpMessageCallback; + private debugMode: boolean; + private started = false; + + // Transport interface callbacks + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + constructor(options: SdkControlClientTransportOptions) { + this.serverName = options.serverName; + this.sendMcpMessage = options.sendMcpMessage; + this.debugMode = options.debugMode ?? false; + } + + /** + * Start the transport + * For SDK transport, this just marks it as ready - no subprocess to spawn + */ + async start(): Promise { + if (this.started) { + return; + } + + this.started = true; + + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Started for server '${this.serverName}'`, + ); + } + } + + /** + * Send a message to the SDK MCP server via control plane + * + * Routes the message through the control plane and delivers + * the response via onmessage callback. + */ + async send(message: JSONRPCMessage): Promise { + if (!this.started) { + throw new Error( + `SdkControlClientTransport (${this.serverName}) not started. Call start() first.`, + ); + } + + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Sending message to '${this.serverName}':`, + JSON.stringify(message), + ); + } + + try { + // Send message to SDK and wait for response + const response = await this.sendMcpMessage(this.serverName, message); + + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Received response from '${this.serverName}':`, + JSON.stringify(response), + ); + } + + // Deliver response via onmessage callback + if (this.onmessage) { + this.onmessage(response); + } + } catch (error) { + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Error sending to '${this.serverName}':`, + error, + ); + } + + if (this.onerror) { + this.onerror(error instanceof Error ? error : new Error(String(error))); + } + + throw error; + } + } + + /** + * Close the transport + */ + async close(): Promise { + if (!this.started) { + return; + } + + this.started = false; + + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Closed for server '${this.serverName}'`, + ); + } + + if (this.onclose) { + this.onclose(); + } + } + + /** + * Check if transport is started + */ + isStarted(): boolean { + return this.started; + } + + /** + * Get server name + */ + getServerName(): string { + return this.serverName; + } +} diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 17e40dbe..8ff3047e 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -17,6 +17,7 @@ import type { ToolResultDisplay, ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, + ToolConfirmationPayload, } from './tools.js'; import { BaseDeclarativeTool, @@ -102,7 +103,10 @@ export class ShellToolInvocation extends BaseToolInvocation< title: 'Confirm Shell Command', command: this.params.command, rootCommand: commandsToConfirm.join(', '), - onConfirm: async (outcome: ToolConfirmationOutcome) => { + onConfirm: async ( + outcome: ToolConfirmationOutcome, + _payload?: ToolConfirmationPayload, + ) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { commandsToConfirm.forEach((command) => this.allowlist.add(command)); } diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index a0123107..9b641647 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -16,6 +16,7 @@ import type { Config } from '../config/config.js'; import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; import { connectAndDiscover } from './mcp-client.js'; +import type { SendSdkMcpMessage } from './mcp-client.js'; import { McpClientManager } from './mcp-client-manager.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; import { parse } from 'shell-quote'; @@ -173,7 +174,11 @@ export class ToolRegistry { private config: Config; private mcpClientManager: McpClientManager; - constructor(config: Config, eventEmitter?: EventEmitter) { + constructor( + config: Config, + eventEmitter?: EventEmitter, + sendSdkMcpMessage?: SendSdkMcpMessage, + ) { this.config = config; this.mcpClientManager = new McpClientManager( this.config.getMcpServers() ?? {}, @@ -183,6 +188,7 @@ export class ToolRegistry { this.config.getDebugMode(), this.config.getWorkspaceContext(), eventEmitter, + sendSdkMcpMessage, ); } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 848b14c6..7b3c893e 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -531,13 +531,18 @@ export interface ToolEditConfirmationDetails { export interface ToolConfirmationPayload { // used to override `modifiedProposedContent` for modifiable tools in the // inline modify flow - newContent: string; + newContent?: string; + // used to provide custom cancellation message when outcome is Cancel + cancelMessage?: string; } export interface ToolExecuteConfirmationDetails { type: 'exec'; title: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; command: string; rootCommand: string; } @@ -548,7 +553,10 @@ export interface ToolMcpConfirmationDetails { serverName: string; toolName: string; toolDisplayName: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; } export interface ToolInfoConfirmationDetails { @@ -573,6 +581,11 @@ export interface ToolPlanConfirmationDetails { onConfirm: (outcome: ToolConfirmationOutcome) => Promise; } +/** + * TODO: + * 1. support explicit denied outcome + * 2. support proceed with modified input + */ export enum ToolConfirmationOutcome { ProceedOnce = 'proceed_once', ProceedAlways = 'proceed_always', diff --git a/packages/core/src/utils/systemEncoding.test.ts b/packages/core/src/utils/systemEncoding.test.ts index bb594dd1..61558f38 100644 --- a/packages/core/src/utils/systemEncoding.test.ts +++ b/packages/core/src/utils/systemEncoding.test.ts @@ -391,6 +391,19 @@ describe('Shell Command Processor - Encoding Functions', () => { expect(result).toBe('windows-1252'); }); + it('should prioritize UTF-8 detection over Windows system encoding', () => { + mockedOsPlatform.mockReturnValue('win32'); + mockedExecSync.mockReturnValue('Active code page: 936'); // GBK + + const buffer = Buffer.from('test'); + // Mock chardet to return UTF-8 + mockedChardetDetect.mockReturnValue('UTF-8'); + + const result = getCachedEncodingForBuffer(buffer); + + expect(result).toBe('utf-8'); + }); + it('should cache null system encoding result', () => { // Reset the cache specifically for this test resetEncodingCache(); diff --git a/packages/core/src/utils/systemEncoding.ts b/packages/core/src/utils/systemEncoding.ts index 4f43b24a..d76bdbab 100644 --- a/packages/core/src/utils/systemEncoding.ts +++ b/packages/core/src/utils/systemEncoding.ts @@ -34,6 +34,15 @@ export function getCachedEncodingForBuffer(buffer: Buffer): string { // If we have a cached system encoding, use it if (cachedSystemEncoding) { + // If the system encoding is not UTF-8 (e.g. Windows CP936), but the buffer + // is detected as UTF-8, prefer UTF-8. This handles tools like 'git' which + // often output UTF-8 regardless of the system code page. + if (cachedSystemEncoding !== 'utf-8') { + const detected = detectEncodingFromBuffer(buffer); + if (detected === 'utf-8') { + return 'utf-8'; + } + } return cachedSystemEncoding; } diff --git a/packages/core/src/utils/thoughtUtils.ts b/packages/core/src/utils/thoughtUtils.ts index c97a39a3..21b95532 100644 --- a/packages/core/src/utils/thoughtUtils.ts +++ b/packages/core/src/utils/thoughtUtils.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { GenerateContentResponse } from '@google/genai'; + export type ThoughtSummary = { subject: string; description: string; @@ -52,3 +54,23 @@ export function parseThought(rawText: string): ThoughtSummary { return { subject, description }; } + +export function getThoughtText( + response: GenerateContentResponse, +): string | null { + if (response.candidates && response.candidates.length > 0) { + const candidate = response.candidates[0]; + + if ( + candidate.content && + candidate.content.parts && + candidate.content.parts.length > 0 + ) { + return candidate.content.parts + .filter((part) => part.thought) + .map((part) => part.text ?? '') + .join(''); + } + } + return null; +} diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md new file mode 100644 index 00000000..bc3ef6aa --- /dev/null +++ b/packages/sdk-typescript/README.md @@ -0,0 +1,377 @@ +# @qwen-code/sdk + +A minimum experimental TypeScript SDK for programmatic access to Qwen Code. + +Feel free to submit a feature request/issue/PR. + +## Installation + +```bash +npm install @qwen-code/sdk +``` + +## Requirements + +- Node.js >= 20.0.0 +- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH + +> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary. + +## Quick Start + +```typescript +import { query } from '@qwen-code/sdk'; + +// Single-turn query +const result = query({ + prompt: 'What files are in the current directory?', + options: { + cwd: '/path/to/project', + }, +}); + +// Iterate over messages +for await (const message of result) { + if (message.type === 'assistant') { + console.log('Assistant:', message.message.content); + } else if (message.type === 'result') { + console.log('Result:', message.result); + } +} +``` + +## API Reference + +### `query(config)` + +Creates a new query session with the Qwen Code. + +#### Parameters + +- `prompt`: `string | AsyncIterable` - The prompt to send. Use a string for single-turn queries or an async iterable for multi-turn conversations. +- `options`: `QueryOptions` - Configuration options for the query session. + +#### QueryOptions + +| Option | Type | Default | Description | +| ------------------------ | ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | +| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | +| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | +| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | +| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | +| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | +| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | +| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | +| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | +| `coreTools` | `string[]` | - | Equivalent to `tool.core` in settings.json. If specified, only these tools will be available to the AI. Example: `['read_file', 'write_file', 'run_terminal_cmd']`. | +| `excludeTools` | `string[]` | - | Equivalent to `tool.exclude` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports pattern matching: tool name (`'write_file'`), tool class (`'ShellTool'`), or shell command prefix (`'ShellTool(rm )'`). | +| `allowedTools` | `string[]` | - | Equivalent to `tool.allowed` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Using `'qwen-oauth'` in SDK is not recommended as credentials are stored in `~/.qwen` and may need periodic refresh. | +| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | +| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | + +### Timeouts + +The SDK enforces the following default timeouts: + +| Timeout | Default | Description | +| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `canUseTool` | 1 minute | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. | +| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. | +| `controlRequest` | 1 minute | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. | +| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. | + +You can customize these timeouts via the `timeout` option: + +```typescript +const query = qwen.query('Your prompt', { + timeout: { + canUseTool: 60000, // 60 seconds for permission callback + mcpRequest: 600000, // 10 minutes for MCP tool calls + controlRequest: 60000, // 60 seconds for control requests + streamClose: 15000, // 15 seconds for stream close wait + }, +}); +``` + +### Message Types + +The SDK provides type guards to identify different message types: + +```typescript +import { + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, +} from '@qwen-code/sdk'; + +for await (const message of result) { + if (isSDKAssistantMessage(message)) { + // Handle assistant message + } else if (isSDKResultMessage(message)) { + // Handle result message + } +} +``` + +### Query Instance Methods + +The `Query` instance returned by `query()` provides several methods: + +```typescript +const q = query({ prompt: 'Hello', options: {} }); + +// Get session ID +const sessionId = q.getSessionId(); + +// Check if closed +const closed = q.isClosed(); + +// Interrupt the current operation +await q.interrupt(); + +// Change permission mode mid-session +await q.setPermissionMode('yolo'); + +// Change model mid-session +await q.setModel('qwen-max'); + +// Close the session +await q.close(); +``` + +## Permission Modes + +The SDK supports different permission modes for controlling tool execution: + +- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation. +- **`plan`**: Blocks all write tools, instructing AI to present a plan first. +- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation. +- **`yolo`**: All tools execute automatically without confirmation. + +### Permission Priority Chain + +1. `excludeTools` - Blocks tools completely +2. `permissionMode: 'plan'` - Blocks non-read-only tools +3. `permissionMode: 'yolo'` - Auto-approves all tools +4. `allowedTools` - Auto-approves matching tools +5. `canUseTool` callback - Custom approval logic +6. Default behavior - Auto-deny in SDK mode + +## Examples + +### Multi-turn Conversation + +```typescript +import { query, type SDKUserMessage } from '@qwen-code/sdk'; + +async function* generateMessages(): AsyncIterable { + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Create a hello.txt file' }, + parent_tool_use_id: null, + }; + + // Wait for some condition or user input + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Now read the file back' }, + parent_tool_use_id: null, + }; +} + +const result = query({ + prompt: generateMessages(), + options: { + permissionMode: 'auto-edit', + }, +}); + +for await (const message of result) { + console.log(message); +} +``` + +### Custom Permission Handler + +```typescript +import { query, type CanUseTool } from '@qwen-code/sdk'; + +const canUseTool: CanUseTool = async (toolName, input, { signal }) => { + // Allow all read operations + if (toolName.startsWith('read_')) { + return { behavior: 'allow', updatedInput: input }; + } + + // Prompt user for write operations (in a real app) + const userApproved = await promptUser(`Allow ${toolName}?`); + + if (userApproved) { + return { behavior: 'allow', updatedInput: input }; + } + + return { behavior: 'deny', message: 'User denied the operation' }; +}; + +const result = query({ + prompt: 'Create a new file', + options: { + canUseTool, + }, +}); +``` + +### With External MCP Servers + +```typescript +import { query } from '@qwen-code/sdk'; + +const result = query({ + prompt: 'Use the custom tool from my MCP server', + options: { + mcpServers: { + 'my-server': { + command: 'node', + args: ['path/to/mcp-server.js'], + env: { PORT: '3000' }, + }, + }, + }, +}); +``` + +### With SDK-Embedded MCP Servers + +The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process. + +#### `tool(name, description, inputSchema, handler)` + +Creates a tool definition with Zod schema type inference. + +| Parameter | Type | Description | +| ------------- | ---------------------------------- | ------------------------------------------------------------------------ | +| `name` | `string` | Tool name (1-64 chars, starts with letter, alphanumeric and underscores) | +| `description` | `string` | Human-readable description of what the tool does | +| `inputSchema` | `ZodRawShape` | Zod schema object defining the tool's input parameters | +| `handler` | `(args, extra) => Promise` | Async function that executes the tool and returns MCP content blocks | + +The handler must return a `CallToolResult` object with the following structure: + +```typescript +{ + content: Array< + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; uri: string; mimeType?: string; text?: string } + >; + isError?: boolean; +} +``` + +#### `createSdkMcpServer(options)` + +Creates an SDK-embedded MCP server instance. + +| Option | Type | Default | Description | +| --------- | ------------------------ | --------- | ------------------------------------ | +| `name` | `string` | Required | Unique name for the MCP server | +| `version` | `string` | `'1.0.0'` | Server version | +| `tools` | `SdkMcpToolDefinition[]` | - | Array of tools created with `tool()` | + +Returns a `McpSdkServerConfigWithInstance` object that can be passed directly to the `mcpServers` option. + +#### Example + +```typescript +import { z } from 'zod'; +import { query, tool, createSdkMcpServer } from '@qwen-code/sdk'; + +// Define a tool with Zod schema +const calculatorTool = tool( + 'calculate_sum', + 'Add two numbers', + { a: z.number(), b: z.number() }, + async (args) => ({ + content: [{ type: 'text', text: String(args.a + args.b) }], + }), +); + +// Create the MCP server +const server = createSdkMcpServer({ + name: 'calculator', + tools: [calculatorTool], +}); + +// Use the server in a query +const result = query({ + prompt: 'What is 42 + 17?', + options: { + permissionMode: 'yolo', + mcpServers: { + calculator: server, + }, + }, +}); + +for await (const message of result) { + console.log(message); +} +``` + +### Abort a Query + +```typescript +import { query, isAbortError } from '@qwen-code/sdk'; + +const abortController = new AbortController(); + +const result = query({ + prompt: 'Long running task...', + options: { + abortController, + }, +}); + +// Abort after 5 seconds +setTimeout(() => abortController.abort(), 5000); + +try { + for await (const message of result) { + console.log(message); + } +} catch (error) { + if (isAbortError(error)) { + console.log('Query was aborted'); + } else { + throw error; + } +} +``` + +## Error Handling + +The SDK provides an `AbortError` class for handling aborted queries: + +```typescript +import { AbortError, isAbortError } from '@qwen-code/sdk'; + +try { + // ... query operations +} catch (error) { + if (isAbortError(error)) { + // Handle abort + } else { + // Handle other errors + } +} +``` + +## License + +Apache-2.0 - see [LICENSE](./LICENSE) for details. diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json new file mode 100644 index 00000000..f6ed8198 --- /dev/null +++ b/packages/sdk-typescript/package.json @@ -0,0 +1,74 @@ +{ + "name": "@qwen-code/sdk", + "version": "0.5.0", + "description": "TypeScript SDK for programmatic access to qwen-code CLI", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "node scripts/build.js", + "test": "vitest run", + "test:ci": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build", + "prepack": "npm run build" + }, + "keywords": [ + "qwen", + "qwen-code", + "ai", + "code-assistant", + "sdk", + "typescript" + ], + "author": "Qwen Team", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitest/coverage-v8": "^1.6.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.12", + "eslint": "^8.57.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0", + "zod": "^3.23.8" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/QwenLM/qwen-code.git", + "directory": "packages/sdk-typescript" + }, + "bugs": { + "url": "https://github.com/QwenLM/qwen-code/issues" + }, + "homepage": "https://qwenlm.github.io/qwen-code-docs/" +} diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js new file mode 100755 index 00000000..beda8b0e --- /dev/null +++ b/packages/sdk-typescript/scripts/build.js @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { rmSync, mkdirSync, existsSync, cpSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import esbuild from 'esbuild'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); + +rmSync(join(rootDir, 'dist'), { recursive: true, force: true }); +mkdirSync(join(rootDir, 'dist'), { recursive: true }); + +execSync('tsc --project tsconfig.build.json', { + stdio: 'inherit', + cwd: rootDir, +}); + +try { + execSync( + 'npx dts-bundle-generator --project tsconfig.build.json -o dist/index.d.ts src/index.ts', + { + stdio: 'inherit', + cwd: rootDir, + }, + ); + + const dirsToRemove = ['mcp', 'query', 'transport', 'types', 'utils']; + for (const dir of dirsToRemove) { + const dirPath = join(rootDir, 'dist', dir); + if (existsSync(dirPath)) { + rmSync(dirPath, { recursive: true, force: true }); + } + } +} catch (error) { + console.warn( + 'Could not bundle type definitions, keeping separate .d.ts files', + error.message, + ); +} + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.mjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.cjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +// Copy LICENSE from root directory to dist +const licenseSource = join(rootDir, '..', '..', 'LICENSE'); +const licenseTarget = join(rootDir, 'dist', 'LICENSE'); +if (existsSync(licenseSource)) { + try { + cpSync(licenseSource, licenseTarget); + } catch (error) { + console.warn('Could not copy LICENSE:', error.message); + } +} diff --git a/packages/sdk-typescript/scripts/get-release-version.js b/packages/sdk-typescript/scripts/get-release-version.js new file mode 100644 index 00000000..c6b1f665 --- /dev/null +++ b/packages/sdk-typescript/scripts/get-release-version.js @@ -0,0 +1,353 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const PACKAGE_NAME = '@qwen-code/sdk'; +const TAG_PREFIX = 'sdk-typescript-v'; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +function getArgs() { + const args = {}; + process.argv.slice(2).forEach((arg) => { + if (arg.startsWith('--')) { + const [key, value] = arg.substring(2).split('='); + args[key] = value === undefined ? true : value; + } + }); + return args; +} + +function getVersionFromNPM(distTag) { + const command = `npm view ${PACKAGE_NAME} version --tag=${distTag}`; + try { + return execSync(command).toString().trim(); + } catch (error) { + console.error( + `Failed to get NPM version for dist-tag "${distTag}": ${error.message}`, + ); + return ''; + } +} + +function getAllVersionsFromNPM() { + const command = `npm view ${PACKAGE_NAME} versions --json`; + try { + const versionsJson = execSync(command).toString().trim(); + const result = JSON.parse(versionsJson); + // npm returns a string if there's only one version, array otherwise + return Array.isArray(result) ? result : [result]; + } catch (error) { + console.error(`Failed to get all NPM versions: ${error.message}`); + return []; + } +} + +function isVersionDeprecated(version) { + const command = `npm view ${PACKAGE_NAME}@${version} deprecated`; + try { + const output = execSync(command).toString().trim(); + return output.length > 0; + } catch (error) { + console.error( + `Failed to check deprecation status for ${version}: ${error.message}`, + ); + return false; + } +} + +function semverCompare(a, b) { + const parseVersion = (v) => { + const [main, prerelease] = v.split('-'); + const [major, minor, patch] = main.split('.').map(Number); + return { major, minor, patch, prerelease: prerelease || '' }; + }; + + const va = parseVersion(a); + const vb = parseVersion(b); + + if (va.major !== vb.major) return va.major - vb.major; + if (va.minor !== vb.minor) return va.minor - vb.minor; + if (va.patch !== vb.patch) return va.patch - vb.patch; + + // Handle prerelease comparison + if (!va.prerelease && vb.prerelease) return 1; // stable > prerelease + if (va.prerelease && !vb.prerelease) return -1; // prerelease < stable + if (va.prerelease && vb.prerelease) { + return va.prerelease.localeCompare(vb.prerelease); + } + return 0; +} + +function detectRollbackAndGetBaseline(npmDistTag) { + const distTagVersion = getVersionFromNPM(npmDistTag); + if (!distTagVersion) return { baseline: '', isRollback: false }; + + const allVersions = getAllVersionsFromNPM(); + if (allVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + let matchingVersions; + if (npmDistTag === 'latest') { + matchingVersions = allVersions.filter((v) => !v.includes('-')); + } else if (npmDistTag === 'preview') { + matchingVersions = allVersions.filter((v) => v.includes('-preview')); + } else if (npmDistTag === 'nightly') { + matchingVersions = allVersions.filter((v) => v.includes('-nightly')); + } else { + return { baseline: distTagVersion, isRollback: false }; + } + + if (matchingVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + matchingVersions.sort((a, b) => -semverCompare(a, b)); + + let highestExistingVersion = ''; + for (const version of matchingVersions) { + if (!isVersionDeprecated(version)) { + highestExistingVersion = version; + break; + } else { + console.error(`Ignoring deprecated version: ${version}`); + } + } + + if (!highestExistingVersion) { + highestExistingVersion = distTagVersion; + } + + const isRollback = semverCompare(highestExistingVersion, distTagVersion) > 0; + + return { + baseline: isRollback ? highestExistingVersion : distTagVersion, + isRollback, + distTagVersion, + highestExistingVersion, + }; +} + +function doesVersionExist(version) { + // Check NPM + try { + const command = `npm view ${PACKAGE_NAME}@${version} version 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === version) { + console.error(`Version ${version} already exists on NPM.`); + return true; + } + } catch (_error) { + // This is expected if the version doesn't exist. + } + + // Check Git tags + try { + const command = `git tag -l '${TAG_PREFIX}${version}'`; + const tagOutput = execSync(command).toString().trim(); + if (tagOutput === `${TAG_PREFIX}${version}`) { + console.error(`Git tag ${TAG_PREFIX}${version} already exists.`); + return true; + } + } catch (error) { + console.error(`Failed to check git tags for conflicts: ${error.message}`); + } + + // Check GitHub releases + try { + const command = `gh release view "${TAG_PREFIX}${version}" --json tagName --jq .tagName 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === `${TAG_PREFIX}${version}`) { + console.error(`GitHub release ${TAG_PREFIX}${version} already exists.`); + return true; + } + } catch (error) { + const isExpectedNotFound = + error.message.includes('release not found') || + error.message.includes('Not Found') || + error.message.includes('not found') || + error.status === 1; + if (!isExpectedNotFound) { + console.error( + `Failed to check GitHub releases for conflicts: ${error.message}`, + ); + } + } + + return false; +} + +function getAndVerifyTags(npmDistTag) { + const rollbackInfo = detectRollbackAndGetBaseline(npmDistTag); + const baselineVersion = rollbackInfo.baseline; + + if (!baselineVersion) { + // First release for this dist-tag, use package.json version as baseline + const packageJson = readJson(join(__dirname, '..', 'package.json')); + return { + latestVersion: packageJson.version.split('-')[0], + latestTag: `v${packageJson.version.split('-')[0]}`, + }; + } + + if (rollbackInfo.isRollback) { + console.error( + `Rollback detected! NPM ${npmDistTag} tag is ${rollbackInfo.distTagVersion}, but using ${baselineVersion} as baseline for next version calculation.`, + ); + } + + return { + latestVersion: baselineVersion, + latestTag: `v${baselineVersion}`, + }; +} + +function getLatestStableReleaseTag() { + try { + const { latestTag } = getAndVerifyTags('latest'); + return latestTag; + } catch (error) { + console.error( + `Failed to determine latest stable release tag: ${error.message}`, + ); + return ''; + } +} + +function getNightlyVersion() { + const packageJson = readJson(join(__dirname, '..', 'package.json')); + const baseVersion = packageJson.version.split('-')[0]; + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim(); + const releaseVersion = `${baseVersion}-nightly.${date}.${gitShortHash}`; + return { + releaseVersion, + npmTag: 'nightly', + }; +} + +function validateVersion(version, format, name) { + const versionRegex = { + 'X.Y.Z': /^\d+\.\d+\.\d+$/, + 'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/, + }; + + if (!versionRegex[format] || !versionRegex[format].test(version)) { + throw new Error( + `Invalid ${name}: ${version}. Must be in ${format} format.`, + ); + } +} + +function getStableVersion(args) { + let releaseVersion; + if (args.stable_version_override) { + const overrideVersion = args.stable_version_override.replace(/^v/, ''); + validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override'); + releaseVersion = overrideVersion; + } else { + // Try to get from preview, fallback to package.json for first release + const { latestVersion: latestPreviewVersion } = getAndVerifyTags('preview'); + releaseVersion = latestPreviewVersion.replace(/-preview.*/, ''); + } + + return { + releaseVersion, + npmTag: 'latest', + }; +} + +function getPreviewVersion(args) { + let releaseVersion; + if (args.preview_version_override) { + const overrideVersion = args.preview_version_override.replace(/^v/, ''); + validateVersion( + overrideVersion, + 'X.Y.Z-preview.N', + 'preview_version_override', + ); + releaseVersion = overrideVersion; + } else { + // Try to get from nightly, fallback to package.json for first release + const { latestVersion: latestNightlyVersion } = getAndVerifyTags('nightly'); + releaseVersion = + latestNightlyVersion.replace(/-nightly.*/, '') + '-preview.0'; + } + + return { + releaseVersion, + npmTag: 'preview', + }; +} + +export function getVersion(options = {}) { + const args = { ...getArgs(), ...options }; + const type = args.type || 'nightly'; + + let versionData; + switch (type) { + case 'nightly': + versionData = getNightlyVersion(); + if (doesVersionExist(versionData.releaseVersion)) { + throw new Error( + `Version conflict! Nightly version ${versionData.releaseVersion} already exists.`, + ); + } + break; + case 'stable': + versionData = getStableVersion(args); + break; + case 'preview': + versionData = getPreviewVersion(args); + break; + default: + throw new Error(`Unknown release type: ${type}`); + } + + // For stable and preview versions, check for existence and increment if needed. + if (type === 'stable' || type === 'preview') { + let releaseVersion = versionData.releaseVersion; + while (doesVersionExist(releaseVersion)) { + console.error(`Version ${releaseVersion} exists, incrementing.`); + if (releaseVersion.includes('-preview.')) { + const [version, prereleasePart] = releaseVersion.split('-'); + const previewNumber = parseInt(prereleasePart.split('.')[1]); + releaseVersion = `${version}-preview.${previewNumber + 1}`; + } else { + const versionParts = releaseVersion.split('.'); + const major = versionParts[0]; + const minor = versionParts[1]; + const patch = parseInt(versionParts[2]); + releaseVersion = `${major}.${minor}.${patch + 1}`; + } + } + versionData.releaseVersion = releaseVersion; + } + + const result = { + releaseTag: `v${versionData.releaseVersion}`, + ...versionData, + }; + + result.previousReleaseTag = getLatestStableReleaseTag(); + + return result; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const version = JSON.stringify(getVersion(getArgs()), null, 2); + console.log(version); +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts new file mode 100644 index 00000000..4ae46597 --- /dev/null +++ b/packages/sdk-typescript/src/index.ts @@ -0,0 +1,64 @@ +export { query } from './query/createQuery.js'; +export { AbortError, isAbortError } from './types/errors.js'; +export { Query } from './query/Query.js'; +export { SdkLogger } from './utils/logger.js'; + +// SDK MCP Server exports +export { tool } from './mcp/tool.js'; +export { createSdkMcpServer } from './mcp/createSdkMcpServer.js'; + +export type { SdkMcpToolDefinition } from './mcp/tool.js'; + +export type { + CreateSdkMcpServerOptions, + McpSdkServerConfigWithInstance, +} from './mcp/createSdkMcpServer.js'; + +export type { QueryOptions } from './query/createQuery.js'; +export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js'; + +export type { + ContentBlock, + TextBlock, + ThinkingBlock, + ToolUseBlock, + ToolResultBlock, + SDKUserMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKResultMessage, + SDKPartialAssistantMessage, + SDKMessage, + SDKMcpServerConfig, + ControlMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + SubagentConfig, + SubagentLevel, + ModelConfig, + RunConfig, +} from './types/protocol.js'; + +export { + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, +} from './types/protocol.js'; + +export type { + PermissionMode, + CanUseTool, + PermissionResult, + CLIMcpServerConfig, + McpServerConfig, + McpOAuthConfig, + McpAuthProviderType, +} from './types/types.js'; + +export { isSdkMcpServerConfig } from './types/types.js'; diff --git a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts new file mode 100644 index 00000000..28db7b2d --- /dev/null +++ b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts @@ -0,0 +1,105 @@ +/** + * SdkControlServerTransport - bridges MCP Server with Query's control plane + * + * Implements @modelcontextprotocol/sdk Transport interface to enable + * SDK-embedded MCP servers. Messages flow bidirectionally: + * + * MCP Server โ†’ send() โ†’ Query โ†’ control_request (mcp_message) โ†’ CLI + * CLI โ†’ control_request (mcp_message) โ†’ Query โ†’ handleMessage() โ†’ MCP Server + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { SdkLogger } from '../utils/logger.js'; + +export type SendToQueryCallback = (message: JSONRPCMessage) => Promise; + +export interface SdkControlServerTransportOptions { + sendToQuery: SendToQueryCallback; + serverName: string; +} + +export class SdkControlServerTransport { + sendToQuery: SendToQueryCallback; + private serverName: string; + private started = false; + private logger; + + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + constructor(options: SdkControlServerTransportOptions) { + this.sendToQuery = options.sendToQuery; + this.serverName = options.serverName; + this.logger = SdkLogger.createLogger( + `SdkControlServerTransport:${options.serverName}`, + ); + } + + async start(): Promise { + this.started = true; + this.logger.debug('Transport started'); + } + + async send(message: JSONRPCMessage): Promise { + if (!this.started) { + throw new Error( + `SdkControlServerTransport (${this.serverName}) not started. Call start() first.`, + ); + } + + try { + this.logger.debug('Sending message to Query', message); + await this.sendToQuery(message); + } catch (error) { + this.logger.error('Error sending message:', error); + if (this.onerror) { + this.onerror(error instanceof Error ? error : new Error(String(error))); + } + throw error; + } + } + + async close(): Promise { + if (!this.started) { + return; // Already closed + } + + this.started = false; + this.logger.debug('Transport closed'); + + // Notify MCP Server + if (this.onclose) { + this.onclose(); + } + } + + handleMessage(message: JSONRPCMessage): void { + if (!this.started) { + this.logger.warn('Received message for closed transport'); + return; + } + + this.logger.debug('Handling message from CLI', message); + if (this.onmessage) { + this.onmessage(message); + } else { + this.logger.warn('No onmessage handler set'); + } + } + + handleError(error: Error): void { + this.logger.error('Transport error:', error); + if (this.onerror) { + this.onerror(error); + } + } + + isStarted(): boolean { + return this.started; + } + + getServerName(): string { + return this.serverName; + } +} diff --git a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts new file mode 100644 index 00000000..cf2482d6 --- /dev/null +++ b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Factory function to create SDK-embedded MCP servers + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SdkMcpToolDefinition } from './tool.js'; +import { validateToolName } from './tool.js'; + +/** + * Options for creating an SDK MCP server + */ +export type CreateSdkMcpServerOptions = { + name: string; + version?: string; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + tools?: Array>; +}; + +/** + * SDK MCP Server configuration with instance + */ +export type McpSdkServerConfigWithInstance = { + type: 'sdk'; + name: string; + instance: McpServer; +}; + +/** + * Creates an MCP server instance that can be used with the SDK transport. + * + * @example + * ```typescript + * import { z } from 'zod'; + * import { tool, createSdkMcpServer } from '@qwen-code/sdk'; + * + * const calculatorTool = tool( + * 'calculate_sum', + * 'Add two numbers', + * { a: z.number(), b: z.number() }, + * async (args) => ({ content: [{ type: 'text', text: String(args.a + args.b) }] }) + * ); + * + * const server = createSdkMcpServer({ + * name: 'calculator', + * version: '1.0.0', + * tools: [calculatorTool], + * }); + * ``` + */ +export function createSdkMcpServer( + options: CreateSdkMcpServerOptions, +): McpSdkServerConfigWithInstance { + const { name, version = '1.0.0', tools } = options; + + if (!name || typeof name !== 'string') { + throw new Error('MCP server name must be a non-empty string'); + } + + if (!version || typeof version !== 'string') { + throw new Error('MCP server version must be a non-empty string'); + } + + if (tools !== undefined && !Array.isArray(tools)) { + throw new Error('Tools must be an array'); + } + + const toolNames = new Set(); + if (tools) { + for (const t of tools) { + validateToolName(t.name); + if (toolNames.has(t.name)) { + throw new Error( + `Duplicate tool name '${t.name}' in MCP server '${name}'`, + ); + } + toolNames.add(t.name); + } + } + + const server = new McpServer( + { name, version }, + { + capabilities: { + tools: tools ? {} : undefined, + }, + }, + ); + + if (tools) { + tools.forEach((toolDef) => { + server.tool( + toolDef.name, + toolDef.description, + toolDef.inputSchema, + toolDef.handler, + ); + }); + } + + return { type: 'sdk', name, instance: server }; +} diff --git a/packages/sdk-typescript/src/mcp/formatters.ts b/packages/sdk-typescript/src/mcp/formatters.ts new file mode 100644 index 00000000..a71e12ff --- /dev/null +++ b/packages/sdk-typescript/src/mcp/formatters.ts @@ -0,0 +1,194 @@ +/** + * Tool result formatting utilities for MCP responses + * + * Converts various output types to MCP content blocks. + */ + +export type McpContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; uri: string; mimeType?: string; text?: string }; + +export interface ToolResult { + content: McpContentBlock[]; + isError?: boolean; +} + +export function formatToolResult(result: unknown): ToolResult { + // Handle Error objects + if (result instanceof Error) { + return { + content: [ + { + type: 'text', + text: result.message || 'Unknown error', + }, + ], + isError: true, + }; + } + + // Handle null/undefined + if (result === null || result === undefined) { + return { + content: [ + { + type: 'text', + text: '', + }, + ], + }; + } + + // Handle string + if (typeof result === 'string') { + return { + content: [ + { + type: 'text', + text: result, + }, + ], + }; + } + + // Handle number + if (typeof result === 'number') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle boolean + if (typeof result === 'boolean') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle object (including arrays) + if (typeof result === 'object') { + try { + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch { + // JSON.stringify failed + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + } + + // Fallback: convert to string + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; +} + +export function formatToolError(error: Error | string): ToolResult { + const message = error instanceof Error ? error.message : error; + + return { + content: [ + { + type: 'text', + text: message, + }, + ], + isError: true, + }; +} + +export function formatTextResult(text: string): ToolResult { + return { + content: [ + { + type: 'text', + text, + }, + ], + }; +} + +export function formatJsonResult(data: unknown): ToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +export function mergeToolResults(results: ToolResult[]): ToolResult { + const mergedContent: McpContentBlock[] = []; + let hasError = false; + + for (const result of results) { + mergedContent.push(...result.content); + if (result.isError) { + hasError = true; + } + } + + return { + content: mergedContent, + isError: hasError, + }; +} + +export function isValidContentBlock(block: unknown): block is McpContentBlock { + if (!block || typeof block !== 'object') { + return false; + } + + const blockObj = block as Record; + + if (!blockObj.type || typeof blockObj.type !== 'string') { + return false; + } + + switch (blockObj.type) { + case 'text': + return typeof blockObj.text === 'string'; + + case 'image': + return ( + typeof blockObj.data === 'string' && + typeof blockObj.mimeType === 'string' + ); + + case 'resource': + return typeof blockObj.uri === 'string'; + + default: + return false; + } +} diff --git a/packages/sdk-typescript/src/mcp/tool.ts b/packages/sdk-typescript/src/mcp/tool.ts new file mode 100644 index 00000000..53e00399 --- /dev/null +++ b/packages/sdk-typescript/src/mcp/tool.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tool definition helper for SDK-embedded MCP servers + */ + +import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { z, ZodRawShape, ZodObject, ZodTypeAny } from 'zod'; + +type CallToolResult = z.infer; + +/** + * SDK MCP Tool Definition with Zod schema type inference + */ +export type SdkMcpToolDefinition = { + name: string; + description: string; + inputSchema: Schema; + handler: ( + args: z.infer>, + extra: unknown, + ) => Promise; +}; + +/** + * Create an SDK MCP tool definition with Zod schema inference + * + * @example + * ```typescript + * import { z } from 'zod'; + * import { tool } from '@qwen-code/sdk'; + * + * const calculatorTool = tool( + * 'calculate_sum', + * 'Calculate the sum of two numbers', + * { a: z.number(), b: z.number() }, + * async (args) => { + * // args is inferred as { a: number, b: number } + * return { content: [{ type: 'text', text: String(args.a + args.b) }] }; + * } + * ); + * ``` + */ +export function tool( + name: string, + description: string, + inputSchema: Schema, + handler: ( + args: z.infer>, + extra: unknown, + ) => Promise, +): SdkMcpToolDefinition { + if (!name || typeof name !== 'string') { + throw new Error('Tool name must be a non-empty string'); + } + + if (!description || typeof description !== 'string') { + throw new Error(`Tool '${name}' must have a description (string)`); + } + + if (!inputSchema || typeof inputSchema !== 'object') { + throw new Error(`Tool '${name}' must have an inputSchema (object)`); + } + + if (!handler || typeof handler !== 'function') { + throw new Error(`Tool '${name}' must have a handler (function)`); + } + + return { name, description, inputSchema, handler }; +} + +export function validateToolName(name: string): void { + if (!name) { + throw new Error('Tool name cannot be empty'); + } + + if (name.length > 64) { + throw new Error( + `Tool name '${name}' is too long (max 64 characters): ${name.length}`, + ); + } + + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) { + throw new Error( + `Tool name '${name}' is invalid. Must start with a letter and contain only letters, numbers, and underscores.`, + ); + } +} diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts new file mode 100644 index 00000000..78bb10b9 --- /dev/null +++ b/packages/sdk-typescript/src/query/Query.ts @@ -0,0 +1,880 @@ +/** + * Query class - Main orchestrator for SDK + * + * Manages SDK workflow, routes messages, and handles lifecycle. + * Implements AsyncIterator protocol for message consumption. + */ + +const DEFAULT_CAN_USE_TOOL_TIMEOUT = 60_000; +const DEFAULT_MCP_REQUEST_TIMEOUT = 60_000; +const DEFAULT_CONTROL_REQUEST_TIMEOUT = 60_000; +const DEFAULT_STREAM_CLOSE_TIMEOUT = 60_000; + +import { randomUUID } from 'node:crypto'; +import { SdkLogger } from '../utils/logger.js'; +import type { + SDKMessage, + SDKUserMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + PermissionSuggestion, + WireSDKMcpServerConfig, +} from '../types/protocol.js'; +import { + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, +} from '../types/protocol.js'; +import type { Transport } from '../transport/Transport.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { QueryOptions, CLIMcpServerConfig } from '../types/types.js'; +import { isSdkMcpServerConfig } from '../types/types.js'; +import { Stream } from '../utils/Stream.js'; +import { serializeJsonLine } from '../utils/jsonLines.js'; +import { AbortError } from '../types/errors.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { + SdkControlServerTransport, + type SdkControlServerTransportOptions, +} from '../mcp/SdkControlServerTransport.js'; +import { ControlRequestType } from '../types/protocol.js'; + +interface PendingControlRequest { + resolve: (response: Record | null) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + abortController: AbortController; +} + +interface PendingMcpResponse { + resolve: (response: JSONRPCMessage) => void; + reject: (error: Error) => void; +} + +interface TransportWithEndInput extends Transport { + endInput(): void; +} + +const logger = SdkLogger.createLogger('Query'); + +export class Query implements AsyncIterable { + private transport: Transport; + private options: QueryOptions; + private sessionId: string; + private inputStream: Stream; + private sdkMessages: AsyncGenerator; + private abortController: AbortController; + private pendingControlRequests: Map = + new Map(); + private pendingMcpResponses: Map = new Map(); + private sdkMcpTransports: Map = new Map(); + private sdkMcpServers: Map = new Map(); + readonly initialized: Promise; + private closed = false; + private messageRouterStarted = false; + + private firstResultReceivedPromise?: Promise; + private firstResultReceivedResolve?: () => void; + + private readonly isSingleTurn: boolean; + + constructor( + transport: Transport, + options: QueryOptions, + singleTurn: boolean = false, + ) { + this.transport = transport; + this.options = options; + this.sessionId = randomUUID(); + this.inputStream = new Stream(); + this.abortController = options.abortController ?? new AbortController(); + this.isSingleTurn = singleTurn; + + /** + * Create async generator proxy to ensure stream.next() is called at least once. + * The generator will start iterating when the user begins iteration. + * This ensures readResolve/readReject are set up as soon as iteration starts. + * If errors occur before iteration starts, they'll be stored in hasError and + * properly rejected when the user starts iterating. + */ + this.sdkMessages = this.readSdkMessages(); + + /** + * Promise that resolves when the first SDKResultMessage is received. + * Used to coordinate endInput() timing - ensures all initialization + * (SDK MCP servers, control responses) is complete before closing CLI stdin. + */ + this.firstResultReceivedPromise = new Promise((resolve) => { + this.firstResultReceivedResolve = resolve; + }); + + /** + * Handle abort signal if controller is provided and already aborted or will be aborted. + * If already aborted, set error immediately. Otherwise, listen for abort events + * and set abort error on the stream before closing. + */ + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted by user')); + this.close().catch((err) => { + logger.error('Error during abort cleanup:', err); + }); + } else { + this.abortController.signal.addEventListener('abort', () => { + this.inputStream.error(new AbortError('Query aborted by user')); + this.close().catch((err) => { + logger.error('Error during abort cleanup:', err); + }); + }); + } + + this.initialized = this.initialize(); + this.initialized.catch(() => {}); + + this.startMessageRouter(); + } + + private async initializeSdkMcpServers(): Promise { + if (!this.options.mcpServers) { + return; + } + + const connectionPromises: Array> = []; + + // Extract SDK MCP servers from the unified mcpServers config + for (const [key, config] of Object.entries(this.options.mcpServers)) { + if (!isSdkMcpServerConfig(config)) { + continue; // Skip external MCP servers + } + + // Use the name from SDKMcpServerConfig, fallback to key for backwards compatibility + const serverName = config.name || key; + const server = config.instance; + + // Create transport options with callback to route MCP server responses + const transportOptions: SdkControlServerTransportOptions = { + sendToQuery: async (message: JSONRPCMessage) => { + this.handleMcpServerResponse(serverName, message); + }, + serverName, + }; + + const sdkTransport = new SdkControlServerTransport(transportOptions); + + // Connect server to transport and only register on success + const connectionPromise = server + .connect(sdkTransport) + .then(() => { + // Only add to maps after successful connection + this.sdkMcpServers.set(serverName, server); + this.sdkMcpTransports.set(serverName, sdkTransport); + logger.debug(`SDK MCP server '${serverName}' connected to transport`); + }) + .catch((error) => { + logger.error( + `Failed to connect SDK MCP server '${serverName}' to transport:`, + error, + ); + // Don't throw - one failed server shouldn't prevent others + }); + + connectionPromises.push(connectionPromise); + } + + // Wait for all connection attempts to complete + await Promise.all(connectionPromises); + + if (this.sdkMcpServers.size > 0) { + logger.info( + `Initialized ${this.sdkMcpServers.size} SDK MCP server(s): ${Array.from(this.sdkMcpServers.keys()).join(', ')}`, + ); + } + } + + /** + * Handle response messages from SDK MCP servers + * + * When an MCP server sends a response via transport.send(), this callback + * routes it back to the pending request that's waiting for it. + */ + private handleMcpServerResponse( + serverName: string, + message: JSONRPCMessage, + ): void { + // Check if this is a response with an id + if ('id' in message && message.id !== null && message.id !== undefined) { + const key = `${serverName}:${message.id}`; + const pending = this.pendingMcpResponses.get(key); + if (pending) { + logger.debug( + `Routing MCP response for server '${serverName}', id: ${message.id}`, + ); + pending.resolve(message); + this.pendingMcpResponses.delete(key); + return; + } + } + + // If no pending request found, log a warning (this shouldn't happen normally) + logger.warn( + `Received MCP server response with no pending request: server='${serverName}'`, + message, + ); + } + + /** + * Get SDK MCP servers config for CLI initialization + * + * Only SDK servers are sent in the initialize request. + */ + private getSdkMcpServersForCli(): Record { + const sdkServers: Record = {}; + + for (const [name] of this.sdkMcpServers.entries()) { + sdkServers[name] = { type: 'sdk', name }; + } + + return sdkServers; + } + + /** + * Get external MCP servers (non-SDK) that should be managed by the CLI + */ + private getMcpServersForCli(): Record { + if (!this.options.mcpServers) { + return {}; + } + + const externalServers: Record = {}; + + for (const [name, config] of Object.entries(this.options.mcpServers)) { + if (isSdkMcpServerConfig(config)) { + continue; + } + externalServers[name] = config as CLIMcpServerConfig; + } + + return externalServers; + } + + private async initialize(): Promise { + try { + logger.debug('Initializing Query'); + + // Initialize SDK MCP servers and wait for connections + await this.initializeSdkMcpServers(); + + // Get only successfully connected SDK servers for CLI + const sdkMcpServersForCli = this.getSdkMcpServersForCli(); + const mcpServersForCli = this.getMcpServersForCli(); + logger.debug('SDK MCP servers for CLI:', sdkMcpServersForCli); + logger.debug('External MCP servers for CLI:', mcpServersForCli); + + await this.sendControlRequest(ControlRequestType.INITIALIZE, { + hooks: null, + sdkMcpServers: + Object.keys(sdkMcpServersForCli).length > 0 + ? sdkMcpServersForCli + : undefined, + mcpServers: + Object.keys(mcpServersForCli).length > 0 + ? mcpServersForCli + : undefined, + agents: this.options.agents, + }); + logger.info('Query initialized successfully'); + } catch (error) { + logger.error('Initialization error:', error); + throw error; + } + } + + private startMessageRouter(): void { + if (this.messageRouterStarted) { + return; + } + + this.messageRouterStarted = true; + + (async () => { + try { + for await (const message of this.transport.readMessages()) { + await this.routeMessage(message); + + if (this.closed) { + break; + } + } + + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted')); + } else { + this.inputStream.done(); + } + } catch (error) { + this.inputStream.error( + error instanceof Error ? error : new Error(String(error)), + ); + } + })(); + } + + private async routeMessage(message: unknown): Promise { + if (isControlRequest(message)) { + await this.handleControlRequest(message); + return; + } + + if (isControlResponse(message)) { + this.handleControlResponse(message); + return; + } + + if (isControlCancel(message)) { + this.handleControlCancelRequest(message); + return; + } + + if (isSDKSystemMessage(message)) { + /** + * SystemMessage contains session info (cwd, tools, model, etc.) + * that should be passed to user. + */ + this.inputStream.enqueue(message); + return; + } + + if (isSDKResultMessage(message)) { + if (this.firstResultReceivedResolve) { + this.firstResultReceivedResolve(); + } + /** + * In single-turn mode, automatically close input after receiving result + * to signal completion to the CLI. + */ + if (this.isSingleTurn && 'endInput' in this.transport) { + (this.transport as TransportWithEndInput).endInput(); + } + this.inputStream.enqueue(message); + return; + } + + if ( + isSDKAssistantMessage(message) || + isSDKUserMessage(message) || + isSDKPartialAssistantMessage(message) + ) { + this.inputStream.enqueue(message); + return; + } + + logger.warn('Unknown message type:', message); + this.inputStream.enqueue(message as SDKMessage); + } + + private async handleControlRequest( + request: CLIControlRequest, + ): Promise { + const { request_id, request: payload } = request; + + logger.debug(`Handling control request: ${payload.subtype}`); + const requestAbortController = new AbortController(); + + try { + let response: Record | null = null; + + switch (payload.subtype) { + case 'can_use_tool': + response = await this.handlePermissionRequest( + payload.tool_name, + payload.input as Record, + payload.permission_suggestions, + requestAbortController.signal, + ); + break; + + case 'mcp_message': + response = await this.handleMcpMessage( + payload.server_name, + payload.message as unknown as JSONRPCMessage, + ); + break; + + default: + throw new Error( + `Unknown control request subtype: ${payload.subtype}`, + ); + } + + await this.sendControlResponse(request_id, true, response); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.error(`Control request error (${payload.subtype}):`, errorMessage); + await this.sendControlResponse(request_id, false, errorMessage); + } + } + + private async handlePermissionRequest( + toolName: string, + toolInput: Record, + permissionSuggestions: PermissionSuggestion[] | null, + signal: AbortSignal, + ): Promise> { + /* Default deny all wildcard tool requests */ + if (!this.options.canUseTool) { + return { behavior: 'deny', message: 'Denied' }; + } + + try { + const canUseToolTimeout = + this.options.timeout?.canUseTool ?? DEFAULT_CAN_USE_TOOL_TIMEOUT; + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error('Permission callback timeout')), + canUseToolTimeout, + ); + }); + + const result = await Promise.race([ + Promise.resolve( + this.options.canUseTool(toolName, toolInput, { + signal, + suggestions: permissionSuggestions, + }), + ), + timeoutPromise, + ]); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (result.behavior === 'allow') { + return { + behavior: 'allow', + updatedInput: result.updatedInput ?? toolInput, + }; + } else { + return { + behavior: 'deny', + message: result.message ?? 'Denied', + ...(result.interrupt !== undefined + ? { interrupt: result.interrupt } + : {}), + }; + } + } catch (error) { + /** + * Timeout or error โ†’ deny (fail-safe). + * This ensures that any issues with the permission callback + * result in a safe default of denying access. + */ + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.warn( + 'Permission callback error (denying by default):', + errorMessage, + ); + return { + behavior: 'deny', + message: `Permission check failed: ${errorMessage}`, + }; + } + } + + private async handleMcpMessage( + serverName: string, + message: JSONRPCMessage, + ): Promise> { + const transport = this.sdkMcpTransports.get(serverName); + if (!transport) { + throw new Error( + `MCP server '${serverName}' not found in SDK-embedded servers`, + ); + } + + /** + * Check if this is a request (has method and id) or notification. + * Requests need to wait for a response, while notifications are just routed. + */ + const isRequest = + 'method' in message && 'id' in message && message.id !== null; + + if (isRequest) { + const response = await this.handleMcpRequest( + serverName, + message, + transport, + ); + return { mcp_response: response }; + } else { + transport.handleMessage(message); + return { mcp_response: { jsonrpc: '2.0', result: {}, id: 0 } }; + } + } + + private handleMcpRequest( + serverName: string, + message: JSONRPCMessage, + transport: SdkControlServerTransport, + ): Promise { + const messageId = 'id' in message ? message.id : null; + const key = `${serverName}:${messageId}`; + + return new Promise((resolve, reject) => { + const mcpRequestTimeout = + this.options.timeout?.mcpRequest ?? DEFAULT_MCP_REQUEST_TIMEOUT; + const timeout = setTimeout(() => { + this.pendingMcpResponses.delete(key); + reject(new Error('MCP request timeout')); + }, mcpRequestTimeout); + + const cleanup = () => { + clearTimeout(timeout); + this.pendingMcpResponses.delete(key); + }; + + const resolveAndCleanup = (response: JSONRPCMessage) => { + cleanup(); + resolve(response); + }; + + const rejectAndCleanup = (error: Error) => { + cleanup(); + reject(error); + }; + + // Register pending response handler + this.pendingMcpResponses.set(key, { + resolve: resolveAndCleanup, + reject: rejectAndCleanup, + }); + + // Deliver message to MCP server via transport.onmessage + // The server will process it and call transport.send() with the response, + // which triggers handleMcpServerResponse to resolve our pending promise + transport.handleMessage(message); + }); + } + + private handleControlResponse(response: CLIControlResponse): void { + const { response: payload } = response; + const request_id = payload.request_id; + + const pending = this.pendingControlRequests.get(request_id); + if (!pending) { + logger.warn( + 'Received response for unknown request:', + request_id, + JSON.stringify(payload), + ); + return; + } + + clearTimeout(pending.timeout); + this.pendingControlRequests.delete(request_id); + + if (payload.subtype === 'success') { + logger.debug( + `Control response success for request: ${request_id}: ${JSON.stringify(payload.response)}`, + ); + pending.resolve(payload.response as Record | null); + } else { + /** + * Extract error message from error field. + * Error can be either a string or an object with a message property. + */ + const errorMessage = + typeof payload.error === 'string' + ? payload.error + : (payload.error?.message ?? 'Unknown error'); + logger.error( + `Control response error for request ${request_id}:`, + errorMessage, + ); + pending.reject(new Error(errorMessage)); + } + } + + private handleControlCancelRequest(request: ControlCancelRequest): void { + const { request_id } = request; + + if (!request_id) { + logger.warn('Received cancel request without request_id'); + return; + } + + const pending = this.pendingControlRequests.get(request_id); + if (pending) { + logger.debug(`Cancelling control request: ${request_id}`); + pending.abortController.abort(); + clearTimeout(pending.timeout); + this.pendingControlRequests.delete(request_id); + pending.reject(new AbortError('Request cancelled')); + } + } + + private async sendControlRequest( + subtype: string, + data: Record = {}, + ): Promise | null> { + if (this.closed) { + return Promise.reject(new Error('Query is closed')); + } + + const requestId = randomUUID(); + + const request: CLIControlRequest = { + type: 'control_request', + request_id: requestId, + request: { + subtype: subtype as never, + ...data, + } as CLIControlRequest['request'], + }; + + const responsePromise = new Promise | null>( + (resolve, reject) => { + const abortController = new AbortController(); + const controlRequestTimeout = + this.options.timeout?.controlRequest ?? + DEFAULT_CONTROL_REQUEST_TIMEOUT; + const timeout = setTimeout(() => { + this.pendingControlRequests.delete(requestId); + reject(new Error(`Control request timeout: ${subtype}`)); + }, controlRequestTimeout); + + this.pendingControlRequests.set(requestId, { + resolve, + reject, + timeout, + abortController, + }); + }, + ); + + this.transport.write(serializeJsonLine(request)); + return responsePromise; + } + + private async sendControlResponse( + requestId: string, + success: boolean, + responseOrError: Record | null | string, + ): Promise { + const response: CLIControlResponse = { + type: 'control_response', + response: success + ? { + subtype: 'success', + request_id: requestId, + response: responseOrError as Record | null, + } + : { + subtype: 'error', + request_id: requestId, + error: responseOrError as string, + }, + }; + + this.transport.write(serializeJsonLine(response)); + } + + async close(): Promise { + if (this.closed) { + return; + } + + this.closed = true; + + for (const pending of this.pendingControlRequests.values()) { + pending.abortController.abort(); + clearTimeout(pending.timeout); + pending.reject(new Error('Query is closed')); + } + this.pendingControlRequests.clear(); + + // Clean up pending MCP responses + for (const pending of this.pendingMcpResponses.values()) { + pending.reject(new Error('Query is closed')); + } + this.pendingMcpResponses.clear(); + + await this.transport.close(); + + /** + * Complete input stream - check if aborted first. + * Only set error/done if stream doesn't already have an error state. + */ + if (this.inputStream.hasError === undefined) { + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted')); + } else { + this.inputStream.done(); + } + } + + for (const transport of this.sdkMcpTransports.values()) { + try { + await transport.close(); + } catch (error) { + logger.error('Error closing MCP transport:', error); + } + } + this.sdkMcpTransports.clear(); + logger.info('Query is closed'); + } + + private async *readSdkMessages(): AsyncGenerator { + for await (const message of this.inputStream) { + yield message; + } + } + + async next(...args: [] | [unknown]): Promise> { + return this.sdkMessages.next(...args); + } + + async return(value?: unknown): Promise> { + return this.sdkMessages.return(value); + } + + async throw(e?: unknown): Promise> { + return this.sdkMessages.throw(e); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this.sdkMessages; + } + + async streamInput(messages: AsyncIterable): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + try { + /** + * Wait for initialization to complete before sending messages. + * This prevents "write after end" errors when streamInput is called + * with an empty iterable before initialization finishes. + */ + await this.initialized; + + for await (const message of messages) { + if (this.abortController.signal.aborted) { + break; + } + this.transport.write(serializeJsonLine(message)); + } + + /** + * After all user messages are sent (for-await loop ended), determine when to + * close the CLI's stdin via endInput(). + * + * - If a result message was already received: All initialization (SDK MCP servers, + * control responses, etc.) is complete, safe to close stdin immediately. + * - If no result yet: Wait for either the result to arrive, or the timeout to expire. + * This gives pending control_responses from SDK MCP servers or other modules + * time to complete their initialization before we close the input stream. + * + * The timeout ensures we don't hang indefinitely - either the turn proceeds + * normally, or it fails with a timeout, but Promise.race will always resolve. + */ + if ( + !this.isSingleTurn && + this.sdkMcpTransports.size > 0 && + this.firstResultReceivedPromise + ) { + const streamCloseTimeout = + this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT; + let timeoutId: NodeJS.Timeout | undefined; + + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + logger.info('streamCloseTimeout resolved'); + resolve(); + }, streamCloseTimeout); + }); + + await Promise.race([this.firstResultReceivedPromise, timeoutPromise]); + + if (timeoutId) { + clearTimeout(timeoutId); + } + } + + this.endInput(); + } catch (error) { + if (this.abortController.signal.aborted) { + logger.info('Aborted during input streaming'); + this.inputStream.error( + new AbortError('Query aborted during input streaming'), + ); + return; + } + throw error; + } + } + + endInput(): void { + if (this.closed) { + throw new Error('Query is closed'); + } + + if ( + 'endInput' in this.transport && + typeof this.transport.endInput === 'function' + ) { + (this.transport as TransportWithEndInput).endInput(); + } + } + + async interrupt(): Promise { + await this.sendControlRequest(ControlRequestType.INTERRUPT); + } + + async setPermissionMode(mode: string): Promise { + await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, { + mode, + }); + } + + async setModel(model: string): Promise { + await this.sendControlRequest(ControlRequestType.SET_MODEL, { model }); + } + + /** + * Get list of control commands supported by the CLI + * + * @returns Promise resolving to list of supported command names + * @throws Error if query is closed + */ + async supportedCommands(): Promise | null> { + return this.sendControlRequest(ControlRequestType.SUPPORTED_COMMANDS); + } + + /** + * Get the status of MCP servers + * + * @returns Promise resolving to MCP server status information + * @throws Error if query is closed + */ + async mcpServerStatus(): Promise | null> { + return this.sendControlRequest(ControlRequestType.MCP_SERVER_STATUS); + } + + getSessionId(): string { + return this.sessionId; + } + + isClosed(): boolean { + return this.closed; + } +} diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts new file mode 100644 index 00000000..43ccf947 --- /dev/null +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -0,0 +1,120 @@ +/** + * Factory function for creating Query instances. + */ + +import type { SDKUserMessage } from '../types/protocol.js'; +import { serializeJsonLine } from '../utils/jsonLines.js'; +import { ProcessTransport } from '../transport/ProcessTransport.js'; +import { parseExecutableSpec } from '../utils/cliPath.js'; +import { Query } from './Query.js'; +import type { QueryOptions } from '../types/types.js'; +import { QueryOptionsSchema } from '../types/queryOptionsSchema.js'; +import { SdkLogger } from '../utils/logger.js'; + +export type { QueryOptions }; + +const logger = SdkLogger.createLogger('createQuery'); + +export function query({ + prompt, + options = {}, +}: { + /** + * The prompt to send to the Qwen Code CLI process. + * - `string` for single-turn query, + * - `AsyncIterable` for multi-turn query. + * + * The transport will remain open until the prompt is done. + */ + prompt: string | AsyncIterable; + /** + * Configuration options for the query. + */ + options?: QueryOptions; +}): Query { + const parsedExecutable = validateOptions(options); + + const isSingleTurn = typeof prompt === 'string'; + + const pathToQwenExecutable = + options.pathToQwenExecutable ?? parsedExecutable.executablePath; + + const abortController = options.abortController ?? new AbortController(); + + const transport = new ProcessTransport({ + pathToQwenExecutable, + cwd: options.cwd, + model: options.model, + permissionMode: options.permissionMode, + env: options.env, + abortController, + debug: options.debug, + stderr: options.stderr, + logLevel: options.logLevel, + maxSessionTurns: options.maxSessionTurns, + coreTools: options.coreTools, + excludeTools: options.excludeTools, + allowedTools: options.allowedTools, + authType: options.authType, + includePartialMessages: options.includePartialMessages, + }); + + const queryOptions: QueryOptions = { + ...options, + abortController, + }; + + const queryInstance = new Query(transport, queryOptions, isSingleTurn); + + if (isSingleTurn) { + const stringPrompt = prompt as string; + const message: SDKUserMessage = { + type: 'user', + session_id: queryInstance.getSessionId(), + message: { + role: 'user', + content: stringPrompt, + }, + parent_tool_use_id: null, + }; + + (async () => { + try { + await queryInstance.initialized; + transport.write(serializeJsonLine(message)); + } catch (err) { + logger.error('Error sending single-turn prompt:', err); + } + })(); + } else { + queryInstance + .streamInput(prompt as AsyncIterable) + .catch((err) => { + logger.error('Error streaming input:', err); + }); + } + + return queryInstance; +} + +function validateOptions( + options: QueryOptions, +): ReturnType { + const validationResult = QueryOptionsSchema.safeParse(options); + if (!validationResult.success) { + const errors = validationResult.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join('; '); + throw new Error(`Invalid QueryOptions: ${errors}`); + } + + let parsedExecutable: ReturnType; + try { + parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); + } + + return parsedExecutable; +} diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts new file mode 100644 index 00000000..43ff09da --- /dev/null +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -0,0 +1,354 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import * as readline from 'node:readline'; +import type { Writable, Readable } from 'node:stream'; +import type { TransportOptions } from '../types/types.js'; +import type { Transport } from './Transport.js'; +import { parseJsonLinesStream } from '../utils/jsonLines.js'; +import { prepareSpawnInfo } from '../utils/cliPath.js'; +import { AbortError } from '../types/errors.js'; +import { SdkLogger } from '../utils/logger.js'; + +const logger = SdkLogger.createLogger('ProcessTransport'); + +export class ProcessTransport implements Transport { + private childProcess: ChildProcess | null = null; + private childStdin: Writable | null = null; + private childStdout: Readable | null = null; + private options: TransportOptions; + private ready = false; + private _exitError: Error | null = null; + private closed = false; + private abortController: AbortController; + private processExitHandler: (() => void) | null = null; + private abortHandler: (() => void) | null = null; + + constructor(options: TransportOptions) { + this.options = options; + this.abortController = + this.options.abortController ?? new AbortController(); + SdkLogger.configure({ + debug: options.debug, + stderr: options.stderr, + logLevel: options.logLevel, + }); + this.initialize(); + } + + private initialize(): void { + try { + if (this.abortController.signal.aborted) { + throw new AbortError('Transport start aborted'); + } + + const cliArgs = this.buildCliArguments(); + const cwd = this.options.cwd ?? process.cwd(); + const env = { ...process.env, ...this.options.env }; + + const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable); + + const stderrMode = + this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; + + logger.debug( + `Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`, + ); + + this.childProcess = spawn( + spawnInfo.command, + [...spawnInfo.args, ...cliArgs], + { + cwd, + env, + stdio: ['pipe', 'pipe', stderrMode], + signal: this.abortController.signal, + }, + ); + + this.childStdin = this.childProcess.stdin; + this.childStdout = this.childProcess.stdout; + + if (this.options.debug || this.options.stderr) { + this.childProcess.stderr?.on('data', (data) => { + logger.debug(data.toString()); + }); + } + + const cleanup = (): void => { + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGTERM'); + } + }; + + this.processExitHandler = cleanup; + this.abortHandler = cleanup; + process.on('exit', this.processExitHandler); + this.abortController.signal.addEventListener('abort', this.abortHandler); + + this.setupEventHandlers(); + + this.ready = true; + logger.info('CLI process started successfully'); + } catch (error) { + this.ready = false; + logger.error('Failed to initialize CLI process:', error); + throw error; + } + } + + private setupEventHandlers(): void { + if (!this.childProcess) return; + + this.childProcess.on('error', (error) => { + this.ready = false; + if (this.abortController.signal.aborted) { + this._exitError = new AbortError('CLI process aborted by user'); + } else { + this._exitError = new Error(`CLI process error: ${error.message}`); + logger.error(this._exitError.message); + } + }); + + this.childProcess.on('close', (code, signal) => { + this.ready = false; + if (this.abortController.signal.aborted) { + this._exitError = new AbortError('CLI process aborted by user'); + } else { + const error = this.getProcessExitError(code, signal); + if (error) { + this._exitError = error; + logger.error(error.message); + } + } + }); + } + + private getProcessExitError( + code: number | null, + signal: NodeJS.Signals | null, + ): Error | undefined { + if (code !== 0 && code !== null) { + return new Error(`CLI process exited with code ${code}`); + } else if (signal) { + return new Error(`CLI process terminated by signal ${signal}`); + } + return undefined; + } + private buildCliArguments(): string[] { + const args: string[] = [ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--channel=SDK', + ]; + + if (this.options.model) { + args.push('--model', this.options.model); + } + + if (this.options.permissionMode) { + args.push('--approval-mode', this.options.permissionMode); + } + + if (this.options.maxSessionTurns !== undefined) { + args.push('--max-session-turns', String(this.options.maxSessionTurns)); + } + + if (this.options.coreTools && this.options.coreTools.length > 0) { + args.push('--core-tools', this.options.coreTools.join(',')); + } + + if (this.options.excludeTools && this.options.excludeTools.length > 0) { + args.push('--exclude-tools', this.options.excludeTools.join(',')); + } + + if (this.options.allowedTools && this.options.allowedTools.length > 0) { + args.push('--allowed-tools', this.options.allowedTools.join(',')); + } + + if (this.options.authType) { + args.push('--auth-type', this.options.authType); + } + + if (this.options.includePartialMessages) { + args.push('--include-partial-messages'); + } + + return args; + } + + async close(): Promise { + if (this.childStdin) { + this.childStdin.end(); + this.childStdin = null; + } + + if (this.processExitHandler) { + process.off('exit', this.processExitHandler); + this.processExitHandler = null; + } + + if (this.abortHandler) { + this.abortController.signal.removeEventListener( + 'abort', + this.abortHandler, + ); + this.abortHandler = null; + } + + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGTERM'); + setTimeout(() => { + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGKILL'); + } + }, 5000); + } + + this.ready = false; + this.closed = true; + } + + async waitForExit(): Promise { + if (!this.childProcess) { + if (this._exitError) { + throw this._exitError; + } + return; + } + + if (this.childProcess.exitCode !== null || this.childProcess.killed) { + if (this._exitError) { + throw this._exitError; + } + return; + } + + return new Promise((resolve, reject) => { + const exitHandler = ( + code: number | null, + signal: NodeJS.Signals | null, + ) => { + if (this.abortController.signal.aborted) { + reject(new AbortError('Operation aborted')); + return; + } + + const error = this.getProcessExitError(code, signal); + if (error) { + reject(error); + } else { + resolve(); + } + }; + + this.childProcess!.once('close', exitHandler); + + const errorHandler = (error: Error) => { + this.childProcess!.off('close', exitHandler); + reject(error); + }; + + this.childProcess!.once('error', errorHandler); + + this.childProcess!.once('close', () => { + this.childProcess!.off('error', errorHandler); + }); + }); + } + + write(message: string): void { + if (this.abortController.signal.aborted) { + throw new AbortError('Cannot write: operation aborted'); + } + + if (!this.ready || !this.childStdin) { + throw new Error('Transport not ready for writing'); + } + + if (this.closed) { + throw new Error('Cannot write to closed transport'); + } + + if (this.childStdin.writableEnded) { + throw new Error('Cannot write to ended stream'); + } + + if (this.childProcess?.killed || this.childProcess?.exitCode !== null) { + throw new Error('Cannot write to terminated process'); + } + + if (this._exitError) { + throw new Error( + `Cannot write to process that exited with error: ${this._exitError.message}`, + ); + } + + logger.debug( + `Writing to stdin (${message.length} bytes): ${message.trim()}`, + ); + + try { + const written = this.childStdin.write(message); + if (!written) { + logger.warn( + `Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, + ); + } else { + logger.debug(`Write successful (${message.length} bytes)`); + } + } catch (error) { + this.ready = false; + const errorMsg = `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`; + logger.error(errorMsg); + throw new Error(errorMsg); + } + } + + async *readMessages(): AsyncGenerator { + if (!this.childStdout) { + throw new Error('Cannot read messages: process not started'); + } + + const rl = readline.createInterface({ + input: this.childStdout, + crlfDelay: Infinity, + terminal: false, + }); + + try { + for await (const message of parseJsonLinesStream( + rl, + 'ProcessTransport', + )) { + yield message; + } + + await this.waitForExit(); + } finally { + rl.close(); + } + } + + get isReady(): boolean { + return this.ready; + } + + get exitError(): Error | null { + return this._exitError; + } + + endInput(): void { + if (this.childStdin) { + this.childStdin.end(); + } + } + + getInputStream(): Writable | undefined { + return this.childStdin || undefined; + } + + getOutputStream(): Readable | undefined { + return this.childStdout || undefined; + } +} diff --git a/packages/sdk-typescript/src/transport/Transport.ts b/packages/sdk-typescript/src/transport/Transport.ts new file mode 100644 index 00000000..cbfb1b7a --- /dev/null +++ b/packages/sdk-typescript/src/transport/Transport.ts @@ -0,0 +1,22 @@ +/** + * Transport interface for SDK-CLI communication + * + * The Transport abstraction enables communication between SDK and CLI via different mechanisms: + * - ProcessTransport: Local subprocess via stdin/stdout (initial implementation) + * - HttpTransport: Remote CLI via HTTP (future) + * - WebSocketTransport: Remote CLI via WebSocket (future) + */ + +export interface Transport { + close(): Promise; + + waitForExit(): Promise; + + write(message: string): void; + + readMessages(): AsyncGenerator; + + readonly isReady: boolean; + + readonly exitError: Error | null; +} diff --git a/packages/sdk-typescript/src/types/errors.ts b/packages/sdk-typescript/src/types/errors.ts new file mode 100644 index 00000000..21f503a6 --- /dev/null +++ b/packages/sdk-typescript/src/types/errors.ts @@ -0,0 +1,17 @@ +export class AbortError extends Error { + constructor(message = 'Operation aborted') { + super(message); + this.name = 'AbortError'; + Object.setPrototypeOf(this, AbortError.prototype); + } +} + +export function isAbortError(error: unknown): error is AbortError { + return ( + error instanceof AbortError || + (typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError') + ); +} diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts new file mode 100644 index 00000000..e5eeb121 --- /dev/null +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -0,0 +1,594 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +export interface Annotation { + type: string; + value: string; +} + +export interface Usage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + total_tokens?: number; +} + +export interface ExtendedUsage extends Usage { + server_tool_use?: { + web_search_requests: number; + }; + service_tier?: string; + cache_creation?: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + }; +} + +export interface ModelUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + webSearchRequests: number; + contextWindow: number; +} + +export interface CLIPermissionDenial { + tool_name: string; + tool_use_id: string; + tool_input: unknown; +} + +export interface TextBlock { + type: 'text'; + text: string; + annotations?: Annotation[]; +} + +export interface ThinkingBlock { + type: 'thinking'; + thinking: string; + signature?: string; + annotations?: Annotation[]; +} + +export interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: unknown; + annotations?: Annotation[]; +} + +export interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content?: string | ContentBlock[]; + is_error?: boolean; + annotations?: Annotation[]; +} + +export type ContentBlock = + | TextBlock + | ThinkingBlock + | ToolUseBlock + | ToolResultBlock; + +export interface APIUserMessage { + role: 'user'; + content: string | ContentBlock[]; +} + +export interface APIAssistantMessage { + id: string; + type: 'message'; + role: 'assistant'; + model: string; + content: ContentBlock[]; + stop_reason?: string | null; + usage: Usage; +} + +export interface SDKUserMessage { + type: 'user'; + uuid?: string; + session_id: string; + message: APIUserMessage; + parent_tool_use_id: string | null; + options?: Record; +} + +export interface SDKAssistantMessage { + type: 'assistant'; + uuid: string; + session_id: string; + message: APIAssistantMessage; + parent_tool_use_id: string | null; +} + +export interface SDKSystemMessage { + type: 'system'; + subtype: string; + uuid: string; + session_id: string; + data?: unknown; + cwd?: string; + tools?: string[]; + mcp_servers?: Array<{ + name: string; + status: string; + }>; + model?: string; + permission_mode?: string; + slash_commands?: string[]; + qwen_code_version?: string; + output_style?: string; + agents?: string[]; + skills?: string[]; + capabilities?: Record; + compact_metadata?: { + trigger: 'manual' | 'auto'; + pre_tokens: number; + }; +} + +export interface SDKResultMessageSuccess { + type: 'result'; + subtype: 'success'; + uuid: string; + session_id: string; + is_error: false; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + result: string; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + [key: string]: unknown; +} + +export interface SDKResultMessageError { + type: 'result'; + subtype: 'error_max_turns' | 'error_during_execution'; + uuid: string; + session_id: string; + is_error: true; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + error?: { + type?: string; + message: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type SDKResultMessage = SDKResultMessageSuccess | SDKResultMessageError; + +export interface MessageStartStreamEvent { + type: 'message_start'; + message: { + id: string; + role: 'assistant'; + model: string; + }; +} + +export interface ContentBlockStartEvent { + type: 'content_block_start'; + index: number; + content_block: ContentBlock; +} + +export type ContentBlockDelta = + | { + type: 'text_delta'; + text: string; + } + | { + type: 'thinking_delta'; + thinking: string; + } + | { + type: 'input_json_delta'; + partial_json: string; + }; + +export interface ContentBlockDeltaEvent { + type: 'content_block_delta'; + index: number; + delta: ContentBlockDelta; +} + +export interface ContentBlockStopEvent { + type: 'content_block_stop'; + index: number; +} + +export interface MessageStopStreamEvent { + type: 'message_stop'; +} + +export type StreamEvent = + | MessageStartStreamEvent + | ContentBlockStartEvent + | ContentBlockDeltaEvent + | ContentBlockStopEvent + | MessageStopStreamEvent; + +export interface SDKPartialAssistantMessage { + type: 'stream_event'; + uuid: string; + session_id: string; + event: StreamEvent; + parent_tool_use_id: string | null; +} + +export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo'; + +/** + * TODO: Align with `ToolCallConfirmationDetails` + */ +export interface PermissionSuggestion { + type: 'allow' | 'deny' | 'modify'; + label: string; + description?: string; + modifiedInput?: unknown; +} + +export interface HookRegistration { + event: string; + callback_id: string; +} + +export interface HookCallbackResult { + shouldSkip?: boolean; + shouldInterrupt?: boolean; + suppressOutput?: boolean; + message?: string; +} + +export interface CLIControlInterruptRequest { + subtype: 'interrupt'; +} + +export interface CLIControlPermissionRequest { + subtype: 'can_use_tool'; + tool_name: string; + tool_use_id: string; + input: unknown; + permission_suggestions: PermissionSuggestion[] | null; + blocked_path: string | null; +} + +export enum AuthProviderType { + DYNAMIC_DISCOVERY = 'dynamic_discovery', + GOOGLE_CREDENTIALS = 'google_credentials', + SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation', +} + +export interface MCPServerConfig { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + url?: string; + httpUrl?: string; + headers?: Record; + tcp?: string; + timeout?: number; + trust?: boolean; + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + extensionName?: string; + oauth?: Record; + authProviderType?: AuthProviderType; + targetAudience?: string; + targetServiceAccount?: string; +} + +/** + * SDK MCP Server configuration + * + * SDK MCP servers run in the SDK process and are connected via in-memory transport. + * Tool calls are routed through the control plane between SDK and CLI. + */ +export interface SDKMcpServerConfig { + /** + * Type identifier for SDK MCP servers + */ + type: 'sdk'; + /** + * Server name for identification and routing + */ + name: string; + /** + * The MCP Server instance created by createSdkMcpServer() + */ + instance: McpServer; +} + +/** + * Wire format for SDK MCP servers sent to the CLI + */ +export type WireSDKMcpServerConfig = Omit; + +export interface CLIControlInitializeRequest { + subtype: 'initialize'; + hooks?: HookRegistration[] | null; + /** + * SDK MCP servers config + * These are MCP servers running in the SDK process, connected via control plane. + * External MCP servers are configured separately in settings, not via initialization. + */ + sdkMcpServers?: Record; + /** + * External MCP servers that should be managed by the CLI. + */ + mcpServers?: Record; + agents?: SubagentConfig[]; +} + +export interface CLIControlSetPermissionModeRequest { + subtype: 'set_permission_mode'; + mode: PermissionMode; +} + +export interface CLIHookCallbackRequest { + subtype: 'hook_callback'; + callback_id: string; + input: unknown; + tool_use_id: string | null; +} + +export interface CLIControlMcpMessageRequest { + subtype: 'mcp_message'; + server_name: string; + message: { + jsonrpc?: string; + method: string; + params?: Record; + id?: string | number | null; + }; +} + +export interface CLIControlSetModelRequest { + subtype: 'set_model'; + model: string; +} + +export interface CLIControlMcpStatusRequest { + subtype: 'mcp_server_status'; +} + +export interface CLIControlSupportedCommandsRequest { + subtype: 'supported_commands'; +} + +export type ControlRequestPayload = + | CLIControlInterruptRequest + | CLIControlPermissionRequest + | CLIControlInitializeRequest + | CLIControlSetPermissionModeRequest + | CLIHookCallbackRequest + | CLIControlMcpMessageRequest + | CLIControlSetModelRequest + | CLIControlMcpStatusRequest + | CLIControlSupportedCommandsRequest; + +export interface CLIControlRequest { + type: 'control_request'; + request_id: string; + request: ControlRequestPayload; +} + +export interface PermissionApproval { + allowed: boolean; + reason?: string; + modifiedInput?: unknown; +} + +export interface ControlResponse { + subtype: 'success'; + request_id: string; + response: unknown; +} + +export interface ControlErrorResponse { + subtype: 'error'; + request_id: string; + error: string | { message: string; [key: string]: unknown }; +} + +export interface CLIControlResponse { + type: 'control_response'; + response: ControlResponse | ControlErrorResponse; +} + +export interface ControlCancelRequest { + type: 'control_cancel_request'; + request_id?: string; +} + +export type ControlMessage = + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; + +/** + * Union of all SDK message types + */ +export type SDKMessage = + | SDKUserMessage + | SDKAssistantMessage + | SDKSystemMessage + | SDKResultMessage + | SDKPartialAssistantMessage; + +export function isSDKUserMessage(msg: any): msg is SDKUserMessage { + return ( + msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg + ); +} + +export function isSDKAssistantMessage(msg: any): msg is SDKAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'assistant' && + 'uuid' in msg && + 'message' in msg && + 'session_id' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isSDKSystemMessage(msg: any): msg is SDKSystemMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'system' && + 'subtype' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isSDKResultMessage(msg: any): msg is SDKResultMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'result' && + 'subtype' in msg && + 'duration_ms' in msg && + 'is_error' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isSDKPartialAssistantMessage( + msg: any, +): msg is SDKPartialAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'stream_event' && + 'uuid' in msg && + 'session_id' in msg && + 'event' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isControlRequest(msg: any): msg is CLIControlRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_request' && + 'request_id' in msg && + 'request' in msg + ); +} + +export function isControlResponse(msg: any): msg is CLIControlResponse { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_response' && + 'response' in msg + ); +} + +export function isControlCancel(msg: any): msg is ControlCancelRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_cancel_request' && + 'request_id' in msg + ); +} + +export function isTextBlock(block: any): block is TextBlock { + return block && typeof block === 'object' && block.type === 'text'; +} + +export function isThinkingBlock(block: any): block is ThinkingBlock { + return block && typeof block === 'object' && block.type === 'thinking'; +} + +export function isToolUseBlock(block: any): block is ToolUseBlock { + return block && typeof block === 'object' && block.type === 'tool_use'; +} + +export function isToolResultBlock(block: any): block is ToolResultBlock { + return block && typeof block === 'object' && block.type === 'tool_result'; +} + +export type SubagentLevel = 'session'; + +export interface ModelConfig { + model?: string; + temp?: number; + top_p?: number; +} + +export interface RunConfig { + max_time_minutes?: number; + max_turns?: number; +} + +export interface SubagentConfig { + name: string; + description: string; + tools?: string[]; + systemPrompt: string; + level: SubagentLevel; + filePath?: string; + modelConfig?: Partial; + runConfig?: Partial; + color?: string; + readonly isBuiltin?: boolean; +} + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Request Types + * + * Centralized enum for all control request subtypes supported by the CLI. + * This enum should be kept in sync with the controllers in: + * - packages/cli/src/services/control/controllers/systemController.ts + * - packages/cli/src/services/control/controllers/permissionController.ts + * - packages/cli/src/services/control/controllers/mcpController.ts + * - packages/cli/src/services/control/controllers/hookController.ts + */ +export enum ControlRequestType { + // SystemController requests + INITIALIZE = 'initialize', + INTERRUPT = 'interrupt', + SET_MODEL = 'set_model', + SUPPORTED_COMMANDS = 'supported_commands', + + // PermissionController requests + CAN_USE_TOOL = 'can_use_tool', + SET_PERMISSION_MODE = 'set_permission_mode', + + // MCPController requests + MCP_MESSAGE = 'mcp_message', + MCP_SERVER_STATUS = 'mcp_server_status', + + // HookController requests + HOOK_CALLBACK = 'hook_callback', +} diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts new file mode 100644 index 00000000..a4794b3f --- /dev/null +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -0,0 +1,169 @@ +import { z } from 'zod'; +import type { CanUseTool } from './types.js'; +import type { SubagentConfig } from './protocol.js'; + +/** + * OAuth configuration for MCP servers + */ +export const McpOAuthConfigSchema = z + .object({ + enabled: z.boolean().optional(), + clientId: z + .string() + .min(1, 'clientId must be a non-empty string') + .optional(), + clientSecret: z.string().optional(), + scopes: z.array(z.string()).optional(), + redirectUri: z.string().optional(), + authorizationUrl: z.string().optional(), + tokenUrl: z.string().optional(), + audiences: z.array(z.string()).optional(), + tokenParamName: z.string().optional(), + registrationUrl: z.string().optional(), + }) + .strict(); + +/** + * CLI MCP Server configuration schema + * + * Supports multiple transport types: + * - stdio: command, args, env, cwd + * - SSE: url + * - Streamable HTTP: httpUrl, headers + * - WebSocket: tcp + */ +export const CLIMcpServerConfigSchema = z.object({ + // For stdio transport + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + cwd: z.string().optional(), + // For SSE transport + url: z.string().optional(), + // For streamable HTTP transport + httpUrl: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + // For WebSocket transport + tcp: z.string().optional(), + // Common + timeout: z.number().optional(), + trust: z.boolean().optional(), + // Metadata + description: z.string().optional(), + includeTools: z.array(z.string()).optional(), + excludeTools: z.array(z.string()).optional(), + extensionName: z.string().optional(), + // OAuth configuration + oauth: McpOAuthConfigSchema.optional(), + authProviderType: z + .enum([ + 'dynamic_discovery', + 'google_credentials', + 'service_account_impersonation', + ]) + .optional(), + // Service Account Configuration + targetAudience: z.string().optional(), + targetServiceAccount: z.string().optional(), +}); + +/** + * SDK MCP Server configuration schema + */ +export const SdkMcpServerConfigSchema = z.object({ + type: z.literal('sdk'), + name: z.string().min(1, 'name must be a non-empty string'), + instance: z.custom<{ + connect(transport: unknown): Promise; + close(): Promise; + }>( + (val) => + val && + typeof val === 'object' && + 'connect' in val && + typeof val.connect === 'function', + { message: 'instance must be an MCP Server with connect method' }, + ), +}); + +/** + * Unified MCP Server configuration schema + */ +export const McpServerConfigSchema = z.union([ + CLIMcpServerConfigSchema, + SdkMcpServerConfigSchema, +]); + +export const ModelConfigSchema = z.object({ + model: z.string().optional(), + temp: z.number().optional(), + top_p: z.number().optional(), +}); + +export const RunConfigSchema = z.object({ + max_time_minutes: z.number().optional(), + max_turns: z.number().optional(), +}); + +export const SubagentConfigSchema = z.object({ + name: z.string().min(1, 'Name must be a non-empty string'), + description: z.string().min(1, 'Description must be a non-empty string'), + tools: z.array(z.string()).optional(), + systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'), + modelConfig: ModelConfigSchema.partial().optional(), + runConfig: RunConfigSchema.partial().optional(), + color: z.string().optional(), + isBuiltin: z.boolean().optional(), +}); + +export const TimeoutConfigSchema = z.object({ + canUseTool: z.number().positive().optional(), + mcpRequest: z.number().positive().optional(), + controlRequest: z.number().positive().optional(), + streamClose: z.number().positive().optional(), +}); + +export const QueryOptionsSchema = z + .object({ + cwd: z.string().optional(), + model: z.string().optional(), + pathToQwenExecutable: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + permissionMode: z.enum(['default', 'plan', 'auto-edit', 'yolo']).optional(), + canUseTool: z + .custom((val) => typeof val === 'function', { + message: 'canUseTool must be a function', + }) + .optional(), + mcpServers: z.record(z.string(), McpServerConfigSchema).optional(), + abortController: z.instanceof(AbortController).optional(), + debug: z.boolean().optional(), + stderr: z + .custom< + (message: string) => void + >((val) => typeof val === 'function', { message: 'stderr must be a function' }) + .optional(), + logLevel: z.enum(['debug', 'info', 'warn', 'error']).optional(), + maxSessionTurns: z.number().optional(), + coreTools: z.array(z.string()).optional(), + excludeTools: z.array(z.string()).optional(), + allowedTools: z.array(z.string()).optional(), + authType: z.enum(['openai', 'qwen-oauth']).optional(), + agents: z + .array( + z.custom( + (val) => + val && + typeof val === 'object' && + 'name' in val && + 'description' in val && + 'systemPrompt' in val && { + message: 'agents must be an array of SubagentConfig objects', + }, + ), + ) + .optional(), + includePartialMessages: z.boolean().optional(), + timeout: TimeoutConfigSchema.optional(), + }) + .strict(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts new file mode 100644 index 00000000..24dc0575 --- /dev/null +++ b/packages/sdk-typescript/src/types/types.ts @@ -0,0 +1,448 @@ +import type { + PermissionMode, + PermissionSuggestion, + SubagentConfig, + SDKMcpServerConfig, +} from './protocol.js'; + +export type { PermissionMode }; + +export type TransportOptions = { + pathToQwenExecutable: string; + cwd?: string; + model?: string; + permissionMode?: PermissionMode; + env?: Record; + abortController?: AbortController; + debug?: boolean; + stderr?: (message: string) => void; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + maxSessionTurns?: number; + coreTools?: string[]; + excludeTools?: string[]; + allowedTools?: string[]; + authType?: string; + includePartialMessages?: boolean; +}; + +type ToolInput = Record; + +export type CanUseTool = ( + toolName: string, + input: ToolInput, + options: { + signal: AbortSignal; + suggestions?: PermissionSuggestion[] | null; + }, +) => Promise; + +export type PermissionResult = + | { + behavior: 'allow'; + updatedInput: ToolInput; + } + | { + behavior: 'deny'; + message: string; + interrupt?: boolean; + }; + +/** + * OAuth configuration for MCP servers + */ +export interface McpOAuthConfig { + enabled?: boolean; + clientId?: string; + clientSecret?: string; + scopes?: string[]; + redirectUri?: string; + authorizationUrl?: string; + tokenUrl?: string; + audiences?: string[]; + tokenParamName?: string; + registrationUrl?: string; +} + +/** + * Auth provider type for MCP servers + */ +export type McpAuthProviderType = + | 'dynamic_discovery' + | 'google_credentials' + | 'service_account_impersonation'; + +/** + * CLI MCP Server configuration + * + * Supports multiple transport types: + * - stdio: command, args, env, cwd + * - SSE: url + * - Streamable HTTP: httpUrl, headers + * - WebSocket: tcp + * + * This interface aligns with MCPServerConfig in @qwen-code/qwen-code-core. + */ +export interface CLIMcpServerConfig { + // For stdio transport + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + // For SSE transport + url?: string; + // For streamable HTTP transport + httpUrl?: string; + headers?: Record; + // For WebSocket transport + tcp?: string; + // Common + timeout?: number; + trust?: boolean; + // Metadata + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + extensionName?: string; + // OAuth configuration + oauth?: McpOAuthConfig; + authProviderType?: McpAuthProviderType; + // Service Account Configuration + /** targetAudience format: CLIENT_ID.apps.googleusercontent.com */ + targetAudience?: string; + /** targetServiceAccount format: @.iam.gserviceaccount.com */ + targetServiceAccount?: string; +} + +/** + * Unified MCP Server configuration + * + * Supports both external MCP servers (stdio/SSE/HTTP/WebSocket) and SDK-embedded MCP servers. + * + * @example External MCP server (stdio) + * ```typescript + * mcpServers: { + * 'my-server': { command: 'node', args: ['server.js'] } + * } + * ``` + * + * @example External MCP server (SSE) + * ```typescript + * mcpServers: { + * 'remote-server': { url: 'http://localhost:3000/sse' } + * } + * ``` + * + * @example External MCP server (Streamable HTTP) + * ```typescript + * mcpServers: { + * 'http-server': { httpUrl: 'http://localhost:3000/mcp', headers: { 'Authorization': 'Bearer token' } } + * } + * ``` + * + * @example SDK MCP server + * ```typescript + * const server = createSdkMcpServer('weather', '1.0.0', [weatherTool]); + * mcpServers: { + * 'weather': { type: 'sdk', name: 'weather', instance: server } + * } + * ``` + */ +export type McpServerConfig = CLIMcpServerConfig | SDKMcpServerConfig; + +/** + * Type guard to check if a config is an SDK MCP server + */ +export function isSdkMcpServerConfig( + config: McpServerConfig, +): config is SDKMcpServerConfig { + return 'type' in config && config.type === 'sdk'; +} + +/** + * Configuration options for creating a query session with the Qwen CLI. + */ +export interface QueryOptions { + /** + * The working directory for the query session. + * This determines the context in which file operations and commands are executed. + * @default process.cwd() + */ + cwd?: string; + + /** + * The AI model to use for the query session. + * This takes precedence over the environment variables `OPENAI_MODEL` and `QWEN_MODEL` + * @example 'qwen-max', 'qwen-plus', 'qwen-turbo' + */ + model?: string; + + /** + * Path to the Qwen CLI executable or runtime specification. + * + * Supports multiple formats: + * - 'qwen' -> native binary (auto-detected from PATH) + * - '/path/to/qwen' -> native binary (explicit path) + * - '/path/to/cli.js' -> Node.js bundle (default for .js files) + * - '/path/to/index.ts' -> TypeScript source (requires tsx) + * - 'bun:/path/to/cli.js' -> Force Bun runtime + * - 'node:/path/to/cli.js' -> Force Node.js runtime + * - 'tsx:/path/to/index.ts' -> Force tsx runtime + * - 'deno:/path/to/cli.ts' -> Force Deno runtime + * + * If not provided, the SDK will auto-detect the native binary in this order: + * 1. QWEN_CODE_CLI_PATH environment variable + * 2. ~/.volta/bin/qwen + * 3. ~/.npm-global/bin/qwen + * 4. /usr/local/bin/qwen + * 5. ~/.local/bin/qwen + * 6. ~/node_modules/.bin/qwen + * 7. ~/.yarn/bin/qwen + * + * The .ts files are only supported for debugging purposes. + * + * @example 'qwen' + * @example '/usr/local/bin/qwen' + * @example 'tsx:/path/to/packages/cli/src/index.ts' + */ + pathToQwenExecutable?: string; + + /** + * Environment variables to pass to the Qwen CLI process. + * These variables will be merged with the current process environment. + */ + env?: Record; + + /** + * Permission mode controlling how the SDK handles tool execution approval. + * + * - 'default': Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. + * Read-only tools execute without confirmation. + * - 'plan': Blocks all write tools, instructing AI to present a plan first. + * Read-only tools execute normally. + * - 'auto-edit': Auto-approve edit tools (edit, write_file) while other tools require confirmation. + * - 'yolo': All tools execute automatically without confirmation. + * + * **Priority Chain (highest to lowest):** + * 1. `excludeTools` - Blocks tools completely (returns permission error) + * 2. `permissionMode: 'plan'` - Blocks non-read-only tools (except exit_plan_mode) + * 3. `permissionMode: 'yolo'` - Auto-approves all tools + * 4. `allowedTools` - Auto-approves matching tools + * 5. `canUseTool` callback - Custom approval logic + * 6. Default behavior - Auto-deny in SDK mode + * + * @default 'default' + * @see canUseTool For custom permission handling + * @see allowedTools For auto-approving specific tools + * @see excludeTools For blocking specific tools + */ + permissionMode?: 'default' | 'plan' | 'auto-edit' | 'yolo'; + + /** + * Custom permission handler for tool execution approval. + * + * This callback is invoked when a tool requires confirmation and allows you to + * programmatically approve or deny execution. It acts as a fallback after + * `allowedTools` check but before default denial. + * + * **When is this called?** + * - Only for tools requiring confirmation (write operations, shell commands, etc.) + * - After `excludeTools` and `allowedTools` checks + * - Not called in 'yolo' mode or 'plan' mode + * - Not called for tools already in `allowedTools` + * + * **Usage with permissionMode:** + * - 'default': Invoked for all write tools not in `allowedTools`; if not provided, auto-denied. + * - 'auto-edit': Invoked for non-edit tools (edit/write_file auto-approved); if not provided, auto-denied. + * - 'plan': Not invoked; write tools are blocked by plan mode. + * - 'yolo': Not invoked; all tools auto-approved. + * + * @see allowedTools For auto-approving tools without callback + */ + canUseTool?: CanUseTool; + + /** + * MCP (Model Context Protocol) servers to connect to. + * + * Supports both external MCP servers and SDK-embedded MCP servers: + * + * **External MCP servers** - Run in separate processes, connected via stdio/SSE/HTTP: + * ```typescript + * mcpServers: { + * 'stdio-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } }, + * 'sse-server': { url: 'http://localhost:3000/sse' }, + * 'http-server': { httpUrl: 'http://localhost:3000/mcp' } + * } + * ``` + * + * **SDK MCP servers** - Run in the SDK process, connected via in-memory transport: + * ```typescript + * const myTool = tool({ + * name: 'my_tool', + * description: 'My custom tool', + * inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + * handler: async (input) => ({ result: input.input.toUpperCase() }), + * }); + * + * const server = createSdkMcpServer('my-server', '1.0.0', [myTool]); + * + * mcpServers: { + * 'my-server': { type: 'sdk', name: 'my-server', instance: server } + * } + * ``` + */ + mcpServers?: Record; + + /** + * AbortController to cancel the query session. + * Call abortController.abort() to terminate the session and cleanup resources. + * Remember to handle the AbortError when the session is aborted. + */ + abortController?: AbortController; + + /** + * Enable debug mode for verbose logging. + * When true, additional diagnostic information will be output. + * Use this with `logLevel` to control the verbosity of the logs. + * @default false + */ + debug?: boolean; + + /** + * Custom handler for stderr output from the Qwen CLI process. + * Use this to capture and process error messages or diagnostic output. + */ + stderr?: (message: string) => void; + + /** + * Logging level for the SDK. + * Controls the verbosity of log messages output by the SDK. + * @default 'error' + */ + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + + /** + * Maximum number of conversation turns before the session automatically terminates. + * A turn consists of a user message and an assistant response. + * @default -1 (unlimited) + */ + maxSessionTurns?: number; + + /** + * Equivalent to `tool.core` in settings.json. + * List of core tools to enable for the session. + * If specified, only these tools will be available to the AI. + * @example ['read_file', 'write_file', 'run_terminal_cmd'] + */ + coreTools?: string[]; + + /** + * Equivalent to `tool.exclude` in settings.json. + * List of tools to exclude from the session. + * + * **Behavior:** + * - Excluded tools return a permission error immediately when invoked + * - Takes highest priority - overrides all other permission settings + * - Tools will not be available to the AI, even if in `coreTools` or `allowedTools` + * + * **Pattern matching:** + * - Tool name: `'write_file'`, `'run_shell_command'` + * - Tool class: `'WriteTool'`, `'ShellTool'` + * - Shell command prefix: `'ShellTool(git commit)'` (matches commands starting with "git commit") + * + * @example ['run_terminal_cmd', 'delete_file', 'ShellTool(rm )'] + * @see allowedTools For allowing specific tools + */ + excludeTools?: string[]; + + /** + * Equivalent to `tool.allowed` in settings.json. + * List of tools that are allowed to run without confirmation. + * + * **Behavior:** + * - Matching tools bypass `canUseTool` callback and execute automatically + * - Only applies when tool requires confirmation (write operations, shell commands) + * - Checked after `excludeTools` but before `canUseTool` callback + * - Does not override `permissionMode: 'plan'` (plan mode blocks all write tools) + * - Has no effect in `permissionMode: 'yolo'` (already auto-approved) + * + * **Pattern matching:** + * - Tool name: `'write_file'`, `'run_shell_command'` + * - Tool class: `'WriteTool'`, `'ShellTool'` + * - Shell command prefix: `'ShellTool(git status)'` (matches commands starting with "git status") + * + * **Use cases:** + * - Auto-approve safe shell commands: `['ShellTool(git status)', 'ShellTool(ls)']` + * - Auto-approve specific tools: `['write_file', 'edit']` + * - Combine with `permissionMode: 'default'` to selectively auto-approve tools + * + * @example ['read_file', 'ShellTool(git status)', 'ShellTool(npm test)'] + * @see canUseTool For custom approval logic + * @see excludeTools For blocking specific tools + */ + allowedTools?: string[]; + + /** + * Authentication type for the AI service. + * - 'openai': Use OpenAI-compatible authentication + * - 'qwen-oauth': Use Qwen OAuth authentication + * + * Though we support 'qwen-oauth', it's not recommended to use it in the SDK. + * Because the credentials are stored in `~/.qwen` and may need to refresh periodically. + */ + authType?: 'openai' | 'qwen-oauth'; + + /** + * Configuration for subagents that can be invoked during the session. + * Subagents are specialized AI agents that can handle specific tasks or domains. + * The invocation is marked as a `task` tool use with the name of agent and a tool_use_id. + * The tool use of these agent is marked with the parent_tool_use_id of the `task` tool use. + */ + agents?: SubagentConfig[]; + + /** + * Include partial messages in the response stream. + * When true, the SDK will emit incomplete messages as they are being generated, + * allowing for real-time streaming of the AI's response. + * @default false + */ + includePartialMessages?: boolean; + + /** + * Timeout configuration for various SDK operations. + * All values are in milliseconds. + */ + timeout?: { + /** + * Timeout for the `canUseTool` callback. + * If the callback doesn't resolve within this time, the permission request + * will be denied with a timeout error (fail-safe behavior). + * @default 60000 (1 minute) + */ + canUseTool?: number; + + /** + * Timeout for SDK MCP tool calls. + * This applies to tool calls made to SDK-embedded MCP servers. + * @default 60000 (1 minute) + */ + mcpRequest?: number; + + /** + * Timeout for SDKโ†’CLI control requests. + * This applies to internal control operations like initialize, interrupt, + * setPermissionMode, setModel, etc. + * @default 60000 (1 minute) + */ + controlRequest?: number; + + /** + * Timeout for waiting before closing CLI's stdin after user messages are sent. + * In multi-turn mode with SDK MCP servers, after all user messages are processed, + * the SDK waits for the first result message to ensure all initialization + * (control responses, MCP server setup, etc.) is complete before closing stdin. + * This timeout is a fallback to avoid hanging indefinitely. + * @default 60000 (1 minute) + */ + streamClose?: number; + }; +} diff --git a/packages/sdk-typescript/src/utils/Stream.ts b/packages/sdk-typescript/src/utils/Stream.ts new file mode 100644 index 00000000..70caf82e --- /dev/null +++ b/packages/sdk-typescript/src/utils/Stream.ts @@ -0,0 +1,79 @@ +export class Stream implements AsyncIterable { + private returned: (() => void) | undefined; + private queue: T[] = []; + private readResolve: ((result: IteratorResult) => void) | undefined; + private readReject: ((error: Error) => void) | undefined; + private isDone = false; + hasError: Error | undefined; + private started = false; + + constructor(returned?: () => void) { + this.returned = returned; + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.started) { + throw new Error('Stream can only be iterated once'); + } + this.started = true; + return this; + } + + async next(): Promise> { + if (this.queue.length > 0) { + return Promise.resolve({ + done: false, + value: this.queue.shift()!, + }); + } + if (this.isDone) { + return Promise.resolve({ done: true, value: undefined }); + } + if (this.hasError) { + return Promise.reject(this.hasError); + } + return new Promise>((resolve, reject) => { + this.readResolve = resolve; + this.readReject = reject; + }); + } + + enqueue(value: T): void { + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = undefined; + this.readReject = undefined; + resolve({ done: false, value }); + } else { + this.queue.push(value); + } + } + + done(): void { + this.isDone = true; + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = undefined; + this.readReject = undefined; + resolve({ done: true, value: undefined }); + } + } + + error(error: Error): void { + this.hasError = error; + if (this.readReject) { + const reject = this.readReject; + this.readResolve = undefined; + this.readReject = undefined; + reject(error); + } + } + + return(): Promise> { + this.isDone = true; + if (this.returned) { + this.returned(); + } + return Promise.resolve({ done: true, value: undefined }); + } +} diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts new file mode 100644 index 00000000..2d919413 --- /dev/null +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -0,0 +1,344 @@ +/** + * CLI path auto-detection and subprocess spawning utilities + * + * Supports multiple execution modes: + * 1. Native binary: 'qwen' (production) + * 2. Node.js bundle: 'node /path/to/cli.js' (production validation) + * 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime) + * 4. TypeScript source: 'tsx /path/to/index.ts' (development) + * + * Auto-detection locations for native binary: + * 1. QWEN_CODE_CLI_PATH environment variable + * 2. ~/.volta/bin/qwen + * 3. ~/.npm-global/bin/qwen + * 4. /usr/local/bin/qwen + * 5. ~/.local/bin/qwen + * 6. ~/node_modules/.bin/qwen + * 7. ~/.yarn/bin/qwen + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; + +/** + * Executable types supported by the SDK + */ +export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno'; + +/** + * Spawn information for CLI process + */ +export type SpawnInfo = { + /** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */ + command: string; + /** Arguments to pass to command */ + args: string[]; + /** Type of executable detected */ + type: ExecutableType; + /** Original input that was resolved */ + originalInput: string; +}; + +export function findNativeCliPath(): string { + const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; + + const candidates: Array = [ + // 1. Environment variable (highest priority) + process.env['QWEN_CODE_CLI_PATH'], + + // 2. Volta bin + path.join(homeDir, '.volta', 'bin', 'qwen'), + + // 3. Global npm installations + path.join(homeDir, '.npm-global', 'bin', 'qwen'), + + // 4. Common Unix binary locations + '/usr/local/bin/qwen', + + // 5. User local bin + path.join(homeDir, '.local', 'bin', 'qwen'), + + // 6. Node modules bin in home directory + path.join(homeDir, 'node_modules', '.bin', 'qwen'), + + // 7. Yarn global bin + path.join(homeDir, '.yarn', 'bin', 'qwen'), + ]; + + // Find first existing candidate + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return path.resolve(candidate); + } + } + + // Not found - throw helpful error + throw new Error( + 'qwen CLI not found. Please:\n' + + ' 1. Install qwen globally: npm install -g qwen\n' + + ' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' + + ' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' + + '\n' + + 'For development/testing, you can also use:\n' + + ' โ€ข TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' + + ' โ€ข Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' + + ' โ€ข Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })', + ); +} + +function isCommandAvailable(command: string): boolean { + try { + // Use 'which' on Unix-like systems, 'where' on Windows + const whichCommand = process.platform === 'win32' ? 'where' : 'which'; + execSync(`${whichCommand} ${command}`, { + stdio: 'ignore', + timeout: 5000, // 5 second timeout + }); + return true; + } catch { + return false; + } +} + +function validateRuntimeAvailability(runtime: string): boolean { + // Node.js is always available since we're running in Node.js + if (runtime === 'node') { + return true; + } + + // Check if the runtime command is available in PATH + return isCommandAvailable(runtime); +} + +function validateFileExtensionForRuntime( + filePath: string, + runtime: string, +): boolean { + const ext = path.extname(filePath).toLowerCase(); + + switch (runtime) { + case 'node': + case 'bun': + return ['.js', '.mjs', '.cjs'].includes(ext); + case 'tsx': + return ['.ts', '.tsx'].includes(ext); + case 'deno': + return ['.ts', '.tsx', '.js', '.mjs'].includes(ext); + default: + return true; // Unknown runtime, let it pass + } +} + +/** + * Parse executable specification into components with comprehensive validation + * + * Supports multiple formats: + * - 'qwen' -> native binary (auto-detected) + * - '/path/to/qwen' -> native binary (explicit path) + * - '/path/to/cli.js' -> Node.js bundle (default for .js files) + * - '/path/to/index.ts' -> TypeScript source (requires tsx) + * + * Advanced runtime specification (for overriding defaults): + * - 'bun:/path/to/cli.js' -> Force Bun runtime + * - 'node:/path/to/cli.js' -> Force Node.js runtime + * - 'tsx:/path/to/index.ts' -> Force tsx runtime + * - 'deno:/path/to/cli.ts' -> Force Deno runtime + * + * @param executableSpec - Executable specification + * @returns Parsed executable information + * @throws Error if specification is invalid or files don't exist + */ +export function parseExecutableSpec(executableSpec?: string): { + runtime?: string; + executablePath: string; + isExplicitRuntime: boolean; +} { + if ( + executableSpec === '' || + (executableSpec && executableSpec.trim() === '') + ) { + throw new Error('Command name cannot be empty'); + } + + if (!executableSpec) { + return { + executablePath: findNativeCliPath(), + isExplicitRuntime: false, + }; + } + + // Check for runtime prefix (e.g., 'bun:/path/to/cli.js') + const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/); + if (runtimeMatch) { + const [, runtime, filePath] = runtimeMatch; + if (!runtime || !filePath) { + throw new Error(`Invalid runtime specification: '${executableSpec}'`); + } + + const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; + if (!supportedRuntimes.includes(runtime)) { + throw new Error( + `Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`, + ); + } + + if (!validateRuntimeAvailability(runtime)) { + throw new Error( + `Runtime '${runtime}' is not available on this system. Please install it first.`, + ); + } + + const resolvedPath = path.resolve(filePath); + + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + + 'Please check the file path and ensure the file exists.', + ); + } + + if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { + const ext = path.extname(resolvedPath); + throw new Error( + `File extension '${ext}' is not compatible with runtime '${runtime}'. ` + + `Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`, + ); + } + + return { + runtime, + executablePath: resolvedPath, + isExplicitRuntime: true, + }; + } + + // Check if it's a command name (no path separators) or a file path + const isCommandName = + !executableSpec.includes('/') && !executableSpec.includes('\\'); + + if (isCommandName) { + // It's a command name like 'qwen' - validate it's a reasonable command name + if (!executableSpec || executableSpec.trim() === '') { + throw new Error('Command name cannot be empty'); + } + + // Basic validation for command names + if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) { + throw new Error( + `Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`, + ); + } + + return { + executablePath: executableSpec, + isExplicitRuntime: false, + }; + } + + // It's a file path - validate and resolve + const resolvedPath = path.resolve(executableSpec); + + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}'. ` + + 'Please check the file path and ensure the file exists. ' + + 'You can also:\n' + + ' โ€ข Set QWEN_CODE_CLI_PATH environment variable\n' + + ' โ€ข Install qwen globally: npm install -g qwen\n' + + ' โ€ข For TypeScript files, ensure tsx is installed: npm install -g tsx\n' + + ' โ€ข Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + ); + } + + // Additional validation for file paths + const stats = fs.statSync(resolvedPath); + if (!stats.isFile()) { + throw new Error( + `Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`, + ); + } + + return { + executablePath: resolvedPath, + isExplicitRuntime: false, + }; +} + +function getExpectedExtensions(runtime: string): string[] { + switch (runtime) { + case 'node': + case 'bun': + return ['.js', '.mjs', '.cjs']; + case 'tsx': + return ['.ts', '.tsx']; + case 'deno': + return ['.ts', '.tsx', '.js', '.mjs']; + default: + return []; + } +} + +function detectRuntimeFromExtension(filePath: string): string | undefined { + const ext = path.extname(filePath).toLowerCase(); + + if (['.js', '.mjs', '.cjs'].includes(ext)) { + // Default to Node.js for JavaScript files + return 'node'; + } + + if (['.ts', '.tsx'].includes(ext)) { + // Check if tsx is available for TypeScript files + if (isCommandAvailable('tsx')) { + return 'tsx'; + } + // If tsx is not available, suggest it in error message + throw new Error( + `TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` + + 'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts', + ); + } + + // Native executable or unknown extension + return undefined; +} + +export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { + const parsed = parseExecutableSpec(executableSpec); + const { runtime, executablePath, isExplicitRuntime } = parsed; + + // If runtime is explicitly specified, use it + if (isExplicitRuntime && runtime) { + const runtimeCommand = runtime === 'node' ? process.execPath : runtime; + + return { + command: runtimeCommand, + args: [executablePath], + type: runtime as ExecutableType, + originalInput: executableSpec || '', + }; + } + + // If no explicit runtime, try to detect from file extension + const detectedRuntime = detectRuntimeFromExtension(executablePath); + + if (detectedRuntime) { + const runtimeCommand = + detectedRuntime === 'node' ? process.execPath : detectedRuntime; + + return { + command: runtimeCommand, + args: [executablePath], + type: detectedRuntime as ExecutableType, + originalInput: executableSpec || '', + }; + } + + // Native executable or command name - use it directly + return { + command: executablePath, + args: [], + type: 'native', + originalInput: executableSpec || '', + }; +} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts new file mode 100644 index 00000000..8af8ec6a --- /dev/null +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -0,0 +1,65 @@ +import { SdkLogger } from './logger.js'; + +export function serializeJsonLine(message: unknown): string { + try { + return JSON.stringify(message) + '\n'; + } catch (error) { + throw new Error( + `Failed to serialize message to JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export function parseJsonLineSafe( + line: string, + context = 'JsonLines', +): unknown | null { + const logger = SdkLogger.createLogger(context); + try { + return JSON.parse(line); + } catch (error) { + logger.warn( + 'Failed to parse JSON line, skipping:', + line.substring(0, 100), + error instanceof Error ? error.message : String(error), + ); + return null; + } +} + +export function isValidMessage(message: unknown): boolean { + return ( + message !== null && + typeof message === 'object' && + 'type' in message && + typeof (message as { type: unknown }).type === 'string' + ); +} + +export async function* parseJsonLinesStream( + lines: AsyncIterable, + context = 'JsonLines', +): AsyncGenerator { + const logger = SdkLogger.createLogger(context); + for await (const line of lines) { + if (line.trim().length === 0) { + continue; + } + + const message = parseJsonLineSafe(line, context); + + if (message === null) { + continue; + } + + if (!isValidMessage(message)) { + logger.warn( + "Invalid message structure (missing 'type' field), skipping:", + line.substring(0, 100), + ); + continue; + } + + yield message; + } +} diff --git a/packages/sdk-typescript/src/utils/logger.ts b/packages/sdk-typescript/src/utils/logger.ts new file mode 100644 index 00000000..caf57ede --- /dev/null +++ b/packages/sdk-typescript/src/utils/logger.ts @@ -0,0 +1,147 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LoggerConfig { + debug?: boolean; + stderr?: (message: string) => void; + logLevel?: LogLevel; +} + +export interface ScopedLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +export class SdkLogger { + private static config: LoggerConfig = {}; + private static effectiveLevel: LogLevel = 'error'; + + static configure(config: LoggerConfig): void { + this.config = config; + this.effectiveLevel = this.determineLogLevel(); + } + + private static determineLogLevel(): LogLevel { + if (this.config.logLevel) { + return this.config.logLevel; + } + + if (this.config.debug) { + return 'debug'; + } + + const envLevel = process.env['DEBUG_QWEN_CODE_SDK_LEVEL']; + if (envLevel && this.isValidLogLevel(envLevel)) { + return envLevel as LogLevel; + } + + if (process.env['DEBUG_QWEN_CODE_SDK']) { + return 'debug'; + } + + return 'error'; + } + + private static isValidLogLevel(level: string): boolean { + return ['debug', 'info', 'warn', 'error'].includes(level); + } + + private static shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.effectiveLevel]; + } + + private static formatTimestamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + } + + private static formatMessage( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): string { + const timestamp = this.formatTimestamp(); + const levelStr = `[${level.toUpperCase()}]`.padEnd(7); + let fullMessage = `${timestamp} ${levelStr} [${scope}] ${message}`; + + if (args.length > 0) { + const argsStr = args + .map((arg) => { + if (typeof arg === 'string') { + return arg; + } + if (arg instanceof Error) { + return arg.message; + } + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }) + .join(' '); + fullMessage += ` ${argsStr}`; + } + + return fullMessage; + } + + private static log( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): void { + if (!this.shouldLog(level)) { + return; + } + + const formattedMessage = this.formatMessage(level, scope, message, args); + + if (this.config.stderr) { + this.config.stderr(formattedMessage); + } else { + if (level === 'warn' || level === 'error') { + process.stderr.write(formattedMessage + '\n'); + } else { + process.stdout.write(formattedMessage + '\n'); + } + } + } + + static createLogger(scope: string): ScopedLogger { + return { + debug: (message: string, ...args: unknown[]) => { + this.log('debug', scope, message, args); + }, + info: (message: string, ...args: unknown[]) => { + this.log('info', scope, message, args); + }, + warn: (message: string, ...args: unknown[]) => { + this.log('warn', scope, message, args); + }, + error: (message: string, ...args: unknown[]) => { + this.log('error', scope, message, args); + }, + }; + } + + static getEffectiveLevel(): LogLevel { + return this.effectiveLevel; + } +} diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts new file mode 100644 index 00000000..b8602654 --- /dev/null +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -0,0 +1,1388 @@ +/** + * Unit tests for ProcessTransport + * Tests subprocess lifecycle management and IPC + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { ProcessTransport } from '../../src/transport/ProcessTransport.js'; +import { AbortError } from '../../src/types/errors.js'; +import type { TransportOptions } from '../../src/types/types.js'; +import { Readable, Writable } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import * as childProcess from 'node:child_process'; +import * as cliPath from '../../src/utils/cliPath.js'; +import * as jsonLines from '../../src/utils/jsonLines.js'; + +// Mock modules +vi.mock('node:child_process'); +vi.mock('../../src/utils/cliPath.js'); +vi.mock('../../src/utils/jsonLines.js'); + +const mockSpawn = vi.mocked(childProcess.spawn); +const mockPrepareSpawnInfo = vi.mocked(cliPath.prepareSpawnInfo); +const mockParseJsonLinesStream = vi.mocked(jsonLines.parseJsonLinesStream); + +// Helper function to create a mock child process with optional overrides +function createMockChildProcess( + overrides: Partial = {}, +): ChildProcess & EventEmitter { + const mockStdin = new Writable({ + write: vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }), + }); + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + const mockStdout = new Readable({ read: vi.fn() }); + const mockStderr = new Readable({ read: vi.fn() }); + + const baseProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + ...overrides, + }) as unknown as ChildProcess & EventEmitter; + + return baseProcess; +} + +describe('ProcessTransport', () => { + let mockChildProcess: ChildProcess & EventEmitter; + let mockStdin: Writable; + let mockStdout: Readable; + let mockStderr: Readable; + + beforeEach(() => { + vi.clearAllMocks(); + + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + + mockStdin = new Writable({ + write: mockWriteFn, + }); + // Override write with a spy so we can track calls + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + mockStdout = new Readable({ read: vi.fn() }); + mockStderr = new Readable({ read: vi.fn() }); + + mockChildProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + }) as unknown as ChildProcess & EventEmitter; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Construction and Initialization', () => { + it('should create transport with required options', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport).toBeDefined(); + expect(transport.isReady).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ]), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); + }); + + it('should build CLI arguments correctly with all options', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + model: 'qwen-max', + permissionMode: 'auto-edit', + maxSessionTurns: 10, + coreTools: ['read_file', 'write_file'], + excludeTools: ['web_search'], + authType: 'api-key', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--model', + 'qwen-max', + '--approval-mode', + 'auto-edit', + '--max-session-turns', + '10', + '--core-tools', + 'read_file,write_file', + '--exclude-tools', + 'web_search', + '--auth-type', + 'api-key', + ]), + expect.any(Object), + ); + }); + + it('should throw if aborted before initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const abortController = new AbortController(); + abortController.abort(); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + expect(() => new ProcessTransport(options)).toThrow(AbortError); + expect(() => new ProcessTransport(options)).toThrow( + 'Transport start aborted', + ); + }); + + it('should use provided AbortController', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: abortController.signal, + }), + ); + }); + + it('should create default AbortController if not provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + }); + + describe('Lifecycle Management', () => { + it('should spawn subprocess during construction', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + + it('should set isReady to true after successful initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); + }); + + it('should set isReady to false on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Spawn failed')); + + expect(transport.isReady).toBe(false); + expect(transport.exitError).toBeDefined(); + }); + + it('should close subprocess gracefully with SIGTERM', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should force kill with SIGKILL after timeout', async () => { + vi.useFakeTimers(); + + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + vi.advanceTimersByTime(5000); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGKILL'); + + vi.useRealTimers(); + }); + + it('should be idempotent when calling close() multiple times', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + await transport.close(); + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledTimes(3); + }); + + it('should wait for process exit in waitForExit()', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it('should reject waitForExit() on non-zero exit code', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 1, null); + + await expect(waitPromise).rejects.toThrow( + 'CLI process exited with code 1', + ); + }); + + it('should reject waitForExit() on signal termination', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', null, 'SIGTERM'); + + await expect(waitPromise).rejects.toThrow( + 'CLI process terminated by signal SIGTERM', + ); + }); + + it('should reject waitForExit() with AbortError when aborted', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + abortController.abort(); + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).rejects.toThrow(AbortError); + }); + }); + + describe('Message Reading', () => { + it('should read JSON Lines from stdout', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const mockMessages = [ + { type: 'message', content: 'test1' }, + { type: 'message', content: 'test2' }, + ]; + + mockParseJsonLinesStream.mockImplementation(async function* () { + for (const msg of mockMessages) { + yield msg; + } + }); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const messages: unknown[] = []; + const readPromise = (async () => { + for await (const message of transport.readMessages()) { + messages.push(message); + } + })(); + + // Give time for the async generator to start and yield messages + await new Promise((resolve) => setTimeout(resolve, 10)); + + mockChildProcess.emit('close', 0, null); + + await readPromise; + + expect(messages).toEqual(mockMessages); + }, 5000); // Set a reasonable timeout + + it('should throw if reading from transport without stdout', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const generator = transport.readMessages(); + + await expect(generator.next()).rejects.toThrow( + 'Cannot read messages: process not started', + ); + }); + }); + + describe('Message Writing', () => { + it('should write message to stdin', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const message = '{"type":"test","data":"hello"}\n'; + transport.write(message); + + expect(mockStdin.write).toHaveBeenCalledWith(message); + }); + + it('should throw if writing before transport is ready', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Process error')); + + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); + }); + + it('should throw if writing to closed transport', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + // After close(), isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); + }); + + it('should throw if writing when aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + + expect(() => transport.write('test')).toThrow(AbortError); + expect(() => transport.write('test')).toThrow( + 'Cannot write: operation aborted', + ); + }); + + it('should throw if writing to ended stream', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockStdin.end(); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to ended stream', + ); + }); + + it('should throw if writing to terminated process', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const terminatedProcess = createMockChildProcess({ exitCode: 1 }); + mockSpawn.mockReturnValue(terminatedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to terminated process', + ); + }); + + it('should throw if process has exit error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + // After process closes with error, isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); + }); + }); + + describe('Error Handling', () => { + it('should set exitError on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const error = new Error('Process error'); + mockChildProcess.emit('error', error); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toContain('CLI process error'); + }); + + it('should set exitError on process close with non-zero code', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process exited with code 1', + ); + }); + + it('should set exitError on process close with signal', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', null, 'SIGKILL'); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process terminated by signal SIGKILL', + ); + }); + + it('should set AbortError when process aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + mockChildProcess.emit('error', new Error('Aborted')); + + expect(transport.exitError).toBeInstanceOf(AbortError); + expect(transport.exitError?.message).toBe('CLI process aborted by user'); + }); + + it('should not set exitError on clean exit', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 0, null); + + expect(transport.exitError).toBeNull(); + }); + }); + + describe('Resource Cleanup', () => { + it('should register cleanup on parent process exit', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const processOnSpy = vi.spyOn(process, 'on'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOnSpy.mockRestore(); + }); + + it('should remove event listeners on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const processOffSpy = vi.spyOn(process, 'off'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(processOffSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOffSpy.mockRestore(); + }); + + it('should register abort listener', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const addEventListenerSpy = vi.spyOn( + abortController.signal, + 'addEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + addEventListenerSpy.mockRestore(); + }); + + it('should remove abort listener on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const removeEventListenerSpy = vi.spyOn( + abortController.signal, + 'removeEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); + }); + + it('should end stdin on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + await transport.close(); + + expect(endSpy).toHaveBeenCalled(); + }); + }); + + describe('Working Directory', () => { + it('should spawn process in specified cwd', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + cwd: '/custom/path', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: '/custom/path', + }), + ); + }); + + it('should default to process.cwd() if not specified', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: process.cwd(), + }), + ); + }); + }); + + describe('Environment Variables', () => { + it('should pass environment variables to subprocess', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + CUSTOM_VAR: 'custom_value', + }), + }), + ); + }); + + it('should inherit parent env by default', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining(process.env), + }), + ); + }); + + it('should merge custom env with parent env', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ...process.env, + CUSTOM_VAR: 'custom_value', + }), + }), + ); + }); + }); + + describe('Debug and Stderr Handling', () => { + it('should pipe stderr when debug is true', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: true, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('should pipe stderr when stderr callback is provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('should ignore stderr when debug is false and no callback', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: false, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); + }); + + it('should call stderr callback when data is received', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + debug: true, // Enable debug to ensure stderr data is logged + }; + + new ProcessTransport(options); + + // Clear previous calls from logger.info during initialization + stderrCallback.mockClear(); + + mockStderr.emit('data', Buffer.from('error message')); + + // The stderr data is passed through logger.debug, which formats it + // So we check that the callback was called with a message containing 'error message' + expect(stderrCallback).toHaveBeenCalled(); + expect(stderrCallback.mock.calls[0][0]).toContain('error message'); + }); + }); + + describe('Stream Access', () => { + it('should provide access to stdin via getInputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBe(mockStdin); + }); + + it('should provide access to stdout via getOutputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBe(mockStdout); + }); + + it('should allow ending input via endInput()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + transport.endInput(); + + expect(endSpy).toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('should handle process that exits immediately', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const immediateExitProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(immediateExitProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); + }); + + it('should handle waitForExit() when process already exited', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const exitedProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(exitedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.waitForExit()).resolves.toBeUndefined(); + }); + + it('should handle close() when process is already killed', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const killedProcess = createMockChildProcess({ killed: true }); + mockSpawn.mockReturnValue(killedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.close()).resolves.toBeUndefined(); + }); + + it('should handle endInput() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.endInput()).not.toThrow(); + }); + + it('should return undefined for getInputStream() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBeUndefined(); + }); + + it('should return undefined for getOutputStream() when stdout is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBeUndefined(); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts new file mode 100644 index 00000000..1dd0a992 --- /dev/null +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -0,0 +1,1437 @@ +/** + * Unit tests for Query class + * Tests message routing, lifecycle, and orchestration + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { Query } from '../../src/query/Query.js'; +import type { Transport } from '../../src/transport/Transport.js'; +import type { + SDKMessage, + SDKUserMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKResultMessage, + SDKPartialAssistantMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from '../../src/types/protocol.js'; +import { ControlRequestType } from '../../src/types/protocol.js'; +import { AbortError } from '../../src/types/errors.js'; +import { Stream } from '../../src/utils/Stream.js'; + +// Mock Transport implementation +class MockTransport implements Transport { + private messageStream = new Stream(); + public writtenMessages: string[] = []; + public closed = false; + public endInputCalled = false; + public isReady = true; + public exitError: Error | null = null; + + write(data: string): void { + this.writtenMessages.push(data); + } + + async *readMessages(): AsyncGenerator { + for await (const message of this.messageStream) { + yield message; + } + } + + async close(): Promise { + this.closed = true; + this.messageStream.done(); + } + + async waitForExit(): Promise { + // Mock implementation - do nothing + } + + endInput(): void { + this.endInputCalled = true; + } + + // Test helper methods + simulateMessage(message: unknown): void { + this.messageStream.enqueue(message); + } + + simulateError(error: Error): void { + this.messageStream.error(error); + } + + simulateClose(): void { + this.messageStream.done(); + } + + getLastWrittenMessage(): unknown { + if (this.writtenMessages.length === 0) return null; + return JSON.parse(this.writtenMessages[this.writtenMessages.length - 1]); + } + + getAllWrittenMessages(): unknown[] { + return this.writtenMessages.map((msg) => JSON.parse(msg)); + } +} + +// Helper function to find control response by request_id +function findControlResponse( + messages: unknown[], + requestId: string, +): CLIControlResponse | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_response' && + 'response' in msg && + typeof msg.response === 'object' && + msg.response !== null && + 'request_id' in msg.response && + msg.response.request_id === requestId, + ) as CLIControlResponse | undefined; +} + +// Helper function to find control request by subtype +function findControlRequest( + messages: unknown[], + subtype: string, +): CLIControlRequest | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_request' && + 'request' in msg && + typeof msg.request === 'object' && + msg.request !== null && + 'subtype' in msg.request && + msg.request.subtype === subtype, + ) as CLIControlRequest | undefined; +} + +// Helper function to create test messages +function createUserMessage( + content: string, + sessionId = 'test-session', +): SDKUserMessage { + return { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content, + }, + parent_tool_use_id: null, + }; +} + +function createAssistantMessage( + content: string, + sessionId = 'test-session', +): SDKAssistantMessage { + return { + type: 'assistant', + uuid: 'msg-123', + session_id: sessionId, + message: { + id: 'msg-123', + type: 'message', + role: 'assistant', + model: 'test-model', + content: [{ type: 'text', text: content }], + usage: { input_tokens: 10, output_tokens: 20 }, + }, + parent_tool_use_id: null, + }; +} + +function createSystemMessage( + subtype: string, + sessionId = 'test-session', +): SDKSystemMessage { + return { + type: 'system', + subtype, + uuid: 'sys-123', + session_id: sessionId, + cwd: '/test/path', + tools: ['read_file', 'write_file'], + model: 'test-model', + }; +} + +function createResultMessage( + success: boolean, + sessionId = 'test-session', +): SDKResultMessage { + if (success) { + return { + type: 'result', + subtype: 'success', + uuid: 'result-123', + session_id: sessionId, + is_error: false, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + result: 'Success', + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + }; + } else { + return { + type: 'result', + subtype: 'error_during_execution', + uuid: 'result-123', + session_id: sessionId, + is_error: true, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + error: { message: 'Test error' }, + }; + } +} + +function createPartialMessage( + sessionId = 'test-session', +): SDKPartialAssistantMessage { + return { + type: 'stream_event', + uuid: 'stream-123', + session_id: sessionId, + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }, + parent_tool_use_id: null, + }; +} + +function createControlRequest( + subtype: string, + requestId = 'req-123', +): CLIControlRequest { + return { + type: 'control_request', + request_id: requestId, + request: { + subtype, + tool_name: 'test_tool', + input: { arg: 'value' }, + permission_suggestions: null, + blocked_path: null, + } as CLIControlRequest['request'], + }; +} + +function createControlResponse( + requestId: string, + success: boolean, + data?: unknown, +): CLIControlResponse { + return { + type: 'control_response', + response: success + ? { + subtype: 'success', + request_id: requestId, + response: data ?? null, + } + : { + subtype: 'error', + request_id: requestId, + error: 'Test error', + }, + }; +} + +function createControlCancel(requestId: string): ControlCancelRequest { + return { + type: 'control_cancel_request', + request_id: requestId, + }; +} + +describe('Query', () => { + let transport: MockTransport; + + beforeEach(() => { + transport = new MockTransport(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (!transport.closed) { + await transport.close(); + } + }); + + describe('Construction and Initialization', () => { + it('should create Query with transport and options', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + expect(query).toBeDefined(); + expect(query.getSessionId()).toBeTruthy(); + expect(query.isClosed()).toBe(false); + + // Should send initialize control request + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + expect(initRequest.type).toBe('control_request'); + expect(initRequest.request.subtype).toBe('initialize'); + + await query.close(); + }); + + it('should generate unique session ID', async () => { + const transport2 = new MockTransport(); + const query1 = new Query(transport, { cwd: '/test' }); + const query2 = new Query(transport2, { + cwd: '/test', + }); + + expect(query1.getSessionId()).not.toBe(query2.getSessionId()); + + await query1.close(); + await query2.close(); + await transport2.close(); + }); + + it('should handle initialization errors', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + // Simulate initialization failure + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, false), + ); + + await expect(query.initialized).rejects.toThrow(); + + await query.close(); + }); + }); + + describe('Message Routing', () => { + it('should route user messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const userMsg = createUserMessage('Hello'); + transport.simulateMessage(userMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(userMsg); + + await query.close(); + }); + + it('should route assistant messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const assistantMsg = createAssistantMessage('Response'); + transport.simulateMessage(assistantMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(assistantMsg); + + await query.close(); + }); + + it('should route system messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const systemMsg = createSystemMessage('session_start'); + transport.simulateMessage(systemMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(systemMsg); + + await query.close(); + }); + + it('should route result messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(resultMsg); + + await query.close(); + }); + + it('should route partial assistant messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const partialMsg = createPartialMessage(); + transport.simulateMessage(partialMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(partialMsg); + + await query.close(); + }); + + it('should handle unknown message types', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const unknownMsg = { type: 'unknown', data: 'test' }; + transport.simulateMessage(unknownMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(unknownMsg); + + await query.close(); + }); + + it('should yield messages in order', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const msg1 = createUserMessage('First'); + const msg2 = createAssistantMessage('Second'); + const msg3 = createResultMessage(true); + + transport.simulateMessage(msg1); + transport.simulateMessage(msg2); + transport.simulateMessage(msg3); + + const result1 = await query.next(); + expect(result1.value).toEqual(msg1); + + const result2 = await query.next(); + expect(result2.value).toEqual(msg2); + + const result3 = await query.next(); + expect(result3.value).toEqual(msg3); + + await query.close(); + }); + }); + + describe('Control Plane - Permission Control', () => { + it('should handle can_use_tool control requests', async () => { + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'allow' }); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalledWith( + 'test_tool', + { arg: 'value' }, + expect.objectContaining({ + signal: expect.any(AbortSignal), + suggestions: null, + }), + ); + }); + + await query.close(); + }); + + it('should send control response with permission result - allow', async () => { + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'allow' }); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-1'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-1'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + }); + } + }); + + await query.close(); + }); + + it('should send control response with permission result - deny', async () => { + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'deny' }); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-2'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-2'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should default to denying tools if no callback', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-3'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-3'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should handle permission callback timeout', async () => { + const canUseTool = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ behavior: 'allow' }), 15000); + }), + ); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + timeout: { + canUseTool: 10000, + }, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-4'); + transport.simulateMessage(controlReq); + + await vi.waitFor( + () => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-4'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }, + { timeout: 15000 }, + ); + + await query.close(); + }); + + it('should handle permission callback errors', async () => { + const canUseTool = vi.fn().mockRejectedValue(new Error('Callback error')); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-5'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-5'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should handle PermissionResult format with updatedInput', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-6'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-6'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + } + }); + + await query.close(); + }); + + it('should handle permission denial with interrupt flag', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-7'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-7'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + } + }); + + await query.close(); + }); + }); + + describe('Control Plane - Control Cancel', () => { + it('should handle control cancel requests', async () => { + const canUseTool = vi.fn().mockImplementation( + ( + _toolName: string, + _toolInput: unknown, + { signal }: { signal: AbortSignal }, + ) => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(new AbortError())); + setTimeout(() => resolve({ behavior: 'allow' }), 5000); + }), + ); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'cancel-req-1'); + transport.simulateMessage(controlReq); + + // Wait a bit then send cancel + await new Promise((resolve) => setTimeout(resolve, 100)); + transport.simulateMessage(createControlCancel('cancel-req-1')); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalled(); + }); + + await query.close(); + }); + + it('should ignore cancel for unknown request_id', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + // Send cancel for non-existent request + transport.simulateMessage(createControlCancel('unknown-req')); + + // Should not throw or cause issues + await new Promise((resolve) => setTimeout(resolve, 100)); + + await query.close(); + }); + }); + + describe('Multi-Turn Conversation', () => { + it('should support streamInput() for follow-up messages', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Follow-up 1'); + yield createUserMessage('Follow-up 2'); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ); + expect(userMessages.length).toBeGreaterThanOrEqual(2); + + await query.close(); + }); + + it('should maintain session context across turns', async () => { + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Turn 1', sessionId); + yield createUserMessage('Turn 2', sessionId); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ) as SDKUserMessage[]; + + userMessages.forEach((msg) => { + expect(msg.session_id).toBe(sessionId); + }); + + await query.close(); + }); + + it('should throw if streamInput() called on closed query', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + async function* messageGenerator() { + yield createUserMessage('Test'); + } + + await expect(query.streamInput(messageGenerator())).rejects.toThrow( + 'Query is closed', + ); + }); + + it('should handle abort during streamInput', async () => { + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Message 1'); + abortController.abort(); + yield createUserMessage('Message 2'); // Should not be sent + } + + await query.streamInput(messageGenerator()); + + await query.close(); + }); + }); + + describe('Lifecycle Management', () => { + it('should close transport on close()', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(transport.closed).toBe(true); + }); + + it('should mark query as closed', async () => { + const query = new Query(transport, { cwd: '/test' }); + expect(query.isClosed()).toBe(false); + + await query.close(); + expect(query.isClosed()).toBe(true); + }); + + it('should complete output stream on close()', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: SDKMessage[] = []; + for await (const msg of query) { + messages.push(msg); + } + return messages; + })(); + + await query.close(); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(Array.isArray(messages)).toBe(true); + }); + + it('should be idempotent when closing multiple times', async () => { + const query = new Query(transport, { cwd: '/test' }); + + await query.close(); + await query.close(); + await query.close(); + + expect(query.isClosed()).toBe(true); + }); + + it('should handle abort signal cancellation', async () => { + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + abortController.abort(); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); + }); + + it('should handle pre-aborted signal', async () => { + const abortController = new AbortController(); + abortController.abort(); + + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); + }); + }); + + describe('Async Iteration', () => { + it('should support for await loop', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const messages: SDKMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + if (messages.length >= 2) break; + } + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createAssistantMessage('Second')); + + await iterationPromise; + + expect(messages).toHaveLength(2); + expect((messages[0] as SDKUserMessage).message.content).toBe('First'); + + await query.close(); + }); + + it('should complete iteration when query closes', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const messages: SDKMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + } + })(); + + transport.simulateMessage(createUserMessage('Test')); + + // Give time for message to be processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + await query.close(); + transport.simulateClose(); + + await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(1); + }); + + it('should propagate transport errors', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + for await (const msg of query) { + void msg; + } + })(); + + transport.simulateError(new Error('Transport error')); + + await expect(iterationPromise).rejects.toThrow('Transport error'); + + await query.close(); + }); + }); + + describe('Public API Methods', () => { + it('should provide interrupt() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Respond to interrupt + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + transport.simulateMessage( + createControlResponse(interruptMsg.request_id, true, {}), + ); + + await interruptPromise; + await query.close(); + }); + + it('should provide setPermissionMode() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModePromise = query.setPermissionMode('yolo'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + ); + expect(setModeMsg).toBeDefined(); + }); + + // Respond to set permission mode + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + )!; + transport.simulateMessage( + createControlResponse(setModeMsg.request_id, true, {}), + ); + + await setModePromise; + await query.close(); + }); + + it('should provide setModel() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModelPromise = query.setModel('new-model'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + ); + expect(setModelMsg).toBeDefined(); + }); + + // Respond to set model + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + )!; + transport.simulateMessage( + createControlResponse(setModelMsg.request_id, true, {}), + ); + + await setModelPromise; + await query.close(); + }); + + it('should provide supportedCommands() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const commandsPromise = query.supportedCommands(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + ); + expect(commandsMsg).toBeDefined(); + }); + + // Respond with commands + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + )!; + transport.simulateMessage( + createControlResponse(commandsMsg.request_id, true, { + commands: ['interrupt', 'set_model'], + }), + ); + + const result = await commandsPromise; + expect(result).toMatchObject({ commands: ['interrupt', 'set_model'] }); + + await query.close(); + }); + + it('should provide mcpServerStatus() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const statusPromise = query.mcpServerStatus(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + ); + expect(statusMsg).toBeDefined(); + }); + + // Respond with status + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + )!; + transport.simulateMessage( + createControlResponse(statusMsg.request_id, true, { + servers: [{ name: 'test', status: 'connected' }], + }), + ); + + const result = await statusPromise; + expect(result).toMatchObject({ + servers: [{ name: 'test', status: 'connected' }], + }); + + await query.close(); + }); + + it('should throw if methods called on closed query', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + await expect(query.interrupt()).rejects.toThrow('Query is closed'); + await expect(query.setPermissionMode('yolo')).rejects.toThrow( + 'Query is closed', + ); + await expect(query.setModel('model')).rejects.toThrow('Query is closed'); + await expect(query.supportedCommands()).rejects.toThrow( + 'Query is closed', + ); + await expect(query.mcpServerStatus()).rejects.toThrow('Query is closed'); + }); + }); + + describe('Error Handling', () => { + it('should propagate transport errors to stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const error = new Error('Transport failure'); + transport.simulateError(error); + + await expect(query.next()).rejects.toThrow('Transport failure'); + + await query.close(); + }); + + it('should handle control request timeout', async () => { + const query = new Query(transport, { + cwd: '/test', + timeout: { + controlRequest: 10000, + }, + }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + // Call interrupt but don't respond - should timeout + const interruptPromise = query.interrupt(); + + await expect(interruptPromise).rejects.toThrow(/timeout/i); + + await query.close(); + }, 15000); + + it('should handle malformed control responses', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Send malformed response + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + + transport.simulateMessage({ + type: 'control_response', + response: { + subtype: 'error', + request_id: interruptMsg.request_id, + error: { message: 'Malformed error' }, + }, + }); + + await expect(interruptPromise).rejects.toThrow('Malformed error'); + + await query.close(); + }); + + it('should handle CLI sending error result message', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const errorResult = createResultMessage(false); + transport.simulateMessage(errorResult); + + const result = await query.next(); + expect(result.done).toBe(false); + expect((result.value as SDKResultMessage).is_error).toBe(true); + + await query.close(); + }); + }); + + describe('Single-Turn Mode', () => { + it('should auto-close input after result in single-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + true, // singleTurn = true + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(true); + + await query.close(); + }); + + it('should not auto-close input in multi-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + false, // singleTurn = false + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(false); + + await query.close(); + }); + }); + + describe('State Management', () => { + it('should track session ID', () => { + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + expect(sessionId).toBeTruthy(); + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); + }); + + it('should track closed state', async () => { + const query = new Query(transport, { cwd: '/test' }); + + expect(query.isClosed()).toBe(false); + await query.close(); + expect(query.isClosed()).toBe(true); + }); + + it('should provide endInput() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + query.endInput(); + expect(transport.endInputCalled).toBe(true); + + await query.close(); + }); + + it('should throw if endInput() called on closed query', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(() => query.endInput()).toThrow('Query is closed'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty message stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + transport.simulateClose(); + + const result = await query.next(); + expect(result.done).toBe(true); + + await query.close(); + }); + + it('should handle rapid message flow', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Simulate rapid messages + for (let i = 0; i < 100; i++) { + transport.simulateMessage(createUserMessage(`Message ${i}`)); + } + + const messages: SDKMessage[] = []; + for (let i = 0; i < 100; i++) { + const result = await query.next(); + if (!result.done) { + messages.push(result.value); + } + } + + expect(messages.length).toBe(100); + + await query.close(); + }); + + it('should handle close during message iteration', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: SDKMessage[] = []; + for await (const msg of query) { + messages.push(msg); + if (messages.length === 2) { + await query.close(); + } + } + return messages; + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createUserMessage('Second')); + transport.simulateMessage(createUserMessage('Third')); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(2); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/Stream.test.ts b/packages/sdk-typescript/test/unit/Stream.test.ts new file mode 100644 index 00000000..2113a202 --- /dev/null +++ b/packages/sdk-typescript/test/unit/Stream.test.ts @@ -0,0 +1,255 @@ +/** + * Unit tests for Stream class + * Tests producer-consumer patterns and async iteration + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { Stream } from '../../src/utils/Stream.js'; + +describe('Stream', () => { + let stream: Stream; + + beforeEach(() => { + stream = new Stream(); + }); + + describe('Producer-Consumer Patterns', () => { + it('should deliver enqueued value immediately to waiting consumer', async () => { + // Start consumer (waits for value) + const consumerPromise = stream.next(); + + // Producer enqueues value + stream.enqueue('hello'); + + // Consumer should receive value immediately + const result = await consumerPromise; + expect(result).toEqual({ value: 'hello', done: false }); + }); + + it('should buffer values when consumer is slow', async () => { + // Producer enqueues multiple values + stream.enqueue('first'); + stream.enqueue('second'); + stream.enqueue('third'); + + // Consumer reads buffered values + expect(await stream.next()).toEqual({ value: 'first', done: false }); + expect(await stream.next()).toEqual({ value: 'second', done: false }); + expect(await stream.next()).toEqual({ value: 'third', done: false }); + }); + + it('should handle fast producer and fast consumer', async () => { + const values: string[] = []; + + // Produce and consume simultaneously + const consumerPromise = (async () => { + for (let i = 0; i < 3; i++) { + const result = await stream.next(); + if (!result.done) { + values.push(result.value); + } + } + })(); + + stream.enqueue('a'); + stream.enqueue('b'); + stream.enqueue('c'); + + await consumerPromise; + expect(values).toEqual(['a', 'b', 'c']); + }); + + it('should handle async iteration with for await loop', async () => { + const values: string[] = []; + + // Start consumer + const consumerPromise = (async () => { + for await (const value of stream) { + values.push(value); + } + })(); + + // Producer enqueues and completes + stream.enqueue('x'); + stream.enqueue('y'); + stream.enqueue('z'); + stream.done(); + + await consumerPromise; + expect(values).toEqual(['x', 'y', 'z']); + }); + }); + + describe('Stream Completion', () => { + it('should signal completion when done() is called', async () => { + stream.done(); + const result = await stream.next(); + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should complete waiting consumer immediately', async () => { + const consumerPromise = stream.next(); + stream.done(); + const result = await consumerPromise; + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should allow done() to be called multiple times', async () => { + stream.done(); + stream.done(); + stream.done(); + + const result = await stream.next(); + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should allow enqueuing to completed stream (no check in reference)', async () => { + stream.done(); + // Reference version doesn't check for done in enqueue + stream.enqueue('value'); + // Verify value was enqueued by reading it + expect(await stream.next()).toEqual({ value: 'value', done: false }); + }); + + it('should deliver buffered values before completion', async () => { + stream.enqueue('first'); + stream.enqueue('second'); + stream.done(); + + expect(await stream.next()).toEqual({ value: 'first', done: false }); + expect(await stream.next()).toEqual({ value: 'second', done: false }); + expect(await stream.next()).toEqual({ done: true, value: undefined }); + }); + }); + + describe('Error Handling', () => { + it('should propagate error to waiting consumer', async () => { + const consumerPromise = stream.next(); + const error = new Error('Stream error'); + stream.error(error); + + await expect(consumerPromise).rejects.toThrow('Stream error'); + }); + + it('should throw error on next read after error is set', async () => { + const error = new Error('Test error'); + stream.error(error); + + await expect(stream.next()).rejects.toThrow('Test error'); + }); + + it('should allow enqueuing to stream with error (no check in reference)', async () => { + stream.error(new Error('Error')); + // Reference version doesn't check for error in enqueue + stream.enqueue('value'); + // Verify value was enqueued by reading it + expect(await stream.next()).toEqual({ value: 'value', done: false }); + }); + + it('should store last error (reference overwrites)', async () => { + const firstError = new Error('First'); + const secondError = new Error('Second'); + + stream.error(firstError); + stream.error(secondError); + + await expect(stream.next()).rejects.toThrow('Second'); + }); + + it('should deliver buffered values before throwing error', async () => { + stream.enqueue('buffered'); + stream.error(new Error('Stream error')); + + expect(await stream.next()).toEqual({ value: 'buffered', done: false }); + await expect(stream.next()).rejects.toThrow('Stream error'); + }); + }); + + describe('State Properties', () => { + it('should track error state', () => { + expect(stream.hasError).toBeUndefined(); + stream.error(new Error('Test')); + expect(stream.hasError).toBeInstanceOf(Error); + expect(stream.hasError?.message).toBe('Test'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty stream', async () => { + stream.done(); + const result = await stream.next(); + expect(result.done).toBe(true); + }); + + it('should handle single value', async () => { + stream.enqueue('only'); + stream.done(); + + expect(await stream.next()).toEqual({ value: 'only', done: false }); + expect(await stream.next()).toEqual({ done: true, value: undefined }); + }); + + it('should handle rapid enqueue-dequeue cycles', async () => { + const numberStream = new Stream(); + const iterations = 100; + const values: number[] = []; + + const producer = async (): Promise => { + for (let i = 0; i < iterations; i++) { + numberStream.enqueue(i); + await new Promise((resolve) => setImmediate(resolve)); + } + numberStream.done(); + }; + + const consumer = async (): Promise => { + for await (const value of numberStream) { + values.push(value); + } + }; + + await Promise.all([producer(), consumer()]); + expect(values).toHaveLength(iterations); + expect(values[0]).toBe(0); + expect(values[iterations - 1]).toBe(iterations - 1); + }); + }); + + describe('TypeScript Types', () => { + it('should handle different value types', async () => { + const numberStream = new Stream(); + numberStream.enqueue(42); + numberStream.done(); + + const result = await numberStream.next(); + expect(result.value).toBe(42); + + const objectStream = new Stream<{ id: number; name: string }>(); + objectStream.enqueue({ id: 1, name: 'test' }); + objectStream.done(); + + const objectResult = await objectStream.next(); + expect(objectResult.value).toEqual({ id: 1, name: 'test' }); + }); + }); + + describe('Iteration Restrictions', () => { + it('should only allow iteration once', async () => { + const stream = new Stream(); + stream.enqueue('test'); + stream.done(); + + // First iteration should work + const iterator1 = stream[Symbol.asyncIterator](); + expect(await iterator1.next()).toEqual({ + value: 'test', + done: false, + }); + + // Second iteration should throw + expect(() => stream[Symbol.asyncIterator]()).toThrow( + 'Stream can only be iterated once', + ); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts new file mode 100644 index 00000000..43f50dec --- /dev/null +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -0,0 +1,648 @@ +/** + * Unit tests for CLI path utilities + * Tests executable detection, parsing, and spawn info preparation + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; +import { + parseExecutableSpec, + prepareSpawnInfo, + findNativeCliPath, +} from '../../src/utils/cliPath.js'; + +// Mock fs module +vi.mock('node:fs'); +const mockFs = vi.mocked(fs); + +// Mock child_process module +vi.mock('node:child_process'); +const mockExecSync = vi.mocked(execSync); + +// Mock process.versions for bun detection +const originalVersions = process.versions; + +describe('CLI Path Utilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset process.versions + Object.defineProperty(process, 'versions', { + value: { ...originalVersions }, + writable: true, + }); + // Default: tsx is available (can be overridden in specific tests) + mockExecSync.mockReturnValue(Buffer.from('')); + // Default: mock statSync to return a proper file stat object + mockFs.statSync.mockReturnValue({ + isFile: () => true, + } as ReturnType); + }); + + afterEach(() => { + // Restore original process.versions + Object.defineProperty(process, 'versions', { + value: originalVersions, + writable: true, + }); + }); + + describe('parseExecutableSpec', () => { + describe('auto-detection (no spec provided)', () => { + it('should auto-detect native CLI when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec(); + + expect(result).toEqual({ + executablePath: path.resolve('/usr/local/bin/qwen'), + isExplicitRuntime: false, + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw when auto-detection fails', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec()).toThrow( + 'qwen CLI not found. Please:', + ); + }); + }); + + describe('runtime prefix parsing', () => { + it('should parse node runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('node:/path/to/cli.js'); + + expect(result).toEqual({ + runtime: 'node', + executablePath: path.resolve('/path/to/cli.js'), + isExplicitRuntime: true, + }); + }); + + it('should parse bun runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('bun:/path/to/cli.js'); + + expect(result).toEqual({ + runtime: 'bun', + executablePath: path.resolve('/path/to/cli.js'), + isExplicitRuntime: true, + }); + }); + + it('should parse tsx runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('tsx:/path/to/index.ts'); + + expect(result).toEqual({ + runtime: 'tsx', + executablePath: path.resolve('/path/to/index.ts'), + isExplicitRuntime: true, + }); + }); + + it('should parse deno runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('deno:/path/to/cli.ts'); + + expect(result).toEqual({ + runtime: 'deno', + executablePath: path.resolve('/path/to/cli.ts'), + isExplicitRuntime: true, + }); + }); + + it('should throw for invalid runtime prefix format', () => { + expect(() => parseExecutableSpec('invalid:format')).toThrow( + 'Unsupported runtime', + ); + }); + + it('should throw when runtime-prefixed file does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow( + 'Executable file not found at', + ); + }); + }); + + describe('command name detection', () => { + it('should detect command names without path separators', () => { + const result = parseExecutableSpec('qwen'); + + expect(result).toEqual({ + executablePath: 'qwen', + isExplicitRuntime: false, + }); + }); + + it('should detect command names on Windows', () => { + const result = parseExecutableSpec('qwen.exe'); + + expect(result).toEqual({ + executablePath: 'qwen.exe', + isExplicitRuntime: false, + }); + }); + }); + + describe('file path resolution', () => { + it('should resolve absolute file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('/absolute/path/to/qwen'); + + expect(result).toEqual({ + executablePath: path.resolve('/absolute/path/to/qwen'), + isExplicitRuntime: false, + }); + }); + + it('should resolve relative file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('./relative/path/to/qwen'); + + expect(result).toEqual({ + executablePath: path.resolve('./relative/path/to/qwen'), + isExplicitRuntime: false, + }); + }); + + it('should throw when file path does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( + 'Executable file not found at', + ); + }); + }); + }); + + describe('prepareSpawnInfo', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); + + describe('native executables', () => { + it('should prepare spawn info for native binary command', () => { + const result = prepareSpawnInfo('qwen'); + + expect(result).toEqual({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + it('should prepare spawn info for native binary path', () => { + const result = prepareSpawnInfo('/usr/local/bin/qwen'); + + expect(result).toEqual({ + command: path.resolve('/usr/local/bin/qwen'), + args: [], + type: 'native', + originalInput: '/usr/local/bin/qwen', + }); + }); + }); + + describe('JavaScript files', () => { + it('should use node for .js files', () => { + const result = prepareSpawnInfo('/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: '/path/to/cli.js', + }); + }); + + it('should default to node for .js files (not auto-detect bun)', () => { + // Even when running under bun, default to node for .js files + Object.defineProperty(process, 'versions', { + value: { ...originalVersions, bun: '1.0.0' }, + writable: true, + }); + + const result = prepareSpawnInfo('/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: '/path/to/cli.js', + }); + }); + + it('should handle .mjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.mjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.mjs')], + type: 'node', + originalInput: '/path/to/cli.mjs', + }); + }); + + it('should handle .cjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.cjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.cjs')], + type: 'node', + originalInput: '/path/to/cli.cjs', + }); + }); + }); + + describe('TypeScript files', () => { + it('should use tsx for .ts files when tsx is available', () => { + // tsx is available by default in beforeEach + const result = prepareSpawnInfo('/path/to/index.ts'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/index.ts')], + type: 'tsx', + originalInput: '/path/to/index.ts', + }); + }); + + it('should use tsx for .tsx files when tsx is available', () => { + const result = prepareSpawnInfo('/path/to/cli.tsx'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/cli.tsx')], + type: 'tsx', + originalInput: '/path/to/cli.tsx', + }); + }); + + it('should throw helpful error when tsx is not available', () => { + // Mock tsx not being available + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + const resolvedPath = path.resolve('/path/to/index.ts'); + expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( + `TypeScript file '${resolvedPath}' requires 'tsx' runtime, but it's not available`, + ); + expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( + 'Please install tsx: npm install -g tsx', + ); + }); + }); + + describe('explicit runtime specifications', () => { + it('should use explicit node runtime', () => { + const result = prepareSpawnInfo('node:/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: 'node:/path/to/cli.js', + }); + }); + + it('should use explicit bun runtime', () => { + const result = prepareSpawnInfo('bun:/path/to/cli.js'); + + expect(result).toEqual({ + command: 'bun', + args: [path.resolve('/path/to/cli.js')], + type: 'bun', + originalInput: 'bun:/path/to/cli.js', + }); + }); + + it('should use explicit tsx runtime', () => { + const result = prepareSpawnInfo('tsx:/path/to/index.ts'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/index.ts')], + type: 'tsx', + originalInput: 'tsx:/path/to/index.ts', + }); + }); + + it('should use explicit deno runtime', () => { + const result = prepareSpawnInfo('deno:/path/to/cli.ts'); + + expect(result).toEqual({ + command: 'deno', + args: [path.resolve('/path/to/cli.ts')], + type: 'deno', + originalInput: 'deno:/path/to/cli.ts', + }); + }); + }); + + describe('auto-detection fallback', () => { + it('should auto-detect when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + + const result = prepareSpawnInfo(); + + expect(result).toEqual({ + command: path.resolve('/usr/local/bin/qwen'), + args: [], + type: 'native', + originalInput: '', + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + }); + + describe('findNativeCliPath', () => { + it('should find CLI from environment variable', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = findNativeCliPath(); + + expect(result).toBe(path.resolve('/custom/path/to/qwen')); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should search common installation locations', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + + // Mock fs.existsSync to return true for volta bin + // Use path.join to match platform-specific path separators + const voltaBinPath = path.join('.volta', 'bin', 'qwen'); + mockFs.existsSync.mockImplementation((p) => { + return p.toString().includes(voltaBinPath); + }); + + const result = findNativeCliPath(); + + expect(result).toContain(voltaBinPath); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw descriptive error when CLI not found', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + mockFs.existsSync.mockReturnValue(false); + + expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + + describe('real-world use cases', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); + + it('should handle development with TypeScript source', () => { + const devPath = '/Users/dev/qwen-code/packages/cli/index.ts'; + const result = prepareSpawnInfo(devPath); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve(devPath)], + type: 'tsx', + originalInput: devPath, + }); + }); + + it('should handle production bundle validation', () => { + const bundlePath = '/path/to/bundled/cli.js'; + const result = prepareSpawnInfo(bundlePath); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve(bundlePath)], + type: 'node', + originalInput: bundlePath, + }); + }); + + it('should handle production native binary', () => { + const result = prepareSpawnInfo('qwen'); + + expect(result).toEqual({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + it('should handle bun runtime with bundle', () => { + const bundlePath = '/path/to/cli.js'; + const result = prepareSpawnInfo(`bun:${bundlePath}`); + + expect(result).toEqual({ + command: 'bun', + args: [path.resolve(bundlePath)], + type: 'bun', + originalInput: `bun:${bundlePath}`, + }); + }); + }); + + describe('error cases', () => { + it('should provide helpful error for missing TypeScript file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow( + 'Executable file not found at', + ); + }); + + it('should provide helpful error for missing JavaScript file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow( + 'Executable file not found at', + ); + }); + + it('should provide helpful error for invalid runtime specification', () => { + expect(() => prepareSpawnInfo('invalid:spec')).toThrow( + 'Unsupported runtime', + ); + }); + }); + + describe('comprehensive validation', () => { + describe('runtime validation', () => { + it('should reject unsupported runtimes', () => { + expect(() => + parseExecutableSpec('unsupported:/path/to/file.js'), + ).toThrow( + "Unsupported runtime 'unsupported'. Supported runtimes: node, bun, tsx, deno", + ); + }); + + it('should validate runtime availability for explicit runtime specs', () => { + mockFs.existsSync.mockReturnValue(true); + // Mock bun not being available + mockExecSync.mockImplementation((command) => { + if (command.includes('bun')) { + throw new Error('Command not found'); + } + return Buffer.from(''); + }); + + expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow( + "Runtime 'bun' is not available on this system. Please install it first.", + ); + }); + + it('should allow node runtime (always available)', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow(); + }); + + it('should validate file extension matches runtime', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow( + "File extension '.js' is not compatible with runtime 'tsx'", + ); + }); + + it('should validate node runtime with JavaScript files', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow( + "File extension '.ts' is not compatible with runtime 'node'", + ); + }); + + it('should accept valid runtime-file combinations', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow(); + expect(() => + parseExecutableSpec('node:/path/to/file.js'), + ).not.toThrow(); + expect(() => + parseExecutableSpec('bun:/path/to/file.mjs'), + ).not.toThrow(); + }); + }); + + describe('command name validation', () => { + it('should reject empty command names', () => { + expect(() => parseExecutableSpec('')).toThrow( + 'Command name cannot be empty', + ); + expect(() => parseExecutableSpec(' ')).toThrow( + 'Command name cannot be empty', + ); + }); + + it('should reject invalid command name characters', () => { + expect(() => parseExecutableSpec('qwen@invalid')).toThrow( + "Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.", + ); + + expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path + }); + + it('should accept valid command names', () => { + expect(() => parseExecutableSpec('qwen')).not.toThrow(); + expect(() => parseExecutableSpec('qwen-code')).not.toThrow(); + expect(() => parseExecutableSpec('qwen_code')).not.toThrow(); + expect(() => parseExecutableSpec('qwen.exe')).not.toThrow(); + expect(() => parseExecutableSpec('qwen123')).not.toThrow(); + }); + }); + + describe('file path validation', () => { + it('should validate file exists', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( + 'Executable file not found at', + ); + }); + + it('should validate path points to a file, not directory', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ + isFile: () => false, + } as ReturnType); + + expect(() => parseExecutableSpec('/path/to/directory')).toThrow( + 'exists but is not a file', + ); + }); + + it('should accept valid file paths', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ + isFile: () => true, + } as ReturnType); + + expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow(); + expect(() => parseExecutableSpec('./relative/path')).not.toThrow(); + }); + }); + + describe('error message quality', () => { + it('should provide helpful error for missing runtime-prefixed file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( + 'Executable file not found at', + ); + expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( + 'Please check the file path and ensure the file exists', + ); + }); + + it('should provide helpful error for missing regular file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Set QWEN_CODE_CLI_PATH environment variable', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Install qwen globally: npm install -g qwen', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + ); + }); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts new file mode 100644 index 00000000..8f39ad08 --- /dev/null +++ b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts @@ -0,0 +1,386 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Unit tests for createSdkMcpServer + * + * Tests MCP server creation and tool registration. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js'; +import { tool } from '../../src/mcp/tool.js'; +import type { SdkMcpToolDefinition } from '../../src/mcp/tool.js'; + +describe('createSdkMcpServer', () => { + describe('Server Creation', () => { + it('should create server with name and version', () => { + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [], + }); + + expect(server).toBeDefined(); + expect(server.type).toBe('sdk'); + expect(server.name).toBe('test-server'); + expect(server.instance).toBeDefined(); + }); + + it('should create server with default version', () => { + const server = createSdkMcpServer({ + name: 'test-server', + }); + + expect(server).toBeDefined(); + expect(server.name).toBe('test-server'); + }); + + it('should throw error with invalid name', () => { + expect(() => createSdkMcpServer({ name: '', version: '1.0.0' })).toThrow( + 'MCP server name must be a non-empty string', + ); + }); + + it('should throw error with invalid version', () => { + expect(() => createSdkMcpServer({ name: 'test', version: '' })).toThrow( + 'MCP server version must be a non-empty string', + ); + }); + + it('should throw error with non-array tools', () => { + expect(() => + createSdkMcpServer({ + name: 'test', + version: '1.0.0', + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + tools: {} as unknown as SdkMcpToolDefinition[], + }), + ).toThrow('Tools must be an array'); + }); + }); + + describe('Tool Registration', () => { + it('should register single tool', () => { + const testTool = tool( + 'test_tool', + 'A test tool', + { input: z.string() }, + async () => ({ + content: [{ type: 'text', text: 'result' }], + }), + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [testTool], + }); + + expect(server).toBeDefined(); + }); + + it('should register multiple tools', () => { + const tool1 = tool('tool1', 'Tool 1', {}, async () => ({ + content: [{ type: 'text', text: 'result1' }], + })); + + const tool2 = tool('tool2', 'Tool 2', {}, async () => ({ + content: [{ type: 'text', text: 'result2' }], + })); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [tool1, tool2], + }); + + expect(server).toBeDefined(); + }); + + it('should throw error for duplicate tool names', () => { + const tool1 = tool('duplicate', 'Tool 1', {}, async () => ({ + content: [{ type: 'text', text: 'result1' }], + })); + + const tool2 = tool('duplicate', 'Tool 2', {}, async () => ({ + content: [{ type: 'text', text: 'result2' }], + })); + + expect(() => + createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [tool1, tool2], + }), + ).toThrow("Duplicate tool name 'duplicate'"); + }); + + it('should validate tool names', () => { + const invalidTool = { + name: '123invalid', // Starts with number + description: 'Invalid tool', + inputSchema: {}, + handler: async () => ({ + content: [{ type: 'text' as const, text: 'result' }], + }), + }; + + expect(() => + createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + tools: [invalidTool as unknown as SdkMcpToolDefinition], + }), + ).toThrow('Tool name'); + }); + }); + + describe('Tool Handler Invocation', () => { + it('should invoke tool handler with correct input', async () => { + const handler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'success' }], + }); + + const testTool = tool( + 'test_tool', + 'A test tool', + { value: z.string() }, + handler, + ); + + createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [testTool], + }); + + // Note: Actual invocation testing requires MCP SDK integration + // This test verifies the handler was properly registered + expect(handler).toBeDefined(); + }); + + it('should handle async tool handlers', async () => { + const handler = vi + .fn() + .mockImplementation(async (input: { value: string }) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + content: [{ type: 'text', text: `processed: ${input.value}` }], + }; + }); + + const testTool = tool('async_tool', 'An async tool', {}, handler); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [testTool], + }); + + expect(server).toBeDefined(); + }); + }); + + describe('Type Safety', () => { + it('should preserve input type in handler', async () => { + const handler = vi.fn().mockImplementation(async (input) => { + return { + content: [ + { type: 'text', text: `Hello ${input.name}, age ${input.age}` }, + ], + }; + }); + + const typedTool = tool( + 'typed_tool', + 'A typed tool', + { + name: z.string(), + age: z.number(), + }, + handler, + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [typedTool], + }); + + expect(server).toBeDefined(); + }); + }); + + describe('Error Handling in Tools', () => { + it('should handle tool handler errors gracefully', async () => { + const handler = vi.fn().mockRejectedValue(new Error('Tool failed')); + + const errorTool = tool('error_tool', 'A tool that errors', {}, handler); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [errorTool], + }); + + expect(server).toBeDefined(); + // Error handling occurs during tool invocation + }); + + it('should handle synchronous tool handler errors', async () => { + const handler = vi.fn().mockImplementation(() => { + throw new Error('Sync error'); + }); + + const errorTool = tool( + 'sync_error_tool', + 'A tool that errors synchronously', + {}, + handler, + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [errorTool], + }); + + expect(server).toBeDefined(); + }); + }); + + describe('Complex Tool Scenarios', () => { + it('should support tool with complex input schema', () => { + const complexTool = tool( + 'complex_tool', + 'A tool with complex schema', + { + query: z.string(), + filters: z + .object({ + category: z.string().optional(), + minPrice: z.number().optional(), + }) + .optional(), + options: z.array(z.string()).optional(), + }, + async (input) => { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ results: [], filters: input.filters }), + }, + ], + }; + }, + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [complexTool], + }); + + expect(server).toBeDefined(); + }); + + it('should support tool returning complex output', () => { + const complexOutputTool = tool( + 'complex_output_tool', + 'Returns complex data', + {}, + async () => { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + data: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ], + metadata: { + total: 2, + page: 1, + }, + nested: { + deep: { + value: 'test', + }, + }, + }), + }, + ], + }; + }, + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [complexOutputTool], + }); + + expect(server).toBeDefined(); + }); + }); + + describe('Multiple Servers', () => { + it('should create multiple independent servers', () => { + const tool1 = tool('tool1', 'Tool in server 1', {}, async () => ({ + content: [{ type: 'text', text: 'result1' }], + })); + + const tool2 = tool('tool2', 'Tool in server 2', {}, async () => ({ + content: [{ type: 'text', text: 'result2' }], + })); + + const server1 = createSdkMcpServer({ + name: 'server1', + version: '1.0.0', + tools: [tool1], + }); + const server2 = createSdkMcpServer({ + name: 'server2', + version: '1.0.0', + tools: [tool2], + }); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + expect(server1.name).toBe('server1'); + expect(server2.name).toBe('server2'); + }); + + it('should allow same tool name in different servers', () => { + const tool1 = tool('shared_name', 'Tool in server 1', {}, async () => ({ + content: [{ type: 'text', text: 'result1' }], + })); + + const tool2 = tool('shared_name', 'Tool in server 2', {}, async () => ({ + content: [{ type: 'text', text: 'result2' }], + })); + + const server1 = createSdkMcpServer({ + name: 'server1', + version: '1.0.0', + tools: [tool1], + }); + const server2 = createSdkMcpServer({ + name: 'server2', + version: '1.0.0', + tools: [tool2], + }); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + }); + }); +}); diff --git a/packages/sdk-typescript/tsconfig.build.json b/packages/sdk-typescript/tsconfig.build.json new file mode 100644 index 00000000..61dbca5b --- /dev/null +++ b/packages/sdk-typescript/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "emitDeclarationOnly": true, + "removeComments": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/sdk-typescript/tsconfig.json b/packages/sdk-typescript/tsconfig.json new file mode 100644 index 00000000..11fba047 --- /dev/null +++ b/packages/sdk-typescript/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + /* Language and Environment */ + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + + /* Emit */ + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "removeComments": true, + "importHelpers": false, + + /* Interop Constraints */ + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + + /* Type Checking */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + + /* Completeness */ + "skipLibCheck": true, + + /* Module Resolution */ + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", ".integration-tests"] +} diff --git a/packages/sdk-typescript/vitest.config.ts b/packages/sdk-typescript/vitest.config.ts new file mode 100644 index 00000000..f46dc537 --- /dev/null +++ b/packages/sdk-typescript/vitest.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from 'vitest/config'; +import * as path from 'path'; + +const timeoutMinutes = Number(process.env['E2E_TIMEOUT_MINUTES'] || '3'); +const testTimeoutMs = timeoutMinutes * 60 * 1000; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + 'test/', + '**/*.d.ts', + '**/*.config.*', + '**/index.ts', // Export-only files + ], + thresholds: { + lines: 80, + functions: 80, + branches: 75, + statements: 80, + }, + }, + include: ['test/**/*.test.ts'], + exclude: ['node_modules/', 'dist/'], + retry: 2, + fileParallelism: true, + poolOptions: { + threads: { + minThreads: 2, + maxThreads: 4, + }, + }, + testTimeout: testTimeoutMs, + hookTimeout: 10000, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index a8a6ead2..64e89c6d 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.4.0", + "version": "0.5.0", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/.vscodeignore b/packages/vscode-ide-companion/.vscodeignore index e74d0536..18e07a04 100644 --- a/packages/vscode-ide-companion/.vscodeignore +++ b/packages/vscode-ide-companion/.vscodeignore @@ -1,5 +1,6 @@ ** !dist/ +!dist/** ../ ../../ !LICENSE diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 8866f163..afbf750d 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -1,5 +1,26 @@ This file contains third-party software notices and license terms. +============================================================ +semver@7.7.2 +(git+https://github.com/npm/node-semver.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ============================================================ @modelcontextprotocol/sdk@1.15.1 (git+https://github.com/modelcontextprotocol/typescript-sdk.git) @@ -2317,3 +2338,520 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +============================================================ +markdown-it@14.1.0 +(No repository found) + +Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +argparse@2.0.1 +(No repository found) + +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see http://www.opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the Internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the Internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +entities@4.5.0 +(git://github.com/fb55/entities.git) + +Copyright (c) Felix Bรถhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +============================================================ +linkify-it@5.0.0 +(No repository found) + +Copyright (c) 2015 Vitaly Puzrin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +uc.micro@2.1.0 +(No repository found) + +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +mdurl@2.0.0 +(No repository found) + +Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------------------- + +.parse() is based on Joyent's node.js `url` code: + +Copyright Joyent, Inc. and other Node contributors. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. + + +============================================================ +punycode.js@2.3.1 +(https://github.com/mathiasbynens/punycode.js.git) + +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +react@19.1.0 +(https://github.com/facebook/react.git) + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +react-dom@19.1.0 +(https://github.com/facebook/react.git) + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +scheduler@0.26.0 +(https://github.com/facebook/react.git) + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/packages/vscode-ide-companion/README.md b/packages/vscode-ide-companion/README.md index c6788769..a5e4980d 100644 --- a/packages/vscode-ide-companion/README.md +++ b/packages/vscode-ide-companion/README.md @@ -19,6 +19,63 @@ To use this extension, you'll need: - VS Code version 1.101.0 or newer - Qwen Code (installed separately) running within the VS Code integrated terminal +# Development and Debugging + +To debug and develop this extension locally: + +1. **Clone the repository** + + ```bash + git clone https://github.com/QwenLM/qwen-code.git + cd qwen-code + ``` + +2. **Install dependencies** + + ```bash + npm install + # or if using pnpm + pnpm install + ``` + +3. **Start debugging** + + ```bash + code . # Open the project root in VS Code + ``` + - Open the `packages/vscode-ide-companion/src/extension.ts` file + - Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`) + - Select **"Launch Companion VS Code Extension"** from the debug dropdown + - Press `F5` to launch Extension Development Host + +4. **Make changes and reload** + - Edit the source code in the original VS Code window + - To see your changes, reload the Extension Development Host window by: + - Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS) + - Or clicking the "Reload" button in the debug toolbar + +5. **View logs and debug output** + - Open the Debug Console in the original VS Code window to see extension logs + - In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs + +## Build for Production + +To build the extension for distribution: + +```bash +npm run compile +# or +pnpm run compile +``` + +To package the extension as a VSIX file: + +```bash +npx vsce package +# or +pnpm vsce package +``` + # Terms of Service and Privacy Notice By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md). diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 7de7c7ad..032c3c13 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -31,8 +31,69 @@ const esbuildProblemMatcherPlugin = { }, }; +/** + * @type {import('esbuild').Plugin} + */ +const cssInjectPlugin = { + name: 'css-inject', + setup(build) { + // Handle CSS files + build.onLoad({ filter: /\.css$/ }, async (args) => { + const fs = await import('fs'); + const postcss = (await import('postcss')).default; + const tailwindcss = (await import('tailwindcss')).default; + const autoprefixer = (await import('autoprefixer')).default; + + let css = await fs.promises.readFile(args.path, 'utf8'); + + // For styles.css, we need to resolve @import statements + if (args.path.endsWith('styles.css')) { + // Read all imported CSS files and inline them + const importRegex = /@import\s+'([^']+)';/g; + let match; + const basePath = args.path.substring(0, args.path.lastIndexOf('/')); + while ((match = importRegex.exec(css)) !== null) { + const importPath = match[1]; + // Resolve relative paths correctly + let fullPath; + if (importPath.startsWith('./')) { + fullPath = basePath + importPath.substring(1); + } else if (importPath.startsWith('../')) { + fullPath = basePath + '/' + importPath; + } else { + fullPath = basePath + '/' + importPath; + } + + try { + const importedCss = await fs.promises.readFile(fullPath, 'utf8'); + css = css.replace(match[0], importedCss); + } catch (err) { + console.warn(`Could not import ${fullPath}: ${err.message}`); + } + } + } + + // Process with PostCSS (Tailwind + Autoprefixer) + const result = await postcss([tailwindcss, autoprefixer]).process(css, { + from: args.path, + to: args.path, + }); + + return { + contents: ` + const style = document.createElement('style'); + style.textContent = ${JSON.stringify(result.css)}; + document.head.appendChild(style); + `, + loader: 'js', + }; + }); + }, +}; + async function main() { - const ctx = await esbuild.context({ + // Build extension + const extensionCtx = await esbuild.context({ entryPoints: ['src/extension.ts'], bundle: true, format: 'cjs', @@ -55,11 +116,30 @@ async function main() { ], loader: { '.node': 'file' }, }); + + // Build webview + const webviewCtx = await esbuild.context({ + entryPoints: ['src/webview/index.tsx'], + bundle: true, + format: 'iife', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'browser', + outfile: 'dist/webview.js', + logLevel: 'silent', + plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin], + jsx: 'automatic', // Use new JSX transform (React 17+) + define: { + 'process.env.NODE_ENV': production ? '"production"' : '"development"', + }, + }); + if (watch) { - await ctx.watch(); + await Promise.all([extensionCtx.watch(), webviewCtx.watch()]); } else { - await ctx.rebuild(); - await ctx.dispose(); + await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]); + await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]); } } diff --git a/packages/vscode-ide-companion/eslint.config.mjs b/packages/vscode-ide-companion/eslint.config.mjs index 02fc9fba..4b444a9b 100644 --- a/packages/vscode-ide-companion/eslint.config.mjs +++ b/packages/vscode-ide-companion/eslint.config.mjs @@ -6,20 +6,44 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; +import reactHooks from 'eslint-plugin-react-hooks'; +import importPlugin from 'eslint-plugin-import'; export default [ { - files: ['**/*.ts'], + files: ['**/*.ts', '**/*.tsx'], + }, + { + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + module: 'readonly', + require: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + process: 'readonly', + console: 'readonly', + }, + }, }, { plugins: { '@typescript-eslint': typescriptEslint, + 'react-hooks': reactHooks, + import: importPlugin, }, languageOptions: { parser: tsParser, ecmaVersion: 2022, sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, }, rules: { @@ -30,6 +54,17 @@ export default [ format: ['camelCase', 'PascalCase'], }, ], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + // Restrict deep imports but allow known-safe exceptions used by the webview + // - react-dom/client: required for React 18's createRoot API + // - ./styles/**: local CSS modules loaded by the webview + 'import/no-internal-modules': [ + 'error', + { + allow: ['react-dom/client', './styles/**'], + }, + ], curly: 'warn', eqeqeq: 'warn', diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 66ccfa0d..8698275b 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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.4.0", + "version": "0.5.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { @@ -54,6 +54,15 @@ { "command": "qwen-code.showNotices", "title": "Qwen Code: View Third-Party Notices" + }, + { + "command": "qwen-code.openChat", + "title": "Qwen Code: Open", + "icon": "./assets/icon.png" + }, + { + "command": "qwen-code.login", + "title": "Qwen Code: Login" } ], "menus": { @@ -65,6 +74,10 @@ { "command": "qwen.diff.cancel", "when": "qwen.diff.isVisible" + }, + { + "command": "qwen-code.login", + "when": "false" } ], "editor/title": [ @@ -77,6 +90,10 @@ "command": "qwen.diff.cancel", "when": "qwen.diff.isVisible", "group": "navigation" + }, + { + "command": "qwen-code.openChat", + "group": "navigation" } ] }, @@ -96,7 +113,7 @@ "main": "./dist/extension.cjs", "type": "module", "scripts": { - "prepackage": "npm run generate:notices && npm run check-types && npm run lint && npm run build:prod", + "prepackage": "node ./scripts/prepackage.js", "build": "npm run build:dev", "build:dev": "npm run check-types && npm run lint && node esbuild.js", "build:prod": "node esbuild.js --production", @@ -115,21 +132,33 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/markdown-it": "^14.1.2", "@types/node": "20.x", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/semver": "^7.7.1", "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.6.0", + "autoprefixer": "^10.4.22", "esbuild": "^0.25.3", "eslint": "^9.25.1", + "eslint-plugin-react-hooks": "^5.2.0", "npm-run-all2": "^8.0.2", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "dependencies": { + "semver": "^7.7.2", "@modelcontextprotocol/sdk": "^1.15.1", "cors": "^2.8.5", "express": "^5.1.0", + "markdown-it": "^14.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "zod": "^3.25.76" } } diff --git a/packages/vscode-ide-companion/postcss.config.js b/packages/vscode-ide-companion/postcss.config.js new file mode 100644 index 00000000..49f4aea7 --- /dev/null +++ b/packages/vscode-ide-companion/postcss.config.js @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-undef */ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/vscode-ide-companion/scripts/copy-bundled-cli.js b/packages/vscode-ide-companion/scripts/copy-bundled-cli.js new file mode 100644 index 00000000..d720e47f --- /dev/null +++ b/packages/vscode-ide-companion/scripts/copy-bundled-cli.js @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Copy the already-built root dist/ folder into the extension dist/qwen-cli/. + * + * Assumes repoRoot/dist already exists (e.g. produced by `npm run bundle` and + * optionally `npm run prepare:package`). + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const extensionRoot = path.resolve(__dirname, '..'); +const repoRoot = path.resolve(extensionRoot, '..', '..'); +const rootDistDir = path.join(repoRoot, 'dist'); +const extensionDistDir = path.join(extensionRoot, 'dist'); +const bundledCliDir = path.join(extensionDistDir, 'qwen-cli'); + +async function main() { + const cliJs = path.join(rootDistDir, 'cli.js'); + const vendorDir = path.join(rootDistDir, 'vendor'); + + if (!existsSync(cliJs) || !existsSync(vendorDir)) { + throw new Error( + `[copy-bundled-cli] Missing root dist artifacts. Expected:\n- ${cliJs}\n- ${vendorDir}\n\nRun root "npm run bundle" first.`, + ); + } + + await fs.mkdir(extensionDistDir, { recursive: true }); + const existingNodeModules = path.join(bundledCliDir, 'node_modules'); + const tmpNodeModules = path.join( + extensionDistDir, + 'qwen-cli.node_modules.tmp', + ); + const keepNodeModules = existsSync(existingNodeModules); + + // Preserve destination node_modules if it exists (e.g. after packaging install). + if (keepNodeModules) { + await fs.rm(tmpNodeModules, { recursive: true, force: true }); + await fs.rename(existingNodeModules, tmpNodeModules); + } + + await fs.rm(bundledCliDir, { recursive: true, force: true }); + await fs.mkdir(bundledCliDir, { recursive: true }); + + await fs.cp(rootDistDir, bundledCliDir, { recursive: true }); + + if (keepNodeModules) { + await fs.rename(tmpNodeModules, existingNodeModules); + } + + console.log(`[copy-bundled-cli] Copied ${rootDistDir} -> ${bundledCliDir}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/vscode-ide-companion/scripts/prepackage.js b/packages/vscode-ide-companion/scripts/prepackage.js new file mode 100644 index 00000000..8db18a69 --- /dev/null +++ b/packages/vscode-ide-companion/scripts/prepackage.js @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * VS Code extension packaging orchestration. + * + * We bundle the CLI into the extension so users don't need a global install. + * To match the published CLI layout, we need to: + * - build root bundle (dist/cli.js + vendor/ + sandbox profiles) + * - run root prepare:package (dist/package.json + locales + README/LICENSE) + * - install production deps into root dist/ (dist/node_modules) so runtime deps + * like optional node-pty are present inside the VSIX payload. + * + * Then we generate notices and build the extension. + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const extensionRoot = path.resolve(__dirname, '..'); +const repoRoot = path.resolve(extensionRoot, '..', '..'); +const bundledCliDir = path.join(extensionRoot, 'dist', 'qwen-cli'); + +function npmBin() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +} + +function run(cmd, args, opts = {}) { + const res = spawnSync(cmd, args, { + stdio: 'inherit', + shell: process.platform === 'win32' ? true : false, + ...opts, + }); + if (res.error) { + throw res.error; + } + if (typeof res.status === 'number' && res.status !== 0) { + throw new Error( + `Command failed (${res.status}): ${cmd} ${args.map((a) => JSON.stringify(a)).join(' ')}`, + ); + } +} + +function main() { + const npm = npmBin(); + + console.log('[prepackage] Bundling root CLI...'); + run(npm, ['--prefix', repoRoot, 'run', 'bundle'], { cwd: repoRoot }); + + console.log('[prepackage] Preparing root dist/ package metadata...'); + run(npm, ['--prefix', repoRoot, 'run', 'prepare:package'], { cwd: repoRoot }); + + console.log('[prepackage] Generating notices...'); + run(npm, ['run', 'generate:notices'], { cwd: extensionRoot }); + + console.log('[prepackage] Typechecking...'); + run(npm, ['run', 'check-types'], { cwd: extensionRoot }); + + console.log('[prepackage] Linting...'); + run(npm, ['run', 'lint'], { cwd: extensionRoot }); + + console.log('[prepackage] Building extension (production)...'); + run(npm, ['run', 'build:prod'], { cwd: extensionRoot }); + + console.log('[prepackage] Copying bundled CLI dist/ into extension...'); + run( + 'node', + [`${path.join(extensionRoot, 'scripts', 'copy-bundled-cli.js')}`], + { + cwd: extensionRoot, + }, + ); + + console.log( + '[prepackage] Installing production deps into extension dist/qwen-cli...', + ); + run( + npm, + [ + '--prefix', + bundledCliDir, + 'install', + '--omit=dev', + '--no-audit', + '--no-fund', + ], + { cwd: bundledCliDir }, + ); +} + +main(); diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts new file mode 100644 index 00000000..e75e1bd1 --- /dev/null +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -0,0 +1,80 @@ +import * as vscode from 'vscode'; +import type { DiffManager } from '../diff-manager.js'; +import type { WebViewProvider } from '../webview/WebViewProvider.js'; + +type Logger = (message: string) => void; + +export const runQwenCodeCommand = 'qwen-code.runQwenCode'; +export const showDiffCommand = 'qwenCode.showDiff'; +export const openChatCommand = 'qwen-code.openChat'; +export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; +export const loginCommand = 'qwen-code.login'; + +export function registerNewCommands( + context: vscode.ExtensionContext, + log: Logger, + diffManager: DiffManager, + getWebViewProviders: () => WebViewProvider[], + createWebViewProvider: () => WebViewProvider, +): void { + const disposables: vscode.Disposable[] = []; + + disposables.push( + vscode.commands.registerCommand(openChatCommand, async () => { + const providers = getWebViewProviders(); + if (providers.length > 0) { + await providers[providers.length - 1].show(); + } else { + const provider = createWebViewProvider(); + await provider.show(); + } + }), + ); + + disposables.push( + vscode.commands.registerCommand( + showDiffCommand, + async (args: { path: string; oldText: string; newText: string }) => { + try { + let absolutePath = args.path; + if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + absolutePath = vscode.Uri.joinPath( + workspaceFolder.uri, + args.path, + ).fsPath; + } + } + log(`[Command] Showing diff for ${absolutePath}`); + await diffManager.showDiff(absolutePath, args.oldText, args.newText); + } catch (error) { + log(`[Command] Error showing diff: ${error}`); + vscode.window.showErrorMessage(`Failed to show diff: ${error}`); + } + }, + ), + ); + + disposables.push( + vscode.commands.registerCommand(openNewChatTabCommand, async () => { + const provider = createWebViewProvider(); + // Session restoration is now disabled by default, so no need to suppress it + await provider.show(); + }), + ); + + disposables.push( + vscode.commands.registerCommand(loginCommand, async () => { + const providers = getWebViewProviders(); + if (providers.length > 0) { + await providers[providers.length - 1].forceReLogin(); + } else { + vscode.window.showInformationMessage( + 'Please open Qwen Code chat first before logging in.', + ); + } + }), + ); + context.subscriptions.push(...disposables); +} diff --git a/packages/vscode-ide-companion/src/constants/acpSchema.ts b/packages/vscode-ide-companion/src/constants/acpSchema.ts new file mode 100644 index 00000000..9f06e4fa --- /dev/null +++ b/packages/vscode-ide-companion/src/constants/acpSchema.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const AGENT_METHODS = { + authenticate: 'authenticate', + initialize: 'initialize', + session_cancel: 'session/cancel', + session_list: 'session/list', + session_load: 'session/load', + session_new: 'session/new', + session_prompt: 'session/prompt', + session_save: 'session/save', + session_set_mode: 'session/set_mode', +} as const; + +export const CLIENT_METHODS = { + fs_read_text_file: 'fs/read_text_file', + fs_write_text_file: 'fs/write_text_file', + authenticate_update: 'authenticate/update', + session_request_permission: 'session/request_permission', + session_update: 'session/update', +} as const; diff --git a/packages/vscode-ide-companion/src/constants/loadingMessages.ts b/packages/vscode-ide-companion/src/constants/loadingMessages.ts new file mode 100644 index 00000000..edb01ca2 --- /dev/null +++ b/packages/vscode-ide-companion/src/constants/loadingMessages.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Loading messages from Qwen Code CLI + * Source: packages/cli/src/ui/hooks/usePhraseCycler.ts + */ +export const WITTY_LOADING_PHRASES = [ + "I'm Feeling Lucky", + 'Shipping awesomeness... ', + 'Painting the serifs back on...', + 'Navigating the slime mold...', + 'Consulting the digital spirits...', + 'Reticulating splines...', + 'Warming up the AI hamsters...', + 'Asking the magic conch shell...', + 'Generating witty retort...', + 'Polishing the algorithms...', + "Don't rush perfection (or my code)...", + 'Brewing fresh bytes...', + 'Counting electrons...', + 'Engaging cognitive processors...', + 'Checking for syntax errors in the universe...', + 'One moment, optimizing humor...', + 'Shuffling punchlines...', + 'Untangling neural nets...', + 'Compiling brilliance...', + 'Loading wit.exe...', + 'Summoning the cloud of wisdom...', + 'Preparing a witty response...', + "Just a sec, I'm debugging reality...", + 'Confuzzling the options...', + 'Tuning the cosmic frequencies...', + 'Crafting a response worthy of your patience...', + 'Compiling the 1s and 0s...', + 'Resolving dependencies... and existential crises...', + 'Defragmenting memories... both RAM and personal...', + 'Rebooting the humor module...', + 'Caching the essentials (mostly cat memes)...', + 'Optimizing for ludicrous speed', + "Swapping bits... don't tell the bytes...", + 'Garbage collecting... be right back...', + 'Assembling the interwebs...', + 'Converting coffee into code...', + 'Updating the syntax for reality...', + 'Rewiring the synapses...', + 'Looking for a misplaced semicolon...', + "Greasin' the cogs of the machine...", + 'Pre-heating the servers...', + 'Calibrating the flux capacitor...', + 'Engaging the improbability drive...', + 'Channeling the Force...', + 'Aligning the stars for optimal response...', + 'So say we all...', + 'Loading the next great idea...', + "Just a moment, I'm in the zone...", + 'Preparing to dazzle you with brilliance...', + "Just a tick, I'm polishing my wit...", + "Hold tight, I'm crafting a masterpiece...", + "Just a jiffy, I'm debugging the universe...", + "Just a moment, I'm aligning the pixels...", + "Just a sec, I'm optimizing the humor...", + "Just a moment, I'm tuning the algorithms...", + 'Warp speed engaged...', + 'Mining for more Dilithium crystals...', + "Don't panic...", + 'Following the white rabbit...', + 'The truth is in here... somewhere...', + 'Blowing on the cartridge...', + 'Loading... Do a barrel roll!', + 'Waiting for the respawn...', + 'Finishing the Kessel Run in less than 12 parsecs...', + "The cake is not a lie, it's just still loading...", + 'Fiddling with the character creation screen...', + "Just a moment, I'm finding the right meme...", + "Pressing 'A' to continue...", + 'Herding digital cats...', + 'Polishing the pixels...', + 'Finding a suitable loading screen pun...', + 'Distracting you with this witty phrase...', + 'Almost there... probably...', + 'Our hamsters are working as fast as they can...', + 'Giving Cloudy a pat on the head...', + 'Petting the cat...', + 'Rickrolling my boss...', + 'Never gonna give you up, never gonna let you down...', + 'Slapping the bass...', + 'Tasting the snozberries...', + "I'm going the distance, I'm going for speed...", + 'Is this the real life? Is this just fantasy?...', + "I've got a good feeling about this...", + 'Poking the bear...', + 'Doing research on the latest memes...', + 'Figuring out how to make this more witty...', + 'Hmmm... let me think...', + 'What do you call a fish with no eyes? A fsh...', + 'Why did the computer go to therapy? It had too many bytes...', + "Why don't programmers like nature? It has too many bugs...", + 'Why do programmers prefer dark mode? Because light attracts bugs...', + 'Why did the developer go broke? Because they used up all their cache...', + "What can you do with a broken pencil? Nothing, it's pointless...", + 'Applying percussive maintenance...', + 'Searching for the correct USB orientation...', + 'Ensuring the magic smoke stays inside the wires...', + 'Rewriting in Rust for no particular reason...', + 'Trying to exit Vim...', + 'Spinning up the hamster wheel...', + "That's not a bug, it's an undocumented feature...", + 'Engage.', + "I'll be back... with an answer.", + 'My other process is a TARDIS...', + 'Communing with the machine spirit...', + 'Letting the thoughts marinate...', + 'Just remembered where I put my keys...', + 'Pondering the orb...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.", + 'Initiating thoughtful gaze...', + "What's a computer's favorite snack? Microchips.", + "Why do Java developers wear glasses? Because they don't C#.", + 'Charging the laser... pew pew!', + 'Dividing by zero... just kidding!', + 'Looking for an adult superviso... I mean, processing.', + 'Making it go beep boop.', + 'Buffering... because even AIs need a moment.', + 'Entangling quantum particles for a faster response...', + 'Polishing the chrome... on the algorithms.', + 'Are you not entertained? (Working on it!)', + 'Summoning the code gremlins... to help, of course.', + 'Just waiting for the dial-up tone to finish...', + 'Recalibrating the humor-o-meter.', + 'My other loading screen is even funnier.', + "Pretty sure there's a cat walking on the keyboard somewhere...", + 'Enhancing... Enhancing... Still loading.', + "It's not a bug, it's a feature... of this loading screen.", + 'Have you tried turning it off and on again? (The loading screen, not me.)', + 'Constructing additional pylons...', + "New line? That's Ctrl+J.", +]; + +export const getRandomLoadingMessage = (): string => + WITTY_LOADING_PHRASES[ + Math.floor(Math.random() * WITTY_LOADING_PHRASES.length) + ]; diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts index fea9edc4..9a32769c 100644 --- a/packages/vscode-ide-companion/src/diff-manager.ts +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -12,6 +12,10 @@ import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { DIFF_SCHEME } from './extension.js'; +import { + findLeftGroupOfChatWebview, + ensureLeftGroupOfChatWebview, +} from './utils/editorGroupUtils.js'; export class DiffContentProvider implements vscode.TextDocumentContentProvider { private content = new Map(); @@ -42,7 +46,9 @@ export class DiffContentProvider implements vscode.TextDocumentContentProvider { // Information about a diff view that is currently open. interface DiffInfo { originalFilePath: string; + oldContent: string; newContent: string; + leftDocUri: vscode.Uri; rightDocUri: vscode.Uri; } @@ -55,11 +61,26 @@ export class DiffManager { readonly onDidChange = this.onDidChangeEmitter.event; private diffDocuments = new Map(); private readonly subscriptions: vscode.Disposable[] = []; + // Dedupe: remember recent showDiff calls keyed by (file+content) + private recentlyShown = new Map(); + private pendingDelayTimers = new Map(); + private static readonly DEDUPE_WINDOW_MS = 1500; + // Optional hooks from extension to influence diff behavior + // - shouldDelay: when true, we defer opening diffs briefly (e.g., while a permission drawer is open) + // - shouldSuppress: when true, we skip opening diffs entirely (e.g., in auto/yolo mode) + private shouldDelay?: () => boolean; + private shouldSuppress?: () => boolean; + // Timed suppression window (e.g. immediately after permission allow) + private suppressUntil: number | null = null; constructor( private readonly log: (message: string) => void, private readonly diffContentProvider: DiffContentProvider, + shouldDelay?: () => boolean, + shouldSuppress?: () => boolean, ) { + this.shouldDelay = shouldDelay; + this.shouldSuppress = shouldSuppress; this.subscriptions.push( vscode.window.onDidChangeActiveTextEditor((editor) => { this.onActiveEditorChange(editor); @@ -75,43 +96,142 @@ export class DiffManager { } /** - * Creates and shows a new diff view. + * Checks if a diff view already exists for the given file path and content + * @param filePath Path to the file being diffed + * @param oldContent The original content (left side) + * @param newContent The modified content (right side) + * @returns True if a diff view with the same content already exists, false otherwise */ - async showDiff(filePath: string, newContent: string) { - const fileUri = vscode.Uri.file(filePath); + private hasExistingDiff( + filePath: string, + oldContent: string, + newContent: string, + ): boolean { + for (const diffInfo of this.diffDocuments.values()) { + if ( + diffInfo.originalFilePath === filePath && + diffInfo.oldContent === oldContent && + diffInfo.newContent === newContent + ) { + return true; + } + } + return false; + } + /** + * Finds an existing diff view for the given file path and focuses it + * @param filePath Path to the file being diffed + * @returns True if an existing diff view was found and focused, false otherwise + */ + private async focusExistingDiff(filePath: string): Promise { + const normalizedPath = path.normalize(filePath); + for (const [, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === normalizedPath) { + const rightDocUri = diffInfo.rightDocUri; + const leftDocUri = diffInfo.leftDocUri; + + const diffTitle = `${path.basename(filePath)} (Before โ†” After)`; + + try { + await vscode.commands.executeCommand( + 'vscode.diff', + leftDocUri, + rightDocUri, + diffTitle, + { + viewColumn: vscode.ViewColumn.Beside, + preview: false, + preserveFocus: true, + }, + ); + return true; + } catch (error) { + this.log(`Failed to focus existing diff: ${error}`); + return false; + } + } + } + return false; + } + + /** + * Creates and shows a new diff view. + * - Overload 1: showDiff(filePath, newContent) + * - Overload 2: showDiff(filePath, oldContent, newContent) + * If only newContent is provided, the old content will be read from the + * filesystem (empty string when file does not exist). + */ + async showDiff(filePath: string, newContent: string): Promise; + async showDiff( + filePath: string, + oldContent: string, + newContent: string, + ): Promise; + async showDiff(filePath: string, a: string, b?: string): Promise { + const haveOld = typeof b === 'string'; + const oldContent = haveOld ? a : await this.readOldContentFromFs(filePath); + const newContent = haveOld ? (b as string) : a; + const normalizedPath = path.normalize(filePath); + const key = this.makeKey(normalizedPath, oldContent, newContent); + + // Check if a diff view with the same content already exists + if (this.hasExistingDiff(normalizedPath, oldContent, newContent)) { + const last = this.recentlyShown.get(key) || 0; + const now = Date.now(); + if (now - last < DiffManager.DEDUPE_WINDOW_MS) { + // Within dedupe window: ignore the duplicate request entirely + this.log( + `Duplicate showDiff suppressed for ${filePath} (within ${DiffManager.DEDUPE_WINDOW_MS}ms)`, + ); + return; + } + // Outside the dedupe window: softly focus the existing diff + await this.focusExistingDiff(normalizedPath); + this.recentlyShown.set(key, now); + return; + } + // Left side: old content using qwen-diff scheme + const leftDocUri = vscode.Uri.from({ + scheme: DIFF_SCHEME, + path: normalizedPath, + query: `old&rand=${Math.random()}`, + }); + this.diffContentProvider.setContent(leftDocUri, oldContent); + + // Right side: new content using qwen-diff scheme const rightDocUri = vscode.Uri.from({ scheme: DIFF_SCHEME, - path: filePath, - // cache busting - query: `rand=${Math.random()}`, + path: normalizedPath, + query: `new&rand=${Math.random()}`, }); this.diffContentProvider.setContent(rightDocUri, newContent); this.addDiffDocument(rightDocUri, { - originalFilePath: filePath, + originalFilePath: normalizedPath, + oldContent, newContent, + leftDocUri, rightDocUri, }); - const diffTitle = `${path.basename(filePath)} โ†” Modified`; + const diffTitle = `${path.basename(normalizedPath)} (Before โ†” After)`; await vscode.commands.executeCommand( 'setContext', 'qwen.diff.isVisible', true, ); - let leftDocUri; - try { - await vscode.workspace.fs.stat(fileUri); - leftDocUri = fileUri; - } catch { - // We need to provide an empty document to diff against. - // Using the 'untitled' scheme is one way to do this. - leftDocUri = vscode.Uri.from({ - scheme: 'untitled', - path: filePath, - }); + // Prefer opening the diff adjacent to the chat webview (so we don't + // replace content inside the locked webview group). We try the group to + // the left of the chat webview first; if none exists we fall back to + // ViewColumn.Beside. With the chat locked in the leftmost group, this + // fallback opens diffs to the right of the chat. + let targetViewColumn = findLeftGroupOfChatWebview(); + if (targetViewColumn === undefined) { + // If there is no left neighbor, create one to satisfy the requirement of + // opening diffs to the left of the chat webview. + targetViewColumn = await ensureLeftGroupOfChatWebview(); } await vscode.commands.executeCommand( @@ -120,6 +240,10 @@ export class DiffManager { rightDocUri, diffTitle, { + // If a left-of-webview group was found, target it explicitly so the + // diff opens there while keeping focus on the webview. Otherwise, use + // the default "open to side" behavior. + viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside, preview: false, preserveFocus: true, }, @@ -127,16 +251,19 @@ export class DiffManager { await vscode.commands.executeCommand( 'workbench.action.files.setActiveEditorWriteableInSession', ); + + this.recentlyShown.set(key, Date.now()); } /** * Closes an open diff view for a specific file. */ async closeDiff(filePath: string, suppressNotification = false) { + const normalizedPath = path.normalize(filePath); let uriToClose: vscode.Uri | undefined; - for (const [uriString, diffInfo] of this.diffDocuments.entries()) { - if (diffInfo.originalFilePath === filePath) { - uriToClose = vscode.Uri.parse(uriString); + for (const [, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === normalizedPath) { + uriToClose = diffInfo.rightDocUri; break; } } @@ -267,4 +394,40 @@ export class DiffManager { } } } + + /** Close all open qwen-diff editors */ + async closeAll(): Promise { + // Collect keys first to avoid iterator invalidation while closing + const uris = Array.from(this.diffDocuments.keys()).map((k) => + vscode.Uri.parse(k), + ); + for (const uri of uris) { + try { + await this.closeDiffEditor(uri); + } catch (err) { + this.log(`Failed to close diff editor: ${err}`); + } + } + } + + // Read the current content of file from the workspace; return empty string if not found + private async readOldContentFromFs(filePath: string): Promise { + try { + const fileUri = vscode.Uri.file(filePath); + const document = await vscode.workspace.openTextDocument(fileUri); + return document.getText(); + } catch { + return ''; + } + } + + private makeKey(filePath: string, oldContent: string, newContent: string) { + // Simple stable key; content could be large but kept transiently + return `${filePath}\u241F${oldContent}\u241F${newContent}`; + } + + /** Temporarily suppress opening diffs for a short duration. */ + suppressFor(durationMs: number): void { + this.suppressUntil = Date.now() + Math.max(0, durationMs); + } } diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index 2560881d..31d5aa52 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -40,6 +40,9 @@ vi.mock('vscode', () => ({ }, showTextDocument: vi.fn(), showWorkspaceFolderPick: vi.fn(), + registerWebviewPanelSerializer: vi.fn(() => ({ + dispose: vi.fn(), + })), }, workspace: { workspaceFolders: [], diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 8e2344a9..c27a7e9d 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -14,6 +14,8 @@ import { IDE_DEFINITIONS, type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; +import { WebViewProvider } from './webview/WebViewProvider.js'; +import { registerNewCommands } from './commands/index.js'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; @@ -31,6 +33,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet = new Set([ let ideServer: IDEServer; let logger: vscode.OutputChannel; +let webViewProviders: WebViewProvider[] = []; // Track multiple chat tabs let log: (message: string) => void = () => {}; @@ -108,7 +111,75 @@ export async function activate(context: vscode.ExtensionContext) { checkForUpdates(context, log); const diffContentProvider = new DiffContentProvider(); - const diffManager = new DiffManager(log, diffContentProvider); + const diffManager = new DiffManager( + log, + diffContentProvider, + // Delay when any chat tab has a pending permission drawer + () => webViewProviders.some((p) => p.hasPendingPermission()), + // Suppress diffs when active mode is auto or yolo in any chat tab + () => { + const providers = webViewProviders.filter( + (p) => typeof p.shouldSuppressDiff === 'function', + ); + if (providers.length === 0) { + return false; + } + return providers.every((p) => p.shouldSuppressDiff()); + }, + ); + + // Helper function to create a new WebView provider instance + const createWebViewProvider = (): WebViewProvider => { + const provider = new WebViewProvider(context, context.extensionUri); + webViewProviders.push(provider); + return provider; + }; + + // Register WebView panel serializer for persistence across reloads + context.subscriptions.push( + vscode.window.registerWebviewPanelSerializer('qwenCode.chat', { + async deserializeWebviewPanel( + webviewPanel: vscode.WebviewPanel, + state: unknown, + ) { + console.log( + '[Extension] Deserializing WebView panel with state:', + state, + ); + + // Create a new provider for the restored panel + const provider = createWebViewProvider(); + console.log('[Extension] Provider created for deserialization'); + + // Restore state if available BEFORE restoring the panel + if (state && typeof state === 'object') { + console.log('[Extension] Restoring state:', state); + provider.restoreState( + state as { + conversationId: string | null; + agentInitialized: boolean; + }, + ); + } else { + console.log('[Extension] No state to restore or invalid state'); + } + + await provider.restorePanel(webviewPanel); + console.log('[Extension] Panel restore completed'); + + log('WebView panel restored from serialization'); + }, + }), + ); + + // Register newly added commands via commands module + registerNewCommands( + context, + log, + diffManager, + () => webViewProviders, + createWebViewProvider, + ); context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((doc) => { @@ -120,17 +191,53 @@ export async function activate(context: vscode.ExtensionContext) { DIFF_SCHEME, diffContentProvider, ), - vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => { + (vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => { const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.acceptDiff(docUri); } + // If WebView is requesting permission, actively select an allow option (prefer once) + try { + for (const provider of webViewProviders) { + if (provider?.hasPendingPermission()) { + provider.respondToPendingPermission('allow'); + } + } + } catch (err) { + console.warn('[Extension] Auto-allow on diff.accept failed:', err); + } + console.log('[Extension] Diff accepted'); }), vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => { const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.cancelDiff(docUri); } + // If WebView is requesting permission, actively select reject/cancel + try { + for (const provider of webViewProviders) { + if (provider?.hasPendingPermission()) { + provider.respondToPendingPermission('cancel'); + } + } + } catch (err) { + console.warn('[Extension] Auto-reject on diff.cancel failed:', err); + } + console.log('[Extension] Diff cancelled'); + })), + vscode.commands.registerCommand('qwen.diff.closeAll', async () => { + try { + await diffManager.closeAll(); + } catch (err) { + console.warn('[Extension] qwen.diff.closeAll failed:', err); + } + }), + vscode.commands.registerCommand('qwen.diff.suppressBriefly', async () => { + try { + diffManager.suppressFor(1200); + } catch (err) { + console.warn('[Extension] qwen.diff.suppressBriefly failed:', err); + } }), ); @@ -160,34 +267,49 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.onDidGrantWorkspaceTrust(() => { ideServer.syncEnvVars(); }), - vscode.commands.registerCommand('qwen-code.runQwenCode', async () => { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - vscode.window.showInformationMessage( - 'No folder open. Please open a folder to run Qwen Code.', - ); - return; - } + vscode.commands.registerCommand( + 'qwen-code.runQwenCode', + async ( + location?: + | vscode.TerminalLocation + | vscode.TerminalEditorLocationOptions, + ) => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showInformationMessage( + 'No folder open. Please open a folder to run Qwen Code.', + ); + return; + } - let selectedFolder: vscode.WorkspaceFolder | undefined; - if (workspaceFolders.length === 1) { - selectedFolder = workspaceFolders[0]; - } else { - selectedFolder = await vscode.window.showWorkspaceFolderPick({ - placeHolder: 'Select a folder to run Qwen Code in', - }); - } + let selectedFolder: vscode.WorkspaceFolder | undefined; + if (workspaceFolders.length === 1) { + selectedFolder = workspaceFolders[0]; + } else { + selectedFolder = await vscode.window.showWorkspaceFolderPick({ + placeHolder: 'Select a folder to run Qwen Code in', + }); + } - if (selectedFolder) { - const qwenCmd = 'qwen'; - const terminal = vscode.window.createTerminal({ - name: `Qwen Code (${selectedFolder.name})`, - cwd: selectedFolder.uri.fsPath, - }); - terminal.show(); - terminal.sendText(qwenCmd); - } - }), + if (selectedFolder) { + const cliEntry = vscode.Uri.joinPath( + context.extensionUri, + 'dist', + 'qwen-cli', + 'cli.js', + ).fsPath; + const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`; + const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`; + const terminal = vscode.window.createTerminal({ + name: `Qwen Code (${selectedFolder.name})`, + cwd: selectedFolder.uri.fsPath, + location, + }); + terminal.show(); + terminal.sendText(qwenCmd); + } + }, + ), vscode.commands.registerCommand('qwen-code.showNotices', async () => { const noticePath = vscode.Uri.joinPath( context.extensionUri, @@ -204,6 +326,11 @@ export async function deactivate(): Promise { if (ideServer) { await ideServer.stop(); } + // Dispose all WebView providers + webViewProviders.forEach((provider) => { + provider.dispose(); + }); + webViewProviders = []; } catch (err) { const message = err instanceof Error ? err.message : String(err); log(`Failed to stop IDE server during deactivation: ${message}`); diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index e67dfa81..69fabbc4 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -164,6 +164,7 @@ export class IDEServer { const allowedHosts = [ `localhost:${this.port}`, `127.0.0.1:${this.port}`, + `host.docker.internal:${this.port}`, // Add Docker support ]; if (!allowedHosts.includes(host)) { return res.status(403).json({ error: 'Invalid Host header' }); @@ -437,6 +438,7 @@ const createMcpServer = (diffManager: DiffManager) => { inputSchema: OpenDiffRequestSchema.shape, }, async ({ filePath, newContent }: z.infer) => { + // Minimal call site: only pass newContent; DiffManager reads old content itself await diffManager.showDiff(filePath, newContent); return { content: [] }; }, diff --git a/packages/vscode-ide-companion/src/open-files-manager.test.ts b/packages/vscode-ide-companion/src/open-files-manager.test.ts index 0b1ada82..74d18ffa 100644 --- a/packages/vscode-ide-companion/src/open-files-manager.test.ts +++ b/packages/vscode-ide-companion/src/open-files-manager.test.ts @@ -414,7 +414,7 @@ describe('OpenFilesManager', () => { await vi.advanceTimersByTimeAsync(100); file1 = manager.state.workspaceState!.openFiles!.find( - (f) => f.path === '/test/file1.txt', + (f: { path: string }) => f.path === '/test/file1.txt', )!; const file2 = manager.state.workspaceState!.openFiles![0]; diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts new file mode 100644 index 00000000..4b2c4028 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -0,0 +1,412 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { JSONRPC_VERSION } from '../types/acpTypes.js'; +import type { + AcpMessage, + AcpPermissionRequest, + AcpResponse, + AcpSessionUpdate, + AuthenticateUpdateNotification, +} from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import type { ChildProcess, SpawnOptions } from 'child_process'; +import { spawn } from 'child_process'; +import type { + PendingRequest, + AcpConnectionCallbacks, +} from '../types/connectionTypes.js'; +import { AcpMessageHandler } from './acpMessageHandler.js'; +import { AcpSessionManager } from './acpSessionManager.js'; +import * as fs from 'node:fs'; + +/** + * ACP Connection Handler for VSCode Extension + * + * This class implements the client side of the ACP (Agent Communication Protocol). + */ +export class AcpConnection { + private child: ChildProcess | null = null; + private pendingRequests = new Map>(); + private nextRequestId = { value: 0 }; + // Remember the working dir provided at connect() so later ACP calls + // that require cwd (e.g. session/list) can include it. + private workingDir: string = process.cwd(); + + private messageHandler: AcpMessageHandler; + private sessionManager: AcpSessionManager; + + onSessionUpdate: (data: AcpSessionUpdate) => void = () => {}; + onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + optionId: string; + }> = () => Promise.resolve({ optionId: 'allow' }); + onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void = + () => {}; + onEndTurn: () => void = () => {}; + // Called after successful initialize() with the initialize result + onInitialized: (init: unknown) => void = () => {}; + + constructor() { + this.messageHandler = new AcpMessageHandler(); + this.sessionManager = new AcpSessionManager(); + } + + /** + * Connect to Qwen ACP + * + * @param cliEntryPath - Path to the bundled CLI entrypoint (cli.js) + * @param workingDir - Working directory + * @param extraArgs - Extra command line arguments + */ + async connect( + cliEntryPath: string, + workingDir: string = process.cwd(), + extraArgs: string[] = [], + ): Promise { + if (this.child) { + this.disconnect(); + } + + this.workingDir = workingDir; + + const env = { ...process.env }; + + // If proxy is configured in extraArgs, also set it as environment variable + // This ensures token refresh requests also use the proxy + const proxyArg = extraArgs.find( + (arg, i) => arg === '--proxy' && i + 1 < extraArgs.length, + ); + if (proxyArg) { + const proxyIndex = extraArgs.indexOf('--proxy'); + const proxyUrl = extraArgs[proxyIndex + 1]; + console.log('[ACP] Setting proxy environment variables:', proxyUrl); + + env['HTTP_PROXY'] = proxyUrl; + env['HTTPS_PROXY'] = proxyUrl; + env['http_proxy'] = proxyUrl; + env['https_proxy'] = proxyUrl; + } + + // Always run the bundled CLI using the VS Code extension host's Node runtime. + // This avoids PATH/NVM/global install problems and ensures deterministic behavior. + const spawnCommand: string = process.execPath; + const spawnArgs: string[] = [ + cliEntryPath, + '--experimental-acp', + '--channel=VSCode', + ...extraArgs, + ]; + + if (!fs.existsSync(cliEntryPath)) { + throw new Error( + `Bundled Qwen CLI entry not found at ${cliEntryPath}. The extension may not have been packaged correctly.`, + ); + } + + console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' ')); + + const options: SpawnOptions = { + cwd: workingDir, + stdio: ['pipe', 'pipe', 'pipe'], + env, + // We spawn node directly; no shell needed (and shell quoting can break paths). + shell: false, + }; + + this.child = spawn(spawnCommand, spawnArgs, options); + await this.setupChildProcessHandlers(); + } + + /** + * Set up child process handlers + */ + private async setupChildProcessHandlers(): Promise { + let spawnError: Error | null = null; + + this.child!.stderr?.on('data', (data) => { + const message = data.toString(); + if ( + message.toLowerCase().includes('error') && + !message.includes('Loaded cached') + ) { + console.error(`[ACP qwen]:`, message); + } else { + console.log(`[ACP qwen]:`, message); + } + }); + + this.child!.on('error', (error) => { + spawnError = error; + }); + + this.child!.on('exit', (code, signal) => { + console.error( + `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, + ); + }); + + // Wait for process to start + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (spawnError) { + throw spawnError; + } + + if (!this.child || this.child.killed) { + throw new Error(`Qwen ACP process failed to start`); + } + + // Handle messages from ACP server + let buffer = ''; + this.child.stdout?.on('data', (data) => { + buffer += data.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const message = JSON.parse(line) as AcpMessage; + console.log( + '[ACP] <<< Received message:', + JSON.stringify(message).substring(0, 500 * 3), + ); + this.handleMessage(message); + } catch (_error) { + // Ignore non-JSON lines + console.log( + '[ACP] <<< Non-JSON line (ignored):', + line.substring(0, 200), + ); + } + } + } + }); + + // Initialize protocol + const res = await this.sessionManager.initialize( + this.child, + this.pendingRequests, + this.nextRequestId, + ); + + console.log('[ACP] Initialization response:', res); + try { + this.onInitialized(res); + } catch (err) { + console.warn('[ACP] onInitialized callback error:', err); + } + } + + /** + * Handle received messages + * + * @param message - ACP message + */ + private handleMessage(message: AcpMessage): void { + const callbacks: AcpConnectionCallbacks = { + onSessionUpdate: this.onSessionUpdate, + onPermissionRequest: this.onPermissionRequest, + onAuthenticateUpdate: this.onAuthenticateUpdate, + onEndTurn: this.onEndTurn, + }; + + // Handle message + if ('method' in message) { + // Request or notification + this.messageHandler + .handleIncomingRequest(message, callbacks) + .then((result) => { + if ('id' in message && typeof message.id === 'number') { + this.messageHandler.sendResponseMessage(this.child, { + jsonrpc: JSONRPC_VERSION, + id: message.id, + result, + }); + } + }) + .catch((error) => { + if ('id' in message && typeof message.id === 'number') { + this.messageHandler.sendResponseMessage(this.child, { + jsonrpc: JSONRPC_VERSION, + id: message.id, + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + }); + } + }); + } else { + // Response + this.messageHandler.handleMessage( + message, + this.pendingRequests, + callbacks, + ); + } + } + + /** + * Authenticate + * + * @param methodId - Authentication method ID + * @returns Authentication response + */ + async authenticate(methodId?: string): Promise { + return this.sessionManager.authenticate( + methodId, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Create new session + * + * @param cwd - Working directory + * @returns New session response + */ + async newSession(cwd: string = process.cwd()): Promise { + return this.sessionManager.newSession( + cwd, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Send prompt message + * + * @param prompt - Prompt content + * @returns Response + */ + async sendPrompt(prompt: string): Promise { + return this.sessionManager.sendPrompt( + prompt, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Load existing session + * + * @param sessionId - Session ID + * @returns Load response + */ + async loadSession( + sessionId: string, + cwdOverride?: string, + ): Promise { + return this.sessionManager.loadSession( + sessionId, + this.child, + this.pendingRequests, + this.nextRequestId, + cwdOverride || this.workingDir, + ); + } + + /** + * Get session list + * + * @returns Session list response + */ + async listSessions(options?: { + cursor?: number; + size?: number; + }): Promise { + return this.sessionManager.listSessions( + this.child, + this.pendingRequests, + this.nextRequestId, + this.workingDir, + options, + ); + } + + /** + * Switch to specified session + * + * @param sessionId - Session ID + * @returns Switch response + */ + async switchSession(sessionId: string): Promise { + return this.sessionManager.switchSession(sessionId, this.nextRequestId); + } + + /** + * Cancel current session prompt generation + */ + async cancelSession(): Promise { + await this.sessionManager.cancelSession(this.child); + } + + /** + * Save current session + * + * @param tag - Save tag + * @returns Save response + */ + async saveSession(tag: string): Promise { + return this.sessionManager.saveSession( + tag, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Set approval mode + */ + async setMode(modeId: ApprovalModeValue): Promise { + return this.sessionManager.setMode( + modeId, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Disconnect + */ + disconnect(): void { + if (this.child) { + this.child.kill(); + this.child = null; + } + + this.pendingRequests.clear(); + this.sessionManager.reset(); + } + + /** + * Check if connected + */ + get isConnected(): boolean { + return this.child !== null && !this.child.killed; + } + + /** + * Check if there is an active session + */ + get hasActiveSession(): boolean { + return this.sessionManager.getCurrentSessionId() !== null; + } + + /** + * Get current session ID + */ + get currentSessionId(): string | null { + return this.sessionManager.getCurrentSessionId(); + } +} diff --git a/packages/vscode-ide-companion/src/services/acpFileHandler.ts b/packages/vscode-ide-companion/src/services/acpFileHandler.ts new file mode 100644 index 00000000..8dce3c7b --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ACP File Operation Handler + * + * Responsible for handling file read and write operations in the ACP protocol + */ + +import { promises as fs } from 'fs'; +import * as path from 'path'; + +/** + * ACP File Operation Handler Class + * Provides file read and write functionality according to ACP protocol specifications + */ +export class AcpFileHandler { + /** + * Handle read text file request + * + * @param params - File read parameters + * @param params.path - File path + * @param params.sessionId - Session ID + * @param params.line - Starting line number (optional) + * @param params.limit - Read line limit (optional) + * @returns File content + * @throws Error when file reading fails + */ + async handleReadTextFile(params: { + path: string; + sessionId: string; + line: number | null; + limit: number | null; + }): Promise<{ content: string }> { + console.log(`[ACP] fs/read_text_file request received for: ${params.path}`); + console.log(`[ACP] Parameters:`, { + line: params.line, + limit: params.limit, + sessionId: params.sessionId, + }); + + try { + const content = await fs.readFile(params.path, 'utf-8'); + console.log( + `[ACP] Successfully read file: ${params.path} (${content.length} bytes)`, + ); + + // Handle line offset and limit + if (params.line !== null || params.limit !== null) { + const lines = content.split('\n'); + const startLine = params.line || 0; + const endLine = params.limit ? startLine + params.limit : lines.length; + const selectedLines = lines.slice(startLine, endLine); + const result = { content: selectedLines.join('\n') }; + console.log(`[ACP] Returning ${selectedLines.length} lines`); + return result; + } + + const result = { content }; + console.log(`[ACP] Returning full file content`); + return result; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg); + + throw new Error(`Failed to read file '${params.path}': ${errorMsg}`); + } + } + + /** + * Handle write text file request + * + * @param params - File write parameters + * @param params.path - File path + * @param params.content - File content + * @param params.sessionId - Session ID + * @returns null indicates success + * @throws Error when file writing fails + */ + async handleWriteTextFile(params: { + path: string; + content: string; + sessionId: string; + }): Promise { + console.log( + `[ACP] fs/write_text_file request received for: ${params.path}`, + ); + console.log(`[ACP] Content size: ${params.content.length} bytes`); + + try { + // Ensure directory exists + const dirName = path.dirname(params.path); + console.log(`[ACP] Ensuring directory exists: ${dirName}`); + await fs.mkdir(dirName, { recursive: true }); + + // Write file + await fs.writeFile(params.path, params.content, 'utf-8'); + + console.log(`[ACP] Successfully wrote file: ${params.path}`); + return null; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg); + + throw new Error(`Failed to write file '${params.path}': ${errorMsg}`); + } + } +} diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts new file mode 100644 index 00000000..8766fdf3 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ACP Message Handler + * + * Responsible for receiving, parsing, and distributing messages in the ACP protocol + */ + +import type { + AcpMessage, + AcpRequest, + AcpNotification, + AcpResponse, + AcpSessionUpdate, + AcpPermissionRequest, + AuthenticateUpdateNotification, +} from '../types/acpTypes.js'; +import { CLIENT_METHODS } from '../constants/acpSchema.js'; +import type { + PendingRequest, + AcpConnectionCallbacks, +} from '../types/connectionTypes.js'; +import { AcpFileHandler } from '../services/acpFileHandler.js'; +import type { ChildProcess } from 'child_process'; + +/** + * ACP Message Handler Class + * Responsible for receiving, parsing, and processing messages + */ +export class AcpMessageHandler { + private fileHandler: AcpFileHandler; + + constructor() { + this.fileHandler = new AcpFileHandler(); + } + + /** + * Send response message to child process + * + * @param child - Child process instance + * @param response - Response message + */ + sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void { + if (child?.stdin) { + const jsonString = JSON.stringify(response); + const lineEnding = process.platform === 'win32' ? '\r\n' : '\n'; + child.stdin.write(jsonString + lineEnding); + } + } + + /** + * Handle received messages + * + * @param message - ACP message + * @param pendingRequests - Pending requests map + * @param callbacks - Callback functions collection + */ + handleMessage( + message: AcpMessage, + pendingRequests: Map>, + callbacks: AcpConnectionCallbacks, + ): void { + try { + if ('method' in message) { + // Request or notification + this.handleIncomingRequest(message, callbacks).catch(() => {}); + } else if ( + 'id' in message && + typeof message.id === 'number' && + pendingRequests.has(message.id) + ) { + // Response + this.handleResponse(message, pendingRequests, callbacks); + } + } catch (error) { + console.error('[ACP] Error handling message:', error); + } + } + + /** + * Handle response message + * + * @param message - Response message + * @param pendingRequests - Pending requests map + * @param callbacks - Callback functions collection + */ + private handleResponse( + message: AcpMessage, + pendingRequests: Map>, + callbacks: AcpConnectionCallbacks, + ): void { + if (!('id' in message) || typeof message.id !== 'number') { + return; + } + + const pendingRequest = pendingRequests.get(message.id); + if (!pendingRequest) { + return; + } + + const { resolve, reject, method } = pendingRequest; + pendingRequests.delete(message.id); + + if ('result' in message) { + console.log( + `[ACP] Response for ${method}:`, + // JSON.stringify(message.result).substring(0, 200), + message.result, + ); + + if (message.result && typeof message.result === 'object') { + const stopReasonValue = + (message.result as { stopReason?: unknown }).stopReason ?? + (message.result as { stop_reason?: unknown }).stop_reason; + if (typeof stopReasonValue === 'string') { + callbacks.onEndTurn(stopReasonValue); + } else if ( + 'stopReason' in message.result || + 'stop_reason' in message.result + ) { + // stop_reason present but not a string (e.g., null) -> still emit + callbacks.onEndTurn(); + } + } + resolve(message.result); + } else if ('error' in message) { + const errorCode = message.error?.code || 'unknown'; + const errorMsg = message.error?.message || 'Unknown ACP error'; + const errorData = message.error?.data + ? JSON.stringify(message.error.data) + : ''; + console.error(`[ACP] Error response for ${method}:`, { + code: errorCode, + message: errorMsg, + data: errorData, + }); + reject( + new Error( + `${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`, + ), + ); + } + } + + /** + * Handle incoming requests + * + * @param message - Request or notification message + * @param callbacks - Callback functions collection + * @returns Request processing result + */ + async handleIncomingRequest( + message: AcpRequest | AcpNotification, + callbacks: AcpConnectionCallbacks, + ): Promise { + const { method, params } = message; + + let result = null; + + switch (method) { + case CLIENT_METHODS.session_update: + console.log( + '[ACP] >>> Processing session_update:', + JSON.stringify(params).substring(0, 300), + ); + callbacks.onSessionUpdate(params as AcpSessionUpdate); + break; + case CLIENT_METHODS.authenticate_update: + console.log( + '[ACP] >>> Processing authenticate_update:', + JSON.stringify(params).substring(0, 300), + ); + callbacks.onAuthenticateUpdate( + params as AuthenticateUpdateNotification, + ); + break; + case CLIENT_METHODS.session_request_permission: + result = await this.handlePermissionRequest( + params as AcpPermissionRequest, + callbacks, + ); + break; + case CLIENT_METHODS.fs_read_text_file: + result = await this.fileHandler.handleReadTextFile( + params as { + path: string; + sessionId: string; + line: number | null; + limit: number | null; + }, + ); + break; + case CLIENT_METHODS.fs_write_text_file: + result = await this.fileHandler.handleWriteTextFile( + params as { path: string; content: string; sessionId: string }, + ); + break; + default: + console.warn(`[ACP] Unhandled method: ${method}`); + break; + } + + return result; + } + + /** + * Handle permission requests + * + * @param params - Permission request parameters + * @param callbacks - Callback functions collection + * @returns Permission request result + */ + private async handlePermissionRequest( + params: AcpPermissionRequest, + callbacks: AcpConnectionCallbacks, + ): Promise<{ + outcome: { outcome: string; optionId: string }; + }> { + try { + const response = await callbacks.onPermissionRequest(params); + const optionId = response?.optionId; + console.log('[ACP] Permission request:', optionId); + // Handle cancel, deny, or allow + let outcome: string; + if (optionId && (optionId.includes('reject') || optionId === 'cancel')) { + outcome = 'cancelled'; + } else { + outcome = 'selected'; + } + console.log('[ACP] Permission outcome:', outcome); + + return { + outcome: { + outcome, + // optionId: optionId === 'cancel' ? 'cancel' : optionId, + optionId, + }, + }; + } catch (_error) { + return { + outcome: { + outcome: 'rejected', + optionId: 'reject_once', + }, + }; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts new file mode 100644 index 00000000..cfa299bf --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpSessionManager.ts @@ -0,0 +1,480 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ACP Session Manager + * + * Responsible for managing ACP protocol session operations, including initialization, authentication, session creation, and switching + */ +import { JSONRPC_VERSION } from '../types/acpTypes.js'; +import type { + AcpRequest, + AcpNotification, + AcpResponse, +} from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import { AGENT_METHODS } from '../constants/acpSchema.js'; +import type { PendingRequest } from '../types/connectionTypes.js'; +import type { ChildProcess } from 'child_process'; + +/** + * ACP Session Manager Class + * Provides session initialization, authentication, creation, loading, and switching functionality + */ +export class AcpSessionManager { + private sessionId: string | null = null; + private isInitialized = false; + + /** + * Send request to ACP server + * + * @param method - Request method name + * @param params - Request parameters + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Request response + */ + private sendRequest( + method: string, + params: Record | undefined, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + const id = nextRequestId.value++; + const message: AcpRequest = { + jsonrpc: JSONRPC_VERSION, + id, + method, + ...(params && { params }), + }; + + return new Promise((resolve, reject) => { + // different timeout durations based on methods + let timeoutDuration = 60000; // default 60 seconds + if ( + method === AGENT_METHODS.session_prompt || + method === AGENT_METHODS.initialize + ) { + timeoutDuration = 120000; // 2min for session_prompt and initialize + } + + const timeoutId = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error(`Request ${method} timed out`)); + }, timeoutDuration); + + const pendingRequest: PendingRequest = { + resolve: (value: T) => { + clearTimeout(timeoutId); + resolve(value); + }, + reject: (error: Error) => { + clearTimeout(timeoutId); + reject(error); + }, + timeoutId, + method, + }; + + pendingRequests.set(id, pendingRequest as PendingRequest); + this.sendMessage(message, child); + }); + } + + /** + * Send message to child process + * + * @param message - Request or notification message + * @param child - Child process instance + */ + private sendMessage( + message: AcpRequest | AcpNotification, + child: ChildProcess | null, + ): void { + if (child?.stdin) { + const jsonString = JSON.stringify(message); + const lineEnding = process.platform === 'win32' ? '\r\n' : '\n'; + child.stdin.write(jsonString + lineEnding); + } + } + + /** + * Initialize ACP protocol connection + * + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Initialization response + */ + async initialize( + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + const initializeParams = { + protocolVersion: 1, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }; + + console.log('[ACP] Sending initialize request...'); + const response = await this.sendRequest( + AGENT_METHODS.initialize, + initializeParams, + child, + pendingRequests, + nextRequestId, + ); + this.isInitialized = true; + + console.log('[ACP] Initialize successful'); + return response; + } + + /** + * Perform authentication + * + * @param methodId - Authentication method ID + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Authentication response + */ + async authenticate( + methodId: string | undefined, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + const authMethodId = methodId || 'default'; + console.log( + '[ACP] Sending authenticate request with methodId:', + authMethodId, + ); + const response = await this.sendRequest( + AGENT_METHODS.authenticate, + { + methodId: authMethodId, + }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] Authenticate successful', response); + return response; + } + + /** + * Create new session + * + * @param cwd - Working directory + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns New session response + */ + async newSession( + cwd: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + console.log('[ACP] Sending session/new request with cwd:', cwd); + const response = await this.sendRequest< + AcpResponse & { sessionId?: string } + >( + AGENT_METHODS.session_new, + { + cwd, + mcpServers: [], + }, + child, + pendingRequests, + nextRequestId, + ); + + this.sessionId = (response && response.sessionId) || null; + console.log('[ACP] Session created with ID:', this.sessionId); + return response; + } + + /** + * Send prompt message + * + * @param prompt - Prompt content + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Response + * @throws Error when there is no active session + */ + async sendPrompt( + prompt: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + + return await this.sendRequest( + AGENT_METHODS.session_prompt, + { + sessionId: this.sessionId, + prompt: [{ type: 'text', text: prompt }], + }, + child, + pendingRequests, + nextRequestId, + ); + } + + /** + * Load existing session + * + * @param sessionId - Session ID + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Load response + */ + async loadSession( + sessionId: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + cwd: string = process.cwd(), + ): Promise { + console.log('[ACP] Sending session/load request for session:', sessionId); + console.log('[ACP] Request parameters:', { + sessionId, + cwd, + mcpServers: [], + }); + + try { + const response = await this.sendRequest( + AGENT_METHODS.session_load, + { + sessionId, + cwd, + mcpServers: [], + }, + child, + pendingRequests, + nextRequestId, + ); + + console.log( + '[ACP] Session load response:', + JSON.stringify(response).substring(0, 500), + ); + + // Check if response contains an error + if (response && response.error) { + console.error('[ACP] Session load returned error:', response.error); + } else { + console.log('[ACP] Session load succeeded'); + // session/load returns null on success per schema; update local sessionId + // so subsequent prompts use the loaded session. + this.sessionId = sessionId; + } + + return response; + } catch (error) { + console.error( + '[ACP] Session load request failed with exception:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + /** + * Get session list + * + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Session list response + */ + async listSessions( + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + cwd: string = process.cwd(), + options?: { cursor?: number; size?: number }, + ): Promise { + console.log('[ACP] Requesting session list...'); + try { + // session/list requires cwd in params per ACP schema + const params: Record = { cwd }; + if (options?.cursor !== undefined) { + params.cursor = options.cursor; + } + if (options?.size !== undefined) { + params.size = options.size; + } + + const response = await this.sendRequest( + AGENT_METHODS.session_list, + params, + child, + pendingRequests, + nextRequestId, + ); + console.log( + '[ACP] Session list response:', + JSON.stringify(response).substring(0, 200), + ); + return response; + } catch (error) { + console.error('[ACP] Failed to get session list:', error); + throw error; + } + } + + /** + * Set approval mode for current session (ACP session/set_mode) + * + * @param modeId - Approval mode value + */ + async setMode( + modeId: ApprovalModeValue, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + console.log('[ACP] Sending session/set_mode:', modeId); + const res = await this.sendRequest( + AGENT_METHODS.session_set_mode, + { sessionId: this.sessionId, modeId }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] set_mode response:', res); + return res; + } + + /** + * Switch to specified session + * + * @param sessionId - Session ID + * @param nextRequestId - Request ID counter + * @returns Switch response + */ + async switchSession( + sessionId: string, + nextRequestId: { value: number }, + ): Promise { + console.log('[ACP] Switching to session:', sessionId); + this.sessionId = sessionId; + + const mockResponse: AcpResponse = { + jsonrpc: JSONRPC_VERSION, + id: nextRequestId.value++, + result: { sessionId }, + }; + console.log( + '[ACP] Session ID updated locally (switch not supported by CLI)', + ); + return mockResponse; + } + + /** + * Cancel prompt generation for current session + * + * @param child - Child process instance + */ + async cancelSession(child: ChildProcess | null): Promise { + if (!this.sessionId) { + console.warn('[ACP] No active session to cancel'); + return; + } + + console.log('[ACP] Cancelling session:', this.sessionId); + + const cancelParams = { + sessionId: this.sessionId, + }; + + const message: AcpNotification = { + jsonrpc: JSONRPC_VERSION, + method: AGENT_METHODS.session_cancel, + params: cancelParams, + }; + + this.sendMessage(message, child); + console.log('[ACP] Cancel notification sent'); + } + + /** + * Save current session + * + * @param tag - Save tag + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Save response + */ + async saveSession( + tag: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + + console.log('[ACP] Saving session with tag:', tag); + const response = await this.sendRequest( + AGENT_METHODS.session_save, + { + sessionId: this.sessionId, + tag, + }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] Session save response:', response); + return response; + } + + /** + * Reset session manager state + */ + reset(): void { + this.sessionId = null; + this.isInitialized = false; + } + + /** + * Get current session ID + */ + getCurrentSessionId(): string | null { + return this.sessionId; + } + + /** + * Check if initialized + */ + getIsInitialized(): boolean { + return this.isInitialized; + } +} diff --git a/packages/vscode-ide-companion/src/services/conversationStore.ts b/packages/vscode-ide-companion/src/services/conversationStore.ts new file mode 100644 index 00000000..8a31af9c --- /dev/null +++ b/packages/vscode-ide-companion/src/services/conversationStore.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as vscode from 'vscode'; +import type { ChatMessage } from './qwenAgentManager.js'; + +export interface Conversation { + id: string; + title: string; + messages: ChatMessage[]; + createdAt: number; + updatedAt: number; +} + +export class ConversationStore { + private context: vscode.ExtensionContext; + private currentConversationId: string | null = null; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + } + + async createConversation(title: string = 'New Chat'): Promise { + const conversation: Conversation = { + id: `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + title, + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const conversations = await this.getAllConversations(); + conversations.push(conversation); + await this.context.globalState.update('conversations', conversations); + + this.currentConversationId = conversation.id; + return conversation; + } + + async getAllConversations(): Promise { + return this.context.globalState.get('conversations', []); + } + + async getConversation(id: string): Promise { + const conversations = await this.getAllConversations(); + return conversations.find((c) => c.id === id) || null; + } + + async addMessage( + conversationId: string, + message: ChatMessage, + ): Promise { + const conversations = await this.getAllConversations(); + const conversation = conversations.find((c) => c.id === conversationId); + + if (conversation) { + conversation.messages.push(message); + conversation.updatedAt = Date.now(); + await this.context.globalState.update('conversations', conversations); + } + } + + async deleteConversation(id: string): Promise { + const conversations = await this.getAllConversations(); + const filtered = conversations.filter((c) => c.id !== id); + await this.context.globalState.update('conversations', filtered); + + if (this.currentConversationId === id) { + this.currentConversationId = null; + } + } + + getCurrentConversationId(): string | null { + return this.currentConversationId; + } + + setCurrentConversationId(id: string): void { + this.currentConversationId = id; + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts new file mode 100644 index 00000000..e60ee3a2 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -0,0 +1,1280 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { AcpConnection } from './acpConnection.js'; +import type { + AcpSessionUpdate, + AcpPermissionRequest, + AuthenticateUpdateNotification, +} from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; +import { QwenSessionManager } from './qwenSessionManager.js'; +import type { + ChatMessage, + PlanEntry, + ToolCallUpdateData, + QwenAgentCallbacks, +} from '../types/chatTypes.js'; +import { + QwenConnectionHandler, + type QwenConnectionResult, +} from '../services/qwenConnectionHandler.js'; +import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; +import { authMethod } from '../types/acpTypes.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; +import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js'; + +export type { ChatMessage, PlanEntry, ToolCallUpdateData }; + +/** + * Qwen Agent Manager + * + * Coordinates various modules and provides unified interface + */ +interface AgentConnectOptions { + autoAuthenticate?: boolean; +} +interface AgentSessionOptions { + autoAuthenticate?: boolean; +} + +export class QwenAgentManager { + private connection: AcpConnection; + private sessionReader: QwenSessionReader; + private sessionManager: QwenSessionManager; + private connectionHandler: QwenConnectionHandler; + private sessionUpdateHandler: QwenSessionUpdateHandler; + private currentWorkingDir: string = process.cwd(); + // When loading a past session via ACP, the CLI replays history through + // session/update notifications. We set this flag to route message chunks + // (user/assistant) as discrete chat messages instead of live streaming. + private rehydratingSessionId: string | null = null; + // CLI is now the single source of truth for authentication state + // Deduplicate concurrent session/new attempts + private sessionCreateInFlight: Promise | null = null; + + // Callback storage + private callbacks: QwenAgentCallbacks = {}; + + constructor() { + this.connection = new AcpConnection(); + this.sessionReader = new QwenSessionReader(); + this.sessionManager = new QwenSessionManager(); + this.connectionHandler = new QwenConnectionHandler(); + this.sessionUpdateHandler = new QwenSessionUpdateHandler({}); + + // Set ACP connection callbacks + this.connection.onSessionUpdate = (data: AcpSessionUpdate) => { + // If we are rehydrating a loaded session, map message chunks into + // full messages for the UI, instead of streaming behavior. + try { + const targetId = this.rehydratingSessionId; + if ( + targetId && + typeof data === 'object' && + data && + 'update' in data && + (data as { sessionId?: string }).sessionId === targetId + ) { + const update = ( + data as unknown as { + update: { sessionUpdate: string; content?: { text?: string } }; + } + ).update; + const text = update?.content?.text || ''; + if (update?.sessionUpdate === 'user_message_chunk' && text) { + console.log( + '[QwenAgentManager] Rehydration: routing user message chunk', + ); + this.callbacks.onMessage?.({ + role: 'user', + content: text, + timestamp: Date.now(), + }); + return; + } + if (update?.sessionUpdate === 'agent_message_chunk' && text) { + console.log( + '[QwenAgentManager] Rehydration: routing agent message chunk', + ); + this.callbacks.onMessage?.({ + role: 'assistant', + content: text, + timestamp: Date.now(), + }); + return; + } + // For other types during rehydration, fall through to normal handler + console.log( + '[QwenAgentManager] Rehydration: non-text update, forwarding to handler', + ); + } + } catch (err) { + console.warn('[QwenAgentManager] Rehydration routing failed:', err); + } + + // Default handling path + this.sessionUpdateHandler.handleSessionUpdate(data); + }; + + this.connection.onPermissionRequest = async ( + data: AcpPermissionRequest, + ) => { + if (this.callbacks.onPermissionRequest) { + const optionId = await this.callbacks.onPermissionRequest(data); + return { optionId }; + } + return { optionId: 'allow_once' }; + }; + + this.connection.onEndTurn = (reason?: string) => { + try { + if (this.callbacks.onEndTurn) { + this.callbacks.onEndTurn(reason); + } else if (this.callbacks.onStreamChunk) { + // Fallback: send a zero-length chunk then rely on streamEnd elsewhere + this.callbacks.onStreamChunk(''); + } + } catch (err) { + console.warn('[QwenAgentManager] onEndTurn callback error:', err); + } + }; + + this.connection.onAuthenticateUpdate = ( + data: AuthenticateUpdateNotification, + ) => { + try { + // Handle authentication update notifications by showing VS Code notification + handleAuthenticateUpdate(data); + } catch (err) { + console.warn( + '[QwenAgentManager] onAuthenticateUpdate callback error:', + err, + ); + } + }; + + // Initialize callback to surface available modes and current mode to UI + this.connection.onInitialized = (init: unknown) => { + try { + const obj = (init || {}) as Record; + const modes = obj['modes'] as + | { + currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo'; + availableModes?: Array<{ + id: 'plan' | 'default' | 'auto-edit' | 'yolo'; + name: string; + description: string; + }>; + } + | undefined; + if (modes && this.callbacks.onModeInfo) { + this.callbacks.onModeInfo({ + currentModeId: modes.currentModeId, + availableModes: modes.availableModes, + }); + } + } catch (err) { + console.warn('[QwenAgentManager] onInitialized parse error:', err); + } + }; + } + + /** + * Connect to Qwen service + * + * @param workingDir - Working directory + * @param cliEntryPath - Path to bundled CLI entrypoint (cli.js) + */ + async connect( + workingDir: string, + cliEntryPath: string, + options?: AgentConnectOptions, + ): Promise { + this.currentWorkingDir = workingDir; + return this.connectionHandler.connect( + this.connection, + workingDir, + cliEntryPath, + options, + ); + } + + /** + * Send message + * + * @param message - Message content + */ + async sendMessage(message: string): Promise { + await this.connection.sendPrompt(message); + } + + /** + * Set approval mode from UI + */ + async setApprovalModeFromUi( + mode: ApprovalModeValue, + ): Promise { + const modeId = mode; + try { + const res = await this.connection.setMode(modeId); + // Optimistically notify UI using response + const result = (res?.result || {}) as { modeId?: string }; + const confirmed = + (result.modeId as + | 'plan' + | 'default' + | 'auto-edit' + | 'yolo' + | undefined) || modeId; + this.callbacks.onModeChanged?.(confirmed); + return confirmed; + } catch (err) { + console.error('[QwenAgentManager] Failed to set mode:', err); + throw err; + } + } + + /** + * Validate if current session is still active + * This is a lightweight check to verify session validity + * + * @returns True if session is valid, false otherwise + */ + async validateCurrentSession(): Promise { + try { + // If we don't have a current session, it's definitely not valid + if (!this.connection.currentSessionId) { + return false; + } + + // Try to get session list to verify our session still exists + const sessions = await this.getSessionList(); + const currentSessionId = this.connection.currentSessionId; + + // Check if our current session exists in the session list + const sessionExists = sessions.some( + (session: Record) => + session.id === currentSessionId || + session.sessionId === currentSessionId, + ); + + return sessionExists; + } catch (error) { + console.warn('[QwenAgentManager] Session validation failed:', error); + // If we can't validate, assume session is invalid + return false; + } + } + + /** + * Get session list with version-aware strategy + * First tries ACP method if CLI version supports it, falls back to file system method + * + * @returns Session list + */ + async getSessionList(): Promise>> { + console.log( + '[QwenAgentManager] Getting session list with version-aware strategy', + ); + + try { + console.log( + '[QwenAgentManager] Attempting to get session list via ACP method', + ); + const response = await this.connection.listSessions(); + console.log('[QwenAgentManager] ACP session list response:', response); + + // sendRequest resolves with the JSON-RPC "result" directly + // Newer CLI returns an object: { items: [...], nextCursor?, hasMore } + // Older prototypes might return an array. Support both. + const res: unknown = response; + let items: Array> = []; + + // Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC + // "result" directly (not the full AcpResponse). Treat it as unknown + // and carefully narrow before accessing `items` to satisfy strict TS. + if (res && typeof res === 'object' && 'items' in res) { + const itemsValue = (res as { items?: unknown }).items; + items = Array.isArray(itemsValue) + ? (itemsValue as Array>) + : []; + } + + console.log( + '[QwenAgentManager] Sessions retrieved via ACP:', + res, + items.length, + ); + if (items.length > 0) { + const sessions = items.map((item) => ({ + id: item.sessionId || item.id, + sessionId: item.sessionId || item.id, + title: item.title || item.name || item.prompt || 'Untitled Session', + name: item.title || item.name || item.prompt || 'Untitled Session', + startTime: item.startTime, + lastUpdated: item.mtime || item.lastUpdated, + messageCount: item.messageCount || 0, + projectHash: item.projectHash, + filePath: item.filePath, + cwd: item.cwd, + })); + + console.log( + '[QwenAgentManager] Sessions retrieved via ACP:', + sessions.length, + ); + return sessions; + } + } catch (error) { + console.warn( + '[QwenAgentManager] ACP session list failed, falling back to file system method:', + error, + ); + } + + // Always fall back to file system method + try { + console.log('[QwenAgentManager] Getting session list from file system'); + const sessions = await this.sessionReader.getAllSessions(undefined, true); + console.log( + '[QwenAgentManager] Session list from file system (all projects):', + sessions.length, + ); + + const result = sessions.map( + (session: QwenSession): Record => ({ + id: session.sessionId, + sessionId: session.sessionId, + title: this.sessionReader.getSessionTitle(session), + name: this.sessionReader.getSessionTitle(session), + startTime: session.startTime, + lastUpdated: session.lastUpdated, + messageCount: session.messageCount ?? session.messages.length, + projectHash: session.projectHash, + filePath: session.filePath, + cwd: session.cwd, + }), + ); + + console.log( + '[QwenAgentManager] Sessions retrieved from file system:', + result.length, + ); + return result; + } catch (error) { + console.error( + '[QwenAgentManager] Failed to get session list from file system:', + error, + ); + return []; + } + } + + /** + * Get session list (paged) + * Uses ACP session/list with cursor-based pagination when available. + * Falls back to file system scan with equivalent pagination semantics. + */ + async getSessionListPaged(params?: { + cursor?: number; + size?: number; + }): Promise<{ + sessions: Array>; + nextCursor?: number; + hasMore: boolean; + }> { + const size = params?.size ?? 20; + const cursor = params?.cursor; + + try { + const response = await this.connection.listSessions({ + size, + ...(cursor !== undefined ? { cursor } : {}), + }); + // sendRequest resolves with the JSON-RPC "result" directly + const res: unknown = response; + let items: Array> = []; + + if (Array.isArray(res)) { + items = res; + } else if (typeof res === 'object' && res !== null && 'items' in res) { + const responseObject = res as { + items?: Array>; + }; + items = Array.isArray(responseObject.items) ? responseObject.items : []; + } + + const mapped = items.map((item) => ({ + id: item.sessionId || item.id, + sessionId: item.sessionId || item.id, + title: item.title || item.name || item.prompt || 'Untitled Session', + name: item.title || item.name || item.prompt || 'Untitled Session', + startTime: item.startTime, + lastUpdated: item.mtime || item.lastUpdated, + messageCount: item.messageCount || 0, + projectHash: item.projectHash, + filePath: item.filePath, + cwd: item.cwd, + })); + + const nextCursor: number | undefined = + typeof res === 'object' && res !== null && 'nextCursor' in res + ? typeof res.nextCursor === 'number' + ? res.nextCursor + : undefined + : undefined; + const hasMore: boolean = + typeof res === 'object' && res !== null && 'hasMore' in res + ? Boolean(res.hasMore) + : false; + + return { sessions: mapped, nextCursor, hasMore }; + } catch (error) { + console.warn('[QwenAgentManager] Paged ACP session list failed:', error); + // fall through to file system + } + + // Fallback: file system for current project only (to match ACP semantics) + try { + const all = await this.sessionReader.getAllSessions( + this.currentWorkingDir, + false, + ); + // Sorted by lastUpdated desc already per reader + const allWithMtime = all.map((s) => ({ + raw: s, + mtime: new Date(s.lastUpdated).getTime(), + })); + const filtered = + cursor !== undefined + ? allWithMtime.filter((x) => x.mtime < cursor) + : allWithMtime; + const page = filtered.slice(0, size); + const sessions = page.map((x) => ({ + id: x.raw.sessionId, + sessionId: x.raw.sessionId, + title: this.sessionReader.getSessionTitle(x.raw), + name: this.sessionReader.getSessionTitle(x.raw), + startTime: x.raw.startTime, + lastUpdated: x.raw.lastUpdated, + messageCount: x.raw.messageCount ?? x.raw.messages.length, + projectHash: x.raw.projectHash, + filePath: x.raw.filePath, + cwd: x.raw.cwd, + })); + const nextCursorVal = + page.length > 0 ? page[page.length - 1].mtime : undefined; + const hasMore = filtered.length > size; + return { sessions, nextCursor: nextCursorVal, hasMore }; + } catch (error) { + console.error('[QwenAgentManager] File system paged list failed:', error); + return { sessions: [], hasMore: false }; + } + } + + /** + * Get session messages (read from disk) + * + * @param sessionId - Session ID + * @returns Message list + */ + async getSessionMessages(sessionId: string): Promise { + try { + try { + const list = await this.getSessionList(); + const item = list.find( + (s) => s.sessionId === sessionId || s.id === sessionId, + ); + console.log( + '[QwenAgentManager] Session list item for filePath lookup:', + item, + ); + if ( + typeof item === 'object' && + item !== null && + 'filePath' in item && + typeof item.filePath === 'string' + ) { + const messages = await this.readJsonlMessages(item.filePath); + // Even if messages array is empty, we should return it rather than falling back + // This ensures we don't accidentally show messages from a different session format + return messages; + } + } catch (e) { + console.warn('[QwenAgentManager] JSONL read path lookup failed:', e); + } + + // Fallback: legacy JSON session files + const session = await this.sessionReader.getSession( + sessionId, + this.currentWorkingDir, + ); + if (!session) { + return []; + } + return session.messages.map( + (msg: { type: string; content: string; timestamp: string }) => ({ + role: msg.type === 'user' ? 'user' : 'assistant', + content: msg.content, + timestamp: new Date(msg.timestamp).getTime(), + }), + ); + } catch (error) { + console.error( + '[QwenAgentManager] Failed to get session messages:', + error, + ); + return []; + } + } + + // Read CLI JSONL session file and convert to ChatMessage[] for UI + private async readJsonlMessages(filePath: string): Promise { + const fs = await import('fs'); + const readline = await import('readline'); + try { + if (!fs.existsSync(filePath)) { + return []; + } + const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + const records: unknown[] = []; + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const obj = JSON.parse(trimmed); + records.push(obj); + } catch { + /* ignore */ + } + } + // Simple linear reconstruction: filter user/assistant and sort by timestamp + console.log( + '[QwenAgentManager] JSONL records read:', + records.length, + filePath, + ); + + // Include all types of records, not just user/assistant + // Narrow unknown JSONL rows into a minimal shape we can work with. + type JsonlRecord = { + type: string; + timestamp: string; + message?: unknown; + toolCallResult?: { callId?: string; status?: string } | unknown; + subtype?: string; + systemPayload?: { uiEvent?: Record } | unknown; + plan?: { entries?: Array> } | unknown; + }; + + const isJsonlRecord = (x: unknown): x is JsonlRecord => + typeof x === 'object' && + x !== null && + typeof (x as Record).type === 'string' && + typeof (x as Record).timestamp === 'string'; + + const allRecords = records + .filter(isJsonlRecord) + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + const msgs: ChatMessage[] = []; + for (const r of allRecords) { + // Handle user and assistant messages + if ((r.type === 'user' || r.type === 'assistant') && r.message) { + msgs.push({ + role: + r.type === 'user' ? ('user' as const) : ('assistant' as const), + content: this.contentToText(r.message), + timestamp: new Date(r.timestamp).getTime(), + }); + } + // Handle tool call records that might have content we want to show + else if (r.type === 'tool_call' || r.type === 'tool_call_update') { + // Convert tool calls to messages if they have relevant content + const toolContent = this.extractToolCallContent(r as unknown); + if (toolContent) { + msgs.push({ + role: 'assistant', + content: toolContent, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle tool result records + else if ( + r.type === 'tool_result' && + r.toolCallResult && + typeof r.toolCallResult === 'object' + ) { + const toolResult = r.toolCallResult as { + callId?: string; + status?: string; + }; + const callId = toolResult.callId ?? 'unknown'; + const status = toolResult.status ?? 'unknown'; + const resultText = `Tool Result (${callId}): ${status}`; + msgs.push({ + role: 'assistant', + content: resultText, + timestamp: new Date(r.timestamp).getTime(), + }); + } + // Handle system telemetry records + else if ( + r.type === 'system' && + r.subtype === 'ui_telemetry' && + r.systemPayload && + typeof r.systemPayload === 'object' && + 'uiEvent' in r.systemPayload && + (r.systemPayload as { uiEvent?: Record }).uiEvent + ) { + const uiEvent = ( + r.systemPayload as { + uiEvent?: Record; + } + ).uiEvent as Record; + let telemetryText = ''; + + if ( + typeof uiEvent['event.name'] === 'string' && + (uiEvent['event.name'] as string).includes('tool_call') + ) { + const functionName = + (uiEvent['function_name'] as string | undefined) || + 'Unknown tool'; + const status = + (uiEvent['status'] as string | undefined) || 'unknown'; + const duration = + typeof uiEvent['duration_ms'] === 'number' + ? ` (${uiEvent['duration_ms']}ms)` + : ''; + telemetryText = `Tool Call: ${functionName} - ${status}${duration}`; + } else if ( + typeof uiEvent['event.name'] === 'string' && + (uiEvent['event.name'] as string).includes('api_response') + ) { + const statusCode = + (uiEvent['status_code'] as string | number | undefined) || + 'unknown'; + const duration = + typeof uiEvent['duration_ms'] === 'number' + ? ` (${uiEvent['duration_ms']}ms)` + : ''; + telemetryText = `API Response: Status ${statusCode}${duration}`; + } else { + // Generic system telemetry + const eventName = + (uiEvent['event.name'] as string | undefined) || 'Unknown event'; + telemetryText = `System Event: ${eventName}`; + } + + if (telemetryText) { + msgs.push({ + role: 'assistant', + content: telemetryText, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle plan entries + else if ( + r.type === 'plan' && + r.plan && + typeof r.plan === 'object' && + 'entries' in r.plan + ) { + const planEntries = + ((r.plan as { entries?: Array> }) + .entries as Array> | undefined) || []; + if (planEntries.length > 0) { + const planText = planEntries + .map( + (entry: Record, index: number) => + `${index + 1}. ${ + entry.description || entry.title || 'Unnamed step' + }`, + ) + .join('\n'); + msgs.push({ + role: 'assistant', + content: `Plan:\n${planText}`, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle other types if needed + } + + console.log( + '[QwenAgentManager] JSONL messages reconstructed:', + msgs.length, + ); + return msgs; + } catch (err) { + console.warn('[QwenAgentManager] Failed to read JSONL messages:', err); + return []; + } + } + + // Extract meaningful content from tool call records + private extractToolCallContent(record: unknown): string | null { + try { + // Type guard for record + if (typeof record !== 'object' || record === null) { + return null; + } + + // Cast to a more specific type for easier handling + const typedRecord = record as Record; + + // If the tool call has a result or output, include it + if ('toolCallResult' in typedRecord && typedRecord.toolCallResult) { + return `Tool result: ${this.formatValue(typedRecord.toolCallResult)}`; + } + + // If the tool call has content, include it + if ('content' in typedRecord && typedRecord.content) { + return this.formatValue(typedRecord.content); + } + + // If the tool call has a title or name, include it + if ( + ('title' in typedRecord && typedRecord.title) || + ('name' in typedRecord && typedRecord.name) + ) { + return `Tool: ${typedRecord.title || typedRecord.name}`; + } + + // Handle tool_call records with more details + if ( + typedRecord.type === 'tool_call' && + 'toolCall' in typedRecord && + typedRecord.toolCall + ) { + const toolCall = typedRecord.toolCall as Record; + if ( + ('title' in toolCall && toolCall.title) || + ('name' in toolCall && toolCall.name) + ) { + return `Tool call: ${toolCall.title || toolCall.name}`; + } + if ('rawInput' in toolCall && toolCall.rawInput) { + return `Tool input: ${this.formatValue(toolCall.rawInput)}`; + } + } + + // Handle tool_call_update records with status + if (typedRecord.type === 'tool_call_update') { + const status = + ('status' in typedRecord && typedRecord.status) || 'unknown'; + const title = + ('title' in typedRecord && typedRecord.title) || + ('name' in typedRecord && typedRecord.name) || + 'Unknown tool'; + return `Tool ${status}: ${title}`; + } + + return null; + } catch { + return null; + } + } + + // Format any value to a string for display + private formatValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (_e) { + return String(value); + } + } + return String(value); + } + + // Extract plain text from Content (genai Content) + private contentToText(message: unknown): string { + try { + // Type guard for message + if (typeof message !== 'object' || message === null) { + return ''; + } + + // Cast to a more specific type for easier handling + const typedMessage = message as Record; + + const parts = Array.isArray(typedMessage.parts) ? typedMessage.parts : []; + const texts: string[] = []; + for (const p of parts) { + // Type guard for part + if (typeof p !== 'object' || p === null) { + continue; + } + + const typedPart = p as Record; + if (typeof typedPart.text === 'string') { + texts.push(typedPart.text); + } else if (typeof typedPart.data === 'string') { + texts.push(typedPart.data); + } + } + return texts.join('\n'); + } catch { + return ''; + } + } + + /** + * Save session via /chat save command + * Since CLI doesn't support session/save ACP method, we send /chat save command directly + * + * @param sessionId - Session ID + * @param tag - Save tag + * @returns Save response + */ + async saveSessionViaCommand( + sessionId: string, + tag: string, + ): Promise<{ success: boolean; message?: string }> { + try { + console.log( + '[QwenAgentManager] Saving session via /chat save command:', + sessionId, + 'with tag:', + tag, + ); + + // Send /chat save command as a prompt + // The CLI will handle this as a special command + await this.connection.sendPrompt(`/chat save "${tag}"`); + + console.log('[QwenAgentManager] /chat save command sent successfully'); + return { + success: true, + message: `Session saved with tag: ${tag}`, + }; + } catch (error) { + console.error('[QwenAgentManager] /chat save command failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Save session via ACP session/save method (deprecated, CLI doesn't support) + * + * @deprecated Use saveSessionViaCommand instead + * @param sessionId - Session ID + * @param tag - Save tag + * @returns Save response + */ + async saveSessionViaAcp( + sessionId: string, + tag: string, + ): Promise<{ success: boolean; message?: string }> { + // Fallback to command-based save since CLI doesn't support session/save ACP method + console.warn( + '[QwenAgentManager] saveSessionViaAcp is deprecated, using command-based save instead', + ); + return this.saveSessionViaCommand(sessionId, tag); + } + + /** + * Try to load session via ACP session/load method + * This method will only be used if CLI version supports it + * + * @param sessionId - Session ID + * @returns Load response or error + */ + async loadSessionViaAcp( + sessionId: string, + cwdOverride?: string, + ): Promise { + try { + // Route upcoming session/update messages as discrete messages for replay + this.rehydratingSessionId = sessionId; + console.log( + '[QwenAgentManager] Rehydration start for session:', + sessionId, + ); + console.log( + '[QwenAgentManager] Attempting session/load via ACP for session:', + sessionId, + ); + const response = await this.connection.loadSession( + sessionId, + cwdOverride, + ); + console.log( + '[QwenAgentManager] Session load succeeded. Response:', + JSON.stringify(response).substring(0, 200), + ); + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + '[QwenAgentManager] Session load via ACP failed for session:', + sessionId, + ); + console.error('[QwenAgentManager] Error type:', error?.constructor?.name); + console.error('[QwenAgentManager] Error message:', errorMessage); + + // Check if error is from ACP response + if (error && typeof error === 'object') { + // Safely check if 'error' property exists + if ('error' in error) { + const acpError = error as { + error?: { code?: number; message?: string }; + }; + if (acpError.error) { + console.error( + '[QwenAgentManager] ACP error code:', + acpError.error.code, + ); + console.error( + '[QwenAgentManager] ACP error message:', + acpError.error.message, + ); + } + } else { + console.error('[QwenAgentManager] Non-ACPIf error details:', error); + } + } + + throw error; + } finally { + // End rehydration routing regardless of outcome + console.log('[QwenAgentManager] Rehydration end for session:', sessionId); + this.rehydratingSessionId = null; + } + } + + /** + * Load session with version-aware strategy + * First tries ACP method if CLI version supports it, falls back to file system method + * + * @param sessionId - Session ID to load + * @returns Loaded session messages or null + */ + async loadSession(sessionId: string): Promise { + console.log( + '[QwenAgentManager] Loading session with version-aware strategy:', + sessionId, + ); + + try { + console.log( + '[QwenAgentManager] Attempting to load session via ACP method', + ); + await this.loadSessionViaAcp(sessionId); + console.log('[QwenAgentManager] Session loaded successfully via ACP'); + + // After loading via ACP, we still need to get messages from file system + // In future, we might get them directly from the ACP response + } catch (error) { + console.warn( + '[QwenAgentManager] ACP session load failed, falling back to file system method:', + error, + ); + } + + // Always fall back to file system method + try { + console.log( + '[QwenAgentManager] Loading session messages from file system', + ); + const messages = await this.loadSessionMessagesFromFile(sessionId); + console.log( + '[QwenAgentManager] Session messages loaded successfully from file system', + ); + return messages; + } catch (error) { + console.error( + '[QwenAgentManager] Failed to load session messages from file system:', + error, + ); + return null; + } + } + + /** + * Load session messages from file system + * + * @param sessionId - Session ID to load + * @returns Loaded session messages + */ + private async loadSessionMessagesFromFile( + sessionId: string, + ): Promise { + try { + console.log( + '[QwenAgentManager] Loading session from file system:', + sessionId, + ); + + // Load session from file system + const session = await this.sessionManager.loadSession( + sessionId, + this.currentWorkingDir, + ); + + if (!session) { + console.log( + '[QwenAgentManager] Session not found in file system:', + sessionId, + ); + return null; + } + + // Convert message format + const messages: ChatMessage[] = session.messages.map((msg) => ({ + role: msg.type === 'user' ? 'user' : 'assistant', + content: msg.content, + timestamp: new Date(msg.timestamp).getTime(), + })); + + return messages; + } catch (error) { + console.error( + '[QwenAgentManager] Session load from file system failed:', + error, + ); + throw error; + } + } + + /** + * Create new session + * + * Note: Authentication should be done in connect() method, only create session here + * + * @param workingDir - Working directory + * @returns Newly created session ID + */ + async createNewSession( + workingDir: string, + options?: AgentSessionOptions, + ): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; + // Reuse existing session if present + if (this.connection.currentSessionId) { + return this.connection.currentSessionId; + } + // Deduplicate concurrent session/new attempts + if (this.sessionCreateInFlight) { + return this.sessionCreateInFlight; + } + + console.log('[QwenAgentManager] Creating new session...'); + + this.sessionCreateInFlight = (async () => { + try { + // Try to create a new ACP session. If Qwen asks for auth, let it handle authentication. + try { + await this.connection.newSession(workingDir); + } catch (err) { + const requiresAuth = isAuthenticationRequiredError(err); + + if (requiresAuth) { + if (!autoAuthenticate) { + console.warn( + '[QwenAgentManager] session/new requires authentication but auto-auth is disabled. Deferring until user logs in.', + ); + throw err; + } + console.warn( + '[QwenAgentManager] session/new requires authentication. Retrying with authenticate...', + ); + try { + // Let CLI handle authentication - it's the single source of truth + await this.connection.authenticate(authMethod); + console.log( + '[QwenAgentManager] createNewSession Authentication successful. Retrying session/new...', + ); + // Add a slight delay to ensure auth state is settled + await new Promise((resolve) => setTimeout(resolve, 300)); + await this.connection.newSession(workingDir); + } catch (reauthErr) { + console.error( + '[QwenAgentManager] Re-authentication failed:', + reauthErr, + ); + throw reauthErr; + } + } else { + throw err; + } + } + const newSessionId = this.connection.currentSessionId; + console.log( + '[QwenAgentManager] New session created with ID:', + newSessionId, + ); + return newSessionId; + } finally { + this.sessionCreateInFlight = null; + } + })(); + + return this.sessionCreateInFlight; + } + + /** + * Switch to specified session + * + * @param sessionId - Session ID + */ + async switchToSession(sessionId: string): Promise { + await this.connection.switchSession(sessionId); + } + + /** + * Cancel current prompt + */ + async cancelCurrentPrompt(): Promise { + console.log('[QwenAgentManager] Cancelling current prompt'); + await this.connection.cancelSession(); + } + + /** + * Register message callback + * + * @param callback - Message callback function + */ + onMessage(callback: (message: ChatMessage) => void): void { + this.callbacks.onMessage = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register stream chunk callback + * + * @param callback - Stream chunk callback function + */ + onStreamChunk(callback: (chunk: string) => void): void { + this.callbacks.onStreamChunk = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register thought chunk callback + * + * @param callback - Thought chunk callback function + */ + onThoughtChunk(callback: (chunk: string) => void): void { + this.callbacks.onThoughtChunk = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register tool call callback + * + * @param callback - Tool call callback function + */ + onToolCall(callback: (update: ToolCallUpdateData) => void): void { + this.callbacks.onToolCall = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register plan callback + * + * @param callback - Plan callback function + */ + onPlan(callback: (entries: PlanEntry[]) => void): void { + this.callbacks.onPlan = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register permission request callback + * + * @param callback - Permission request callback function + */ + onPermissionRequest( + callback: (request: AcpPermissionRequest) => Promise, + ): void { + this.callbacks.onPermissionRequest = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register end-of-turn callback + * + * @param callback - Called when ACP stopReason is reported + */ + onEndTurn(callback: (reason?: string) => void): void { + this.callbacks.onEndTurn = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register initialize mode info callback + */ + onModeInfo( + callback: (info: { + currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo'; + availableModes?: Array<{ + id: 'plan' | 'default' | 'auto-edit' | 'yolo'; + name: string; + description: string; + }>; + }) => void, + ): void { + this.callbacks.onModeInfo = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register mode changed callback + */ + onModeChanged( + callback: (modeId: 'plan' | 'default' | 'auto-edit' | 'yolo') => void, + ): void { + this.callbacks.onModeChanged = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Disconnect + */ + disconnect(): void { + this.connection.disconnect(); + } + + /** + * Check if connected + */ + get isConnected(): boolean { + return this.connection.isConnected; + } + + /** + * Get current session ID + */ + get currentSessionId(): string | null { + return this.connection.currentSessionId; + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts new file mode 100644 index 00000000..c66ee23c --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Qwen Connection Handler + * + * Handles Qwen Agent connection establishment, authentication, and session creation + */ + +import type { AcpConnection } from './acpConnection.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; +import { authMethod } from '../types/acpTypes.js'; + +export interface QwenConnectionResult { + sessionCreated: boolean; + requiresAuth: boolean; +} + +/** + * Qwen Connection Handler class + * Handles connection, authentication, and session initialization + */ +export class QwenConnectionHandler { + /** + * Connect to Qwen service and establish session + * + * @param connection - ACP connection instance + * @param workingDir - Working directory + * @param cliPath - CLI path (optional, if provided will override the path in configuration) + */ + async connect( + connection: AcpConnection, + workingDir: string, + cliEntryPath: string, + options?: { + autoAuthenticate?: boolean; + }, + ): Promise { + const connectId = Date.now(); + console.log(`[QwenAgentManager] ๐Ÿš€ CONNECT() CALLED - ID: ${connectId}`); + const autoAuthenticate = options?.autoAuthenticate ?? true; + let sessionCreated = false; + let requiresAuth = false; + + // Build extra CLI arguments (only essential parameters) + const extraArgs: string[] = []; + + await connection.connect(cliEntryPath!, workingDir, extraArgs); + + // Try to restore existing session or create new session + // Note: Auto-restore on connect is disabled to avoid surprising loads + // when user opens a "New Chat" tab. Restoration is now an explicit action + // (session selector โ†’ session/load) or handled by higher-level flows. + const sessionRestored = false; + + // Create new session if unable to restore + if (!sessionRestored) { + console.log( + '[QwenAgentManager] no sessionRestored, Creating new session...', + ); + + try { + console.log( + '[QwenAgentManager] Creating new session (letting CLI handle authentication)...', + ); + await this.newSessionWithRetry( + connection, + workingDir, + 3, + authMethod, + autoAuthenticate, + ); + console.log('[QwenAgentManager] New session created successfully'); + sessionCreated = true; + } catch (sessionError) { + const needsAuth = + autoAuthenticate === false && + isAuthenticationRequiredError(sessionError); + if (needsAuth) { + requiresAuth = true; + console.log( + '[QwenAgentManager] Session creation requires authentication; waiting for user-triggered login.', + ); + } else { + console.log( + `\nโš ๏ธ [SESSION FAILED] newSessionWithRetry threw error\n`, + ); + console.log(`[QwenAgentManager] Error details:`, sessionError); + throw sessionError; + } + } + } else { + sessionCreated = true; + } + + console.log(`\n========================================`); + console.log(`[QwenAgentManager] โœ… CONNECT() COMPLETED SUCCESSFULLY`); + console.log(`========================================\n`); + return { sessionCreated, requiresAuth }; + } + + /** + * Create new session (with retry) + * + * @param connection - ACP connection instance + * @param workingDir - Working directory + * @param maxRetries - Maximum number of retries + */ + private async newSessionWithRetry( + connection: AcpConnection, + workingDir: string, + maxRetries: number, + authMethod: string, + autoAuthenticate: boolean, + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + console.log( + `[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`, + ); + await connection.newSession(workingDir); + console.log('[QwenAgentManager] Session created successfully'); + return; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[QwenAgentManager] Session creation attempt ${attempt} failed:`, + errorMessage, + ); + + // If Qwen reports that authentication is required, try to + // authenticate on-the-fly once and retry without waiting. + const requiresAuth = isAuthenticationRequiredError(error); + if (requiresAuth) { + if (!autoAuthenticate) { + console.log( + '[QwenAgentManager] Authentication required but auto-authentication is disabled. Propagating error.', + ); + throw error; + } + console.log( + '[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...', + ); + try { + await connection.authenticate(authMethod); + // FIXME: @yiliang114 If there is no delay for a while, immediately executing + // newSession may cause the cli authorization jump to be triggered again + // Add a slight delay to ensure auth state is settled + await new Promise((resolve) => setTimeout(resolve, 300)); + console.log( + '[QwenAgentManager] newSessionWithRetry Authentication successful', + ); + // Retry immediately after successful auth + await connection.newSession(workingDir); + console.log( + '[QwenAgentManager] Session created successfully after auth', + ); + return; + } catch (authErr) { + console.error( + '[QwenAgentManager] Re-authentication failed:', + authErr, + ); + // Fall through to retry logic below + } + } + + if (attempt === maxRetries) { + throw new Error( + `Session creation failed after ${maxRetries} attempts: ${errorMessage}`, + ); + } + + const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); + console.log(`[QwenAgentManager] Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts new file mode 100644 index 00000000..9336a060 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; + +/** + * Qwen Session Manager + * + * This service provides direct filesystem access to save and load sessions + * without relying on the CLI's ACP session/save method. + * + * Note: This is primarily used as a fallback mechanism when ACP methods are + * unavailable or fail. In normal operation, ACP session/list and session/load + * should be preferred for consistency with the CLI. + */ +export class QwenSessionManager { + private qwenDir: string; + + constructor() { + this.qwenDir = path.join(os.homedir(), '.qwen'); + } + + /** + * Calculate project hash (same as CLI) + * Qwen CLI uses SHA256 hash of the project path + */ + private getProjectHash(workingDir: string): string { + return crypto.createHash('sha256').update(workingDir).digest('hex'); + } + + /** + * Get the session directory for a project + */ + private getSessionDir(workingDir: string): string { + const projectHash = this.getProjectHash(workingDir); + return path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + } + + /** + * Generate a new session ID + */ + private generateSessionId(): string { + return crypto.randomUUID(); + } + + /** + * Save current conversation as a named session + * + * @param messages - Current conversation messages + * @param sessionName - Name/tag for the saved session + * @param workingDir - Current working directory + * @returns Session ID of the saved session + */ + async saveSession( + messages: QwenMessage[], + sessionName: string, + workingDir: string, + ): Promise { + try { + // Create session directory if it doesn't exist + const sessionDir = this.getSessionDir(workingDir); + if (!fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }); + } + + // Generate session ID and filename using CLI's naming convention + const sessionId = this.generateSessionId(); + const shortId = sessionId.split('-')[0]; // First part of UUID (8 chars) + const now = new Date(); + const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD + const isoTime = now + .toISOString() + .split('T')[1] + .split(':') + .slice(0, 2) + .join('-'); // HH-MM + const filename = `session-${isoDate}T${isoTime}-${shortId}.json`; + const filePath = path.join(sessionDir, filename); + + // Create session object + const session: QwenSession = { + sessionId, + projectHash: this.getProjectHash(workingDir), + startTime: messages[0]?.timestamp || new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages, + }; + + // Save session to file + fs.writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf-8'); + + console.log(`[QwenSessionManager] Session saved: ${filePath}`); + return sessionId; + } catch (error) { + console.error('[QwenSessionManager] Failed to save session:', error); + throw error; + } + } + + /** + * Load a saved session by name + * + * @param sessionName - Name/tag of the session to load + * @param workingDir - Current working directory + * @returns Loaded session or null if not found + */ + async loadSession( + sessionId: string, + workingDir: string, + ): Promise { + try { + const sessionDir = this.getSessionDir(workingDir); + const filename = `session-${sessionId}.json`; + const filePath = path.join(sessionDir, filename); + + if (!fs.existsSync(filePath)) { + console.log(`[QwenSessionManager] Session file not found: ${filePath}`); + return null; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + + console.log(`[QwenSessionManager] Session loaded: ${filePath}`); + return session; + } catch (error) { + console.error('[QwenSessionManager] Failed to load session:', error); + return null; + } + } + + /** + * List all saved sessions + * + * @param workingDir - Current working directory + * @returns Array of session objects + */ + async listSessions(workingDir: string): Promise { + try { + const sessionDir = this.getSessionDir(workingDir); + + if (!fs.existsSync(sessionDir)) { + return []; + } + + const files = fs + .readdirSync(sessionDir) + .filter( + (file) => file.startsWith('session-') && file.endsWith('.json'), + ); + + const sessions: QwenSession[] = []; + for (const file of files) { + try { + const filePath = path.join(sessionDir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + sessions.push(session); + } catch (error) { + console.error( + `[QwenSessionManager] Failed to read session file ${file}:`, + error, + ); + } + } + + // Sort by last updated time (newest first) + sessions.sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + + return sessions; + } catch (error) { + console.error('[QwenSessionManager] Failed to list sessions:', error); + return []; + } + } + + /** + * Delete a saved session + * + * @param sessionId - ID of the session to delete + * @param workingDir - Current working directory + * @returns True if deleted successfully, false otherwise + */ + async deleteSession(sessionId: string, workingDir: string): Promise { + try { + const sessionDir = this.getSessionDir(workingDir); + const filename = `session-${sessionId}.json`; + const filePath = path.join(sessionDir, filename); + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`[QwenSessionManager] Session deleted: ${filePath}`); + return true; + } + + return false; + } catch (error) { + console.error('[QwenSessionManager] Failed to delete session:', error); + return false; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts new file mode 100644 index 00000000..3fc4e484 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -0,0 +1,361 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as readline from 'readline'; +import * as crypto from 'crypto'; + +export interface QwenMessage { + id: string; + timestamp: string; + type: 'user' | 'qwen'; + content: string; + thoughts?: unknown[]; + tokens?: { + input: number; + output: number; + cached: number; + thoughts: number; + tool: number; + total: number; + }; + model?: string; +} + +export interface QwenSession { + sessionId: string; + projectHash: string; + startTime: string; + lastUpdated: string; + messages: QwenMessage[]; + filePath?: string; + messageCount?: number; + firstUserText?: string; + cwd?: string; +} + +export class QwenSessionReader { + private qwenDir: string; + + constructor() { + this.qwenDir = path.join(os.homedir(), '.qwen'); + } + + /** + * Get all session list (optional: current project only or all projects) + */ + async getAllSessions( + workingDir?: string, + allProjects: boolean = false, + ): Promise { + try { + const sessions: QwenSession[] = []; + + if (!allProjects && workingDir) { + // Current project only + const projectHash = await this.getProjectHash(workingDir); + const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + const projectSessions = await this.readSessionsFromDir(chatsDir); + sessions.push(...projectSessions); + } else { + // All projects + const tmpDir = path.join(this.qwenDir, 'tmp'); + if (!fs.existsSync(tmpDir)) { + console.log('[QwenSessionReader] Tmp directory not found:', tmpDir); + return []; + } + + const projectDirs = fs.readdirSync(tmpDir); + for (const projectHash of projectDirs) { + const chatsDir = path.join(tmpDir, projectHash, 'chats'); + const projectSessions = await this.readSessionsFromDir(chatsDir); + sessions.push(...projectSessions); + } + } + + // Sort by last updated time + sessions.sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + + return sessions; + } catch (error) { + console.error('[QwenSessionReader] Failed to get sessions:', error); + return []; + } + } + + /** + * Read all sessions from specified directory + */ + private async readSessionsFromDir(chatsDir: string): Promise { + const sessions: QwenSession[] = []; + + if (!fs.existsSync(chatsDir)) { + return sessions; + } + + const files = fs.readdirSync(chatsDir); + + const jsonSessionFiles = files.filter( + (f) => f.startsWith('session-') && f.endsWith('.json'), + ); + + const jsonlSessionFiles = files.filter((f) => + /^[0-9a-fA-F-]{32,36}\.jsonl$/.test(f), + ); + + for (const file of jsonSessionFiles) { + const filePath = path.join(chatsDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + session.filePath = filePath; + sessions.push(session); + } catch (error) { + console.error( + '[QwenSessionReader] Failed to read session file:', + filePath, + error, + ); + } + } + + // Support new JSONL session format produced by the CLI + for (const file of jsonlSessionFiles) { + const filePath = path.join(chatsDir, file); + try { + const session = await this.readJsonlSession(filePath, false); + if (session) { + sessions.push(session); + } + } catch (error) { + console.error( + '[QwenSessionReader] Failed to read JSONL session file:', + filePath, + error, + ); + } + } + + return sessions; + } + + /** + * Get details of specific session + */ + async getSession( + sessionId: string, + _workingDir?: string, + ): Promise { + // First try to find in all projects + const sessions = await this.getAllSessions(undefined, true); + const found = sessions.find((s) => s.sessionId === sessionId); + + if (!found) { + return null; + } + + // If the session points to a JSONL file, load full content on demand + if ( + found.filePath && + found.filePath.endsWith('.jsonl') && + found.messages.length === 0 + ) { + const hydrated = await this.readJsonlSession(found.filePath, true); + if (hydrated) { + return hydrated; + } + } + + return found; + } + + /** + * Calculate project hash (needs to be consistent with Qwen CLI) + * Qwen CLI uses SHA256 hash of project path + */ + private async getProjectHash(workingDir: string): Promise { + return crypto.createHash('sha256').update(workingDir).digest('hex'); + } + + /** + * Get session title (based on first user message) + */ + getSessionTitle(session: QwenSession): string { + // Prefer cached prompt text to avoid loading messages for JSONL sessions + if (session.firstUserText) { + return ( + session.firstUserText.substring(0, 50) + + (session.firstUserText.length > 50 ? '...' : '') + ); + } + + const firstUserMessage = session.messages.find((m) => m.type === 'user'); + if (firstUserMessage) { + // Extract first 50 characters as title + return ( + firstUserMessage.content.substring(0, 50) + + (firstUserMessage.content.length > 50 ? '...' : '') + ); + } + return 'Untitled Session'; + } + + /** + * Parse a JSONL session file written by the CLI. + * When includeMessages is false, only lightweight metadata is returned. + */ + private async readJsonlSession( + filePath: string, + includeMessages: boolean, + ): Promise { + try { + if (!fs.existsSync(filePath)) { + return null; + } + + const stats = fs.statSync(filePath); + const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + const messages: QwenMessage[] = []; + const seenUuids = new Set(); + let sessionId: string | undefined; + let startTime: string | undefined; + let firstUserText: string | undefined; + let cwd: string | undefined; + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + let obj: Record; + try { + obj = JSON.parse(trimmed) as Record; + } catch { + continue; + } + + if (!sessionId && typeof obj.sessionId === 'string') { + sessionId = obj.sessionId; + } + if (!startTime && typeof obj.timestamp === 'string') { + startTime = obj.timestamp; + } + if (!cwd && typeof obj.cwd === 'string') { + cwd = obj.cwd; + } + + const type = typeof obj.type === 'string' ? obj.type : ''; + if (type === 'user' || type === 'assistant') { + const uuid = typeof obj.uuid === 'string' ? obj.uuid : undefined; + if (uuid) { + seenUuids.add(uuid); + } + + const text = this.contentToText(obj.message); + if (includeMessages) { + messages.push({ + id: uuid || `${messages.length}`, + timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : '', + type: type === 'user' ? 'user' : 'qwen', + content: text, + }); + } + + if (!firstUserText && type === 'user' && text) { + firstUserText = text; + } + } + } + + // Ensure stream is closed + rl.close(); + + if (!sessionId) { + return null; + } + + const projectHash = cwd + ? await this.getProjectHash(cwd) + : path.basename(path.dirname(path.dirname(filePath))); + + return { + sessionId, + projectHash, + startTime: startTime || new Date(stats.birthtimeMs).toISOString(), + lastUpdated: new Date(stats.mtimeMs).toISOString(), + messages: includeMessages ? messages : [], + filePath, + messageCount: seenUuids.size, + firstUserText, + cwd, + }; + } catch (error) { + console.error( + '[QwenSessionReader] Failed to parse JSONL session:', + error, + ); + return null; + } + } + + // Extract plain text from CLI Content structure + private contentToText(message: unknown): string { + try { + if (typeof message !== 'object' || message === null) { + return ''; + } + + const typed = message as { parts?: unknown[] }; + const parts = Array.isArray(typed.parts) ? typed.parts : []; + const texts: string[] = []; + for (const part of parts) { + if (typeof part !== 'object' || part === null) { + continue; + } + const p = part as Record; + if (typeof p.text === 'string') { + texts.push(p.text); + } else if (typeof p.data === 'string') { + texts.push(p.data); + } + } + return texts.join('\n'); + } catch { + return ''; + } + } + + /** + * Delete session file + */ + async deleteSession( + sessionId: string, + _workingDir: string, + ): Promise { + try { + const session = await this.getSession(sessionId, _workingDir); + if (session && session.filePath) { + fs.unlinkSync(session.filePath); + return true; + } + return false; + } catch (error) { + console.error('[QwenSessionReader] Failed to delete session:', error); + return false; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts new file mode 100644 index 00000000..d7b24bb2 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Qwen Session Update Handler + * + * Handles session updates from ACP and dispatches them to appropriate callbacks + */ + +import type { AcpSessionUpdate } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import type { QwenAgentCallbacks } from '../types/chatTypes.js'; + +/** + * Qwen Session Update Handler class + * Processes various session update events and calls appropriate callbacks + */ +export class QwenSessionUpdateHandler { + private callbacks: QwenAgentCallbacks; + + constructor(callbacks: QwenAgentCallbacks) { + this.callbacks = callbacks; + } + + /** + * Update callbacks + * + * @param callbacks - New callback collection + */ + updateCallbacks(callbacks: QwenAgentCallbacks): void { + this.callbacks = callbacks; + } + + /** + * Handle session update + * + * @param data - ACP session update data + */ + handleSessionUpdate(data: AcpSessionUpdate): void { + const update = data.update; + console.log( + '[SessionUpdateHandler] Processing update type:', + update.sessionUpdate, + ); + + switch (update.sessionUpdate) { + case 'user_message_chunk': + if (update.content?.text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(update.content.text); + } + break; + + case 'agent_message_chunk': + if (update.content?.text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(update.content.text); + } + break; + + case 'agent_thought_chunk': + if (update.content?.text) { + if (this.callbacks.onThoughtChunk) { + this.callbacks.onThoughtChunk(update.content.text); + } else if (this.callbacks.onStreamChunk) { + // Fallback to regular stream processing + console.log( + '[SessionUpdateHandler] ๐Ÿง  Falling back to onStreamChunk', + ); + this.callbacks.onStreamChunk(update.content.text); + } + } + break; + + case 'tool_call': { + // Handle new tool call + if (this.callbacks.onToolCall && 'toolCallId' in update) { + this.callbacks.onToolCall({ + toolCallId: update.toolCallId as string, + kind: (update.kind as string) || undefined, + title: (update.title as string) || undefined, + status: (update.status as string) || undefined, + rawInput: update.rawInput, + content: update.content as + | Array> + | undefined, + locations: update.locations as + | Array<{ path: string; line?: number | null }> + | undefined, + }); + } + break; + } + + case 'tool_call_update': { + if (this.callbacks.onToolCall && 'toolCallId' in update) { + this.callbacks.onToolCall({ + toolCallId: update.toolCallId as string, + kind: (update.kind as string) || undefined, + title: (update.title as string) || undefined, + status: (update.status as string) || undefined, + rawInput: update.rawInput, + content: update.content as + | Array> + | undefined, + locations: update.locations as + | Array<{ path: string; line?: number | null }> + | undefined, + }); + } + break; + } + + case 'plan': { + if ('entries' in update) { + const entries = update.entries as Array<{ + content: string; + priority: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; + }>; + + if (this.callbacks.onPlan) { + this.callbacks.onPlan(entries); + } else if (this.callbacks.onStreamChunk) { + // Fallback to stream processing + const planText = + '\n๐Ÿ“‹ Plan:\n' + + entries + .map( + (entry, i) => + `${i + 1}. [${entry.priority}] ${entry.content}`, + ) + .join('\n'); + this.callbacks.onStreamChunk(planText); + } + } + break; + } + + case 'current_mode_update': { + // Notify UI about mode change + try { + const modeId = (update as unknown as { modeId?: ApprovalModeValue }) + .modeId; + if (modeId && this.callbacks.onModeChanged) { + this.callbacks.onModeChanged(modeId); + } + } catch (err) { + console.warn( + '[SessionUpdateHandler] Failed to handle mode update', + err, + ); + } + break; + } + + default: + console.log('[QwenAgentManager] Unhandled session update type'); + break; + } + } +} diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts new file mode 100644 index 00000000..5ddbfd06 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import type { ApprovalModeValue } from './approvalModeValueTypes.js'; + +export const JSONRPC_VERSION = '2.0' as const; +export const authMethod = 'qwen-oauth'; + +export interface AcpRequest { + jsonrpc: typeof JSONRPC_VERSION; + id: number; + method: string; + params?: unknown; +} + +export interface AcpResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: number; + result?: unknown; + capabilities?: { + [key: string]: unknown; + }; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +export interface AcpNotification { + jsonrpc: typeof JSONRPC_VERSION; + method: string; + params?: unknown; +} + +export interface BaseSessionUpdate { + sessionId: string; +} + +// Content block type (simplified version, use schema.ContentBlock for validation) +export interface ContentBlock { + type: 'text' | 'image'; + text?: string; + data?: string; + mimeType?: string; + uri?: string; +} + +export interface UserMessageChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'user_message_chunk'; + content: ContentBlock; + }; +} + +export interface AgentMessageChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'agent_message_chunk'; + content: ContentBlock; + }; +} + +export interface AgentThoughtChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'agent_thought_chunk'; + content: ContentBlock; + }; +} + +export interface ToolCallUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'tool_call'; + toolCallId: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + title: string; + kind: + | 'read' + | 'edit' + | 'execute' + | 'delete' + | 'move' + | 'search' + | 'fetch' + | 'think' + | 'other'; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: 'text'; + text: string; + }; + path?: string; + oldText?: string | null; + newText?: string; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + }; +} + +export interface ToolCallStatusUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'tool_call_update'; + toolCallId: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + title?: string; + kind?: string; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: 'text'; + text: string; + }; + path?: string; + oldText?: string | null; + newText?: string; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + }; +} + +export interface PlanUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'plan'; + entries: Array<{ + content: string; + priority: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; + }>; + }; +} + +export { + ApprovalMode, + APPROVAL_MODE_MAP, + APPROVAL_MODE_INFO, + getApprovalModeInfoFromString, +} from './approvalModeTypes.js'; + +// Cyclic next-mode mapping used by UI toggles and other consumers +export const NEXT_APPROVAL_MODE: { + [k in ApprovalModeValue]: ApprovalModeValue; +} = { + // Hide "plan" from the public toggle sequence for now + // Cycle: default -> auto-edit -> yolo -> default + default: 'auto-edit', + 'auto-edit': 'yolo', + plan: 'yolo', + yolo: 'default', +}; + +// Current mode update (sent by agent when mode changes) +export interface CurrentModeUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'current_mode_update'; + modeId: ApprovalModeValue; + }; +} + +// Authenticate update (sent by agent during authentication process) +export interface AuthenticateUpdateNotification { + _meta: { + authUri: string; + }; +} + +export type AcpSessionUpdate = + | UserMessageChunkUpdate + | AgentMessageChunkUpdate + | AgentThoughtChunkUpdate + | ToolCallUpdate + | ToolCallStatusUpdate + | PlanUpdate + | CurrentModeUpdate; + +// Permission request (simplified version, use schema.RequestPermissionRequest for validation) +export interface AcpPermissionRequest { + sessionId: string; + options: Array<{ + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + }>; + toolCall: { + toolCallId: string; + rawInput?: { + command?: string; + description?: string; + [key: string]: unknown; + }; + title?: string; + kind?: string; + }; +} + +export type AcpMessage = + | AcpRequest + | AcpNotification + | AcpResponse + | AcpSessionUpdate; diff --git a/packages/vscode-ide-companion/src/types/approvalModeTypes.ts b/packages/vscode-ide-companion/src/types/approvalModeTypes.ts new file mode 100644 index 00000000..ac9b22e5 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/approvalModeTypes.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Enum for approval modes with UI-friendly labels + * Represents the different approval modes available in the ACP protocol + * with their corresponding user-facing display names + */ +export enum ApprovalMode { + PLAN = 'plan', + DEFAULT = 'default', + AUTO_EDIT = 'auto-edit', + YOLO = 'yolo', +} + +/** + * Mapping from string values to enum values for runtime conversion + */ +export const APPROVAL_MODE_MAP: Record = { + plan: ApprovalMode.PLAN, + default: ApprovalMode.DEFAULT, + 'auto-edit': ApprovalMode.AUTO_EDIT, + yolo: ApprovalMode.YOLO, +}; + +/** + * UI display information for each approval mode + */ +export const APPROVAL_MODE_INFO: Record< + ApprovalMode, + { + label: string; + title: string; + iconType?: 'edit' | 'auto' | 'plan' | 'yolo'; + } +> = { + [ApprovalMode.PLAN]: { + label: 'Plan mode', + title: 'Qwen will plan before executing. Click to switch modes.', + iconType: 'plan', + }, + [ApprovalMode.DEFAULT]: { + label: 'Ask before edits', + title: 'Qwen will ask before each edit. Click to switch modes.', + iconType: 'edit', + }, + [ApprovalMode.AUTO_EDIT]: { + label: 'Edit automatically', + title: 'Qwen will edit files automatically. Click to switch modes.', + iconType: 'auto', + }, + [ApprovalMode.YOLO]: { + label: 'YOLO', + title: 'Automatically approve all tools. Click to switch modes.', + iconType: 'yolo', + }, +}; + +/** + * Get UI display information for an approval mode from string value + */ +export function getApprovalModeInfoFromString(mode: string): { + label: string; + title: string; + iconType?: 'edit' | 'auto' | 'plan' | 'yolo'; +} { + const enumValue = APPROVAL_MODE_MAP[mode]; + if (enumValue !== undefined) { + return APPROVAL_MODE_INFO[enumValue]; + } + return { + label: 'Unknown mode', + title: 'Unknown edit mode', + iconType: undefined, + }; +} diff --git a/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts new file mode 100644 index 00000000..fe1f37e1 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Type for approval mode values + * Used in ACP protocol for controlling agent behavior + */ +export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts new file mode 100644 index 00000000..4cffd4eb --- /dev/null +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import type { AcpPermissionRequest } from './acpTypes.js'; +import type { ApprovalModeValue } from './approvalModeValueTypes.js'; + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: number; +} + +export interface PlanEntry { + content: string; + priority?: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; +} + +export interface ToolCallUpdateData { + toolCallId: string; + kind?: string; + title?: string; + status?: string; + rawInput?: unknown; + content?: Array>; + locations?: Array<{ path: string; line?: number | null }>; +} + +export interface QwenAgentCallbacks { + onMessage?: (message: ChatMessage) => void; + onStreamChunk?: (chunk: string) => void; + onThoughtChunk?: (chunk: string) => void; + onToolCall?: (update: ToolCallUpdateData) => void; + onPlan?: (entries: PlanEntry[]) => void; + onPermissionRequest?: (request: AcpPermissionRequest) => Promise; + onEndTurn?: (reason?: string) => void; + onModeInfo?: (info: { + currentModeId?: ApprovalModeValue; + availableModes?: Array<{ + id: ApprovalModeValue; + name: string; + description: string; + }>; + }) => void; + onModeChanged?: (modeId: ApprovalModeValue) => void; +} + +export interface ToolCallUpdate { + type: 'tool_call' | 'tool_call_update'; + toolCallId: string; + kind?: string; + title?: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: string; + text?: string; + [key: string]: unknown; + }; + path?: string; + oldText?: string | null; + newText?: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + timestamp?: number; // Add timestamp field for message ordering +} diff --git a/packages/vscode-ide-companion/src/types/completionItemTypes.ts b/packages/vscode-ide-companion/src/types/completionItemTypes.ts new file mode 100644 index 00000000..8bc884b3 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/completionItemTypes.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +export interface CompletionItem { + id: string; + label: string; + description?: string; + icon?: React.ReactNode; + type: 'file' | 'folder' | 'symbol' | 'command' | 'variable' | 'info'; + // Value inserted into the input when selected (e.g., filename or command) + value?: string; + // Optional full path for files (used to build @filename -> full path mapping) + path?: string; +} diff --git a/packages/vscode-ide-companion/src/types/connectionTypes.ts b/packages/vscode-ide-companion/src/types/connectionTypes.ts new file mode 100644 index 00000000..7ada3aed --- /dev/null +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChildProcess } from 'child_process'; +import type { + AcpSessionUpdate, + AcpPermissionRequest, + AuthenticateUpdateNotification, +} from './acpTypes.js'; + +export interface PendingRequest { + resolve: (value: T) => void; + reject: (error: Error) => void; + timeoutId?: NodeJS.Timeout; + method: string; +} + +export interface AcpConnectionCallbacks { + onSessionUpdate: (data: AcpSessionUpdate) => void; + onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + optionId: string; + }>; + onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void; + onEndTurn: (reason?: string) => void; +} + +export interface AcpConnectionState { + child: ChildProcess | null; + pendingRequests: Map>; + nextRequestId: number; + sessionId: string | null; + isInitialized: boolean; +} diff --git a/packages/vscode-ide-companion/src/utils/authErrors.ts b/packages/vscode-ide-companion/src/utils/authErrors.ts new file mode 100644 index 00000000..8b0e6af9 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/authErrors.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const AUTH_ERROR_PATTERNS = [ + 'Authentication required', // Standard authentication request message + '(code: -32000)', // RPC error code -32000 indicates authentication failure + 'Unauthorized', // HTTP unauthorized error + 'Invalid token', // Invalid token + 'Session expired', // Session expired +]; + +/** + * Determines if the given error is authentication-related + */ +export const isAuthenticationRequiredError = (error: unknown): boolean => { + // Null check to avoid unnecessary processing + if (!error) { + return false; + } + + // Extract error message text + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : String(error); + + // Match authentication-related errors using predefined patterns + return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); +}; diff --git a/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts new file mode 100644 index 00000000..362867c2 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { AuthenticateUpdateNotification } from '../types/acpTypes.js'; + +// Store reference to the current notification +let currentNotification: Thenable | null = null; + +/** + * Handle authentication update notifications by showing a VS Code notification + * with the authentication URI and action buttons. + * + * @param data - Authentication update notification data containing the auth URI + */ +export function handleAuthenticateUpdate( + data: AuthenticateUpdateNotification, +): void { + const authUri = data._meta.authUri; + + // Store reference to the current notification + currentNotification = vscode.window.showInformationMessage( + `Qwen Code needs authentication. Click an action below:`, + 'Open in Browser', + 'Copy Link', + 'Dismiss', + ); + + currentNotification.then((selection) => { + if (selection === 'Open in Browser') { + // Open the authentication URI in the default browser + vscode.env.openExternal(vscode.Uri.parse(authUri)); + vscode.window.showInformationMessage( + 'Opening authentication page in your browser...', + ); + } else if (selection === 'Copy Link') { + // Copy the authentication URI to clipboard + vscode.env.clipboard.writeText(authUri); + vscode.window.showInformationMessage( + 'Authentication link copied to clipboard!', + ); + } + + // Clear the notification reference after user interaction + currentNotification = null; + }); +} diff --git a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts new file mode 100644 index 00000000..3bfc675f --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { openChatCommand } from '../commands/index.js'; + +/** + * Find the editor group immediately to the left of the Qwen chat webview. + * - If the chat webview group is the leftmost group, returns undefined. + * - Uses the webview tab viewType 'mainThreadWebview-qwenCode.chat'. + */ +export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined { + try { + const groups = vscode.window.tabGroups.all; + + // Locate the group that contains our chat webview + const webviewGroup = groups.find((group) => + group.tabs.some((tab) => { + const input: unknown = (tab as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + return ( + isWebviewInput(input) && + input.viewType === 'mainThreadWebview-qwenCode.chat' + ); + }), + ); + + if (!webviewGroup) { + return undefined; + } + + // Among all groups to the left (smaller viewColumn), choose the one with + // the largest viewColumn value (i.e. the immediate neighbor on the left). + let candidate: + | { group: vscode.TabGroup; viewColumn: vscode.ViewColumn } + | undefined; + for (const g of groups) { + if (g.viewColumn < webviewGroup.viewColumn) { + if (!candidate || g.viewColumn > candidate.viewColumn) { + candidate = { group: g, viewColumn: g.viewColumn }; + } + } + } + + return candidate?.viewColumn; + } catch (_err) { + // Best-effort only; fall back to default behavior if anything goes wrong + return undefined; + } +} + +/** + * Ensure there is an editor group directly to the left of the Qwen chat webview. + * - If one exists, return its ViewColumn. + * - If none exists, focus the chat panel and create a new group on its left, + * then return the new group's ViewColumn (which equals the chat's previous column). + * - If the chat webview cannot be located, returns undefined. + */ +export async function ensureLeftGroupOfChatWebview(): Promise< + vscode.ViewColumn | undefined +> { + // First try to find an existing left neighbor + const existing = findLeftGroupOfChatWebview(); + if (existing !== undefined) { + return existing; + } + + // Locate the chat webview group + const groups = vscode.window.tabGroups.all; + const webviewGroup = groups.find((group) => + group.tabs.some((tab) => { + const input: unknown = (tab as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + return ( + isWebviewInput(input) && + input.viewType === 'mainThreadWebview-qwenCode.chat' + ); + }), + ); + + if (!webviewGroup) { + return undefined; + } + + const previousChatColumn = webviewGroup.viewColumn; + + // Make the chat group active by revealing the panel + try { + await vscode.commands.executeCommand(openChatCommand); + } catch { + // Best-effort; continue even if this fails + } + + // Create a new group to the left of the chat group + try { + await vscode.commands.executeCommand('workbench.action.newGroupLeft'); + } catch { + // If we fail to create a group, fall back to default behavior + return undefined; + } + + // Restore focus to chat (optional), so we don't disturb user focus + try { + await vscode.commands.executeCommand(openChatCommand); + } catch { + // Ignore + } + + // The new left group's column equals the chat's previous column + return previousChatColumn; +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx new file mode 100644 index 00000000..5eacdabf --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -0,0 +1,829 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { + useState, + useEffect, + useRef, + useCallback, + useMemo, + useLayoutEffect, +} from 'react'; +import { useVSCode } from './hooks/useVSCode.js'; +import { useSessionManagement } from './hooks/session/useSessionManagement.js'; +import { useFileContext } from './hooks/file/useFileContext.js'; +import { useMessageHandling } from './hooks/message/useMessageHandling.js'; +import { useToolCalls } from './hooks/useToolCalls.js'; +import { useWebViewMessages } from './hooks/useWebViewMessages.js'; +import { useMessageSubmit } from './hooks/useMessageSubmit.js'; +import type { + PermissionOption, + ToolCall as PermissionToolCall, +} from './components/PermissionDrawer/PermissionRequest.js'; +import type { TextMessage } from './hooks/message/useMessageHandling.js'; +import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js'; +import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js'; +import { ToolCall } from './components/messages/toolcalls/ToolCall.js'; +import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js'; +import { EmptyState } from './components/layout/EmptyState.js'; +import { Onboarding } from './components/layout/Onboarding.js'; +import { type CompletionItem } from '../types/completionItemTypes.js'; +import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; +import { ChatHeader } from './components/layout/ChatHeader.js'; +import { + UserMessage, + AssistantMessage, + ThinkingMessage, + WaitingMessage, + InterruptedMessage, +} from './components/messages/index.js'; +import { InputForm } from './components/layout/InputForm.js'; +import { SessionSelector } from './components/layout/SessionSelector.js'; +import { FileIcon, UserIcon } from './components/icons/index.js'; +import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import type { PlanEntry } from '../types/chatTypes.js'; + +export const App: React.FC = () => { + const vscode = useVSCode(); + + // Core hooks + const sessionManagement = useSessionManagement(vscode); + const fileContext = useFileContext(vscode); + const messageHandling = useMessageHandling(); + const { + inProgressToolCalls, + completedToolCalls, + handleToolCallUpdate, + clearToolCalls, + } = useToolCalls(); + + // UI state + const [inputText, setInputText] = useState(''); + const [permissionRequest, setPermissionRequest] = useState<{ + options: PermissionOption[]; + toolCall: PermissionToolCall; + } | null>(null); + const [planEntries, setPlanEntries] = useState([]); + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading + const messagesEndRef = useRef( + null, + ) as React.RefObject; + // Scroll container for message list; used to keep the view anchored to the latest content + const messagesContainerRef = useRef( + null, + ) as React.RefObject; + const inputFieldRef = useRef( + null, + ) as React.RefObject; + + const [editMode, setEditMode] = useState( + ApprovalMode.DEFAULT, + ); + const [thinkingEnabled, setThinkingEnabled] = useState(false); + const [isComposing, setIsComposing] = useState(false); + // When true, do NOT auto-attach the active editor file/selection to message context + const [skipAutoActiveContext, setSkipAutoActiveContext] = useState(false); + + // Completion system + const getCompletionItems = React.useCallback( + async (trigger: '@' | '/', query: string): Promise => { + if (trigger === '@') { + console.log('[App] getCompletionItems @ called', { + query, + requested: fileContext.hasRequestedFiles, + workspaceFiles: fileContext.workspaceFiles.length, + }); + // ๅง‹็ปˆๆ นๆฎๅฝ“ๅ‰ query ่งฆๅ‘่ฏทๆฑ‚๏ผŒ่ฎฉ hook ๅˆคๆ–ญๆ˜ฏๅฆ้œ€่ฆ็œŸๆญฃ่ฏทๆฑ‚ + fileContext.requestWorkspaceFiles(query); + + const fileIcon = ; + const allItems: CompletionItem[] = fileContext.workspaceFiles.map( + (file) => ({ + id: file.id, + label: file.label, + description: file.description, + type: 'file' as const, + icon: fileIcon, + // Insert filename after @, keep path for mapping + value: file.label, + path: file.path, + }), + ); + + if (query && query.length >= 1) { + const lowerQuery = query.toLowerCase(); + return allItems.filter( + (item) => + item.label.toLowerCase().includes(lowerQuery) || + (item.description && + item.description.toLowerCase().includes(lowerQuery)), + ); + } + + // If first time and still loading, show a placeholder + if (allItems.length === 0) { + return [ + { + id: 'loading-files', + label: 'Searching filesโ€ฆ', + description: 'Type to filter, or wait a momentโ€ฆ', + type: 'info' as const, + }, + ]; + } + + return allItems; + } else { + // Handle slash commands + const commands: CompletionItem[] = [ + { + id: 'login', + label: '/login', + description: 'Login to Qwen Code', + type: 'command', + icon: , + }, + ]; + + return commands.filter((cmd) => + cmd.label.toLowerCase().includes(query.toLowerCase()), + ); + } + }, + [fileContext], + ); + + const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); + + // Track a lightweight signature of workspace files to detect content changes even when length is unchanged + const workspaceFilesSignature = useMemo( + () => + fileContext.workspaceFiles + .map( + (file) => + `${file.id}|${file.label}|${file.description ?? ''}|${file.path}`, + ) + .join('||'), + [fileContext.workspaceFiles], + ); + + // When workspace files update while menu open for @, refresh items so the first @ shows the list + // Note: Avoid depending on the entire `completion` object here, since its identity + // changes on every render which would retrigger this effect and can cause a refresh loop. + useEffect(() => { + // Only auto-refresh when there's no query (first @ popup) to avoid repeated refreshes during search + if ( + completion.isOpen && + completion.triggerChar === '@' && + !completion.query + ) { + // Only refresh items; do not change other completion state to avoid re-renders loops + completion.refreshCompletion(); + } + // Only re-run when the actual data source changes, not on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + workspaceFilesSignature, + completion.isOpen, + completion.triggerChar, + completion.query, + ]); + + // Message submission + const { handleSubmit: submitMessage } = useMessageSubmit({ + inputText, + setInputText, + messageHandling, + fileContext, + skipAutoActiveContext, + vscode, + inputFieldRef, + isStreaming: messageHandling.isStreaming, + isWaitingForResponse: messageHandling.isWaitingForResponse, + }); + + // Handle cancel/stop from the input bar + // Emit a cancel to the extension and immediately reflect interruption locally. + const handleCancel = useCallback(() => { + if (messageHandling.isStreaming || messageHandling.isWaitingForResponse) { + // Proactively end local states and add an 'Interrupted' line + try { + messageHandling.endStreaming?.(); + } catch { + /* no-op */ + } + try { + messageHandling.clearWaitingForResponse?.(); + } catch { + /* no-op */ + } + messageHandling.addMessage({ + role: 'assistant', + content: 'Interrupted', + timestamp: Date.now(), + }); + } + // Notify extension/agent to cancel server-side work + vscode.postMessage({ + type: 'cancelStreaming', + data: {}, + }); + }, [messageHandling, vscode]); + + // Message handling + useWebViewMessages({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest: setPermissionRequest, + inputFieldRef, + setInputText, + setEditMode, + setIsAuthenticated, + }); + + // Auto-scroll handling: keep the view pinned to bottom when new content arrives, + // but don't interrupt the user if they scrolled up. + // We track whether the user is currently "pinned" to the bottom (near the end). + const [pinnedToBottom, setPinnedToBottom] = useState(true); + const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 }); + + // Observe scroll position to know if user has scrolled away from the bottom. + useEffect(() => { + const container = messagesContainerRef.current; + if (!container) { + return; + } + + const onScroll = () => { + // Use a small threshold so slight deltas don't flip the state. + // Note: there's extra bottom padding for the input area, so keep this a bit generous. + const threshold = 80; // px tolerance + const distanceFromBottom = + container.scrollHeight - (container.scrollTop + container.clientHeight); + setPinnedToBottom(distanceFromBottom <= threshold); + }; + + // Initialize once mounted so first render is correct + onScroll(); + container.addEventListener('scroll', onScroll, { passive: true }); + return () => container.removeEventListener('scroll', onScroll); + }, []); + + // When content changes, if the user is pinned to bottom, keep it anchored there. + // Only smooth-scroll when new items are appended; do not smooth for streaming chunk updates. + useLayoutEffect(() => { + const container = messagesContainerRef.current; + if (!container) { + return; + } + + // Detect whether new items were appended (vs. streaming chunk updates) + const prev = prevCountsRef.current; + const newMsg = messageHandling.messages.length > prev.msgLen; + const newInProg = inProgressToolCalls.length > prev.inProgLen; + const newDone = completedToolCalls.length > prev.doneLen; + prevCountsRef.current = { + msgLen: messageHandling.messages.length, + inProgLen: inProgressToolCalls.length, + doneLen: completedToolCalls.length, + }; + + if (!pinnedToBottom) { + // Do nothing if user scrolled away; avoid stealing scroll. + return; + } + + const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks + + // Anchor to the bottom on next frame to avoid layout thrash. + const raf = requestAnimationFrame(() => { + const top = container.scrollHeight - container.clientHeight; + // Use scrollTo to avoid cross-context issues with scrollIntoView. + container.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' }); + }); + return () => cancelAnimationFrame(raf); + }, [ + pinnedToBottom, + messageHandling.messages, + inProgressToolCalls, + completedToolCalls, + messageHandling.isWaitingForResponse, + messageHandling.loadingMessage, + messageHandling.isStreaming, + planEntries, + ]); + + // When the last rendered item resizes (e.g., images/code blocks load/expand), + // if we're pinned to bottom, keep it anchored there. + useEffect(() => { + const container = messagesContainerRef.current; + const endEl = messagesEndRef.current; + if (!container || !endEl) { + return; + } + + const lastItem = endEl.previousElementSibling as HTMLElement | null; + if (!lastItem) { + return; + } + + let frame = 0; + const ro = new ResizeObserver(() => { + if (!pinnedToBottom) { + return; + } + // Defer to next frame to avoid thrash during rapid size changes + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const top = container.scrollHeight - container.clientHeight; + container.scrollTo({ top }); + }); + }); + ro.observe(lastItem); + + return () => { + cancelAnimationFrame(frame); + ro.disconnect(); + }; + }, [ + pinnedToBottom, + messageHandling.messages, + inProgressToolCalls, + completedToolCalls, + ]); + + // Set loading state to false after initial mount and when we have authentication info + useEffect(() => { + // If we have determined authentication status, we're done loading + if (isAuthenticated !== null) { + setIsLoading(false); + } + }, [isAuthenticated]); + + // Handle permission response + const handlePermissionResponse = useCallback( + (optionId: string) => { + // Forward the selected optionId directly to extension as ACP permission response + // Expected values include: 'proceed_once', 'proceed_always', 'cancel', 'proceed_always_server', etc. + vscode.postMessage({ + type: 'permissionResponse', + data: { optionId }, + }); + setPermissionRequest(null); + }, + [vscode], + ); + + // Handle completion selection + const handleCompletionSelect = useCallback( + (item: CompletionItem) => { + // Handle completion selection by inserting the value into the input field + const inputElement = inputFieldRef.current; + if (!inputElement) { + return; + } + + // Ignore info items (placeholders like "Searching filesโ€ฆ") + if (item.type === 'info') { + completion.closeCompletion(); + return; + } + + // Slash commands can execute immediately + if (item.type === 'command') { + const command = (item.label || '').trim(); + if (command === '/login') { + vscode.postMessage({ type: 'login', data: {} }); + completion.closeCompletion(); + return; + } + } + + // If selecting a file, add @filename -> fullpath mapping + if (item.type === 'file' && item.value && item.path) { + try { + fileContext.addFileReference(item.value, item.path); + } catch (err) { + console.warn('[App] addFileReference failed:', err); + } + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + // Current text and cursor + const text = inputElement.textContent || ''; + const range = selection.getRangeAt(0); + + // Compute total text offset for contentEditable + let cursorPos = text.length; + if (range.startContainer === inputElement) { + const childIndex = range.startOffset; + let offset = 0; + for ( + let i = 0; + i < childIndex && i < inputElement.childNodes.length; + i++ + ) { + offset += inputElement.childNodes[i].textContent?.length || 0; + } + cursorPos = offset || text.length; + } else if (range.startContainer.nodeType === Node.TEXT_NODE) { + const walker = document.createTreeWalker( + inputElement, + NodeFilter.SHOW_TEXT, + null, + ); + let offset = 0; + let found = false; + let node: Node | null = walker.nextNode(); + while (node) { + if (node === range.startContainer) { + offset += range.startOffset; + found = true; + break; + } + offset += node.textContent?.length || 0; + node = walker.nextNode(); + } + cursorPos = found ? offset : text.length; + } + + // Replace from trigger to cursor with selected value + const textBeforeCursor = text.substring(0, cursorPos); + const atPos = textBeforeCursor.lastIndexOf('@'); + const slashPos = textBeforeCursor.lastIndexOf('/'); + const triggerPos = Math.max(atPos, slashPos); + + if (triggerPos >= 0) { + const insertValue = + typeof item.value === 'string' ? item.value : String(item.label); + const newText = + text.substring(0, triggerPos + 1) + // keep the trigger symbol + insertValue + + ' ' + + text.substring(cursorPos); + + // Update DOM and state, and move caret to end + inputElement.textContent = newText; + setInputText(newText); + + const newRange = document.createRange(); + const sel = window.getSelection(); + newRange.selectNodeContents(inputElement); + newRange.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(newRange); + } + + // Close the completion menu + completion.closeCompletion(); + }, + [completion, inputFieldRef, setInputText, fileContext, vscode], + ); + + // Handle attach context click + const handleAttachContextClick = useCallback(() => { + // Open native file picker (different from '@' completion which searches workspace files) + vscode.postMessage({ + type: 'attachFile', + data: {}, + }); + }, [vscode]); + + // Handle toggle edit mode (Default -> Auto-edit -> YOLO -> Default) + const handleToggleEditMode = useCallback(() => { + setEditMode((prev) => { + const next: ApprovalModeValue = NEXT_APPROVAL_MODE[prev]; + + // Notify extension to set approval mode via ACP + try { + vscode.postMessage({ + type: 'setApprovalMode', + data: { modeId: next }, + }); + } catch { + /* no-op */ + } + return next; + }); + }, [vscode]); + + // Handle toggle thinking + const handleToggleThinking = () => { + setThinkingEnabled((prev) => !prev); + }; + + // When user sends a message after scrolling up, re-pin and jump to the bottom + const handleSubmitWithScroll = useCallback( + (e: React.FormEvent) => { + setPinnedToBottom(true); + + const container = messagesContainerRef.current; + if (container) { + const top = container.scrollHeight - container.clientHeight; + container.scrollTo({ top }); + } + + submitMessage(e); + }, + [submitMessage], + ); + + // Create unified message array containing all types of messages and tool calls + const allMessages = useMemo< + Array<{ + type: 'message' | 'in-progress-tool-call' | 'completed-tool-call'; + data: TextMessage | ToolCallData; + timestamp: number; + }> + >(() => { + // Regular messages + const regularMessages = messageHandling.messages.map((msg) => ({ + type: 'message' as const, + data: msg, + timestamp: msg.timestamp, + })); + + // In-progress tool calls + const inProgressTools = inProgressToolCalls.map((toolCall) => ({ + type: 'in-progress-tool-call' as const, + data: toolCall, + timestamp: toolCall.timestamp || Date.now(), + })); + + // Completed tool calls + const completedTools = completedToolCalls + .filter(hasToolCallOutput) + .map((toolCall) => ({ + type: 'completed-tool-call' as const, + data: toolCall, + timestamp: toolCall.timestamp || Date.now(), + })); + + // Merge and sort by timestamp to ensure messages and tool calls are interleaved + return [...regularMessages, ...inProgressTools, ...completedTools].sort( + (a, b) => (a.timestamp || 0) - (b.timestamp || 0), + ); + }, [messageHandling.messages, inProgressToolCalls, completedToolCalls]); + + console.log('[App] Rendering messages:', allMessages); + + // Render all messages and tool calls + const renderMessages = useCallback<() => React.ReactNode>( + () => + allMessages.map((item, index) => { + switch (item.type) { + case 'message': { + const msg = item.data as TextMessage; + const handleFileClick = (path: string): void => { + vscode.postMessage({ + type: 'openFile', + data: { path }, + }); + }; + + if (msg.role === 'thinking') { + return ( + + ); + } + + if (msg.role === 'user') { + return ( + + ); + } + + { + const content = (msg.content || '').trim(); + if (content === 'Interrupted' || content === 'Tool interrupted') { + return ( + + ); + } + return ( + + ); + } + } + + case 'in-progress-tool-call': + case 'completed-tool-call': { + const prev = allMessages[index - 1]; + const next = allMessages[index + 1]; + const isToolCallType = ( + x: unknown, + ): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } => + !!x && + typeof x === 'object' && + 'type' in (x as Record) && + ((x as { type: string }).type === 'in-progress-tool-call' || + (x as { type: string }).type === 'completed-tool-call'); + const isFirst = !isToolCallType(prev); + const isLast = !isToolCallType(next); + return ( + + ); + } + + default: + return null; + } + }), + [allMessages, vscode], + ); + + const hasContent = + messageHandling.messages.length > 0 || + messageHandling.isStreaming || + inProgressToolCalls.length > 0 || + completedToolCalls.length > 0 || + planEntries.length > 0 || + allMessages.length > 0; + + return ( +
+ {/* Top-level loading overlay */} + {isLoading && ( +
+
+
+

+ Preparing Qwen Code... +

+
+
+ )} + + { + sessionManagement.handleSwitchSession(sessionId); + sessionManagement.setSessionSearchQuery(''); + }} + onClose={() => sessionManagement.setShowSessionSelector(false)} + hasMore={sessionManagement.hasMore} + isLoading={sessionManagement.isLoading} + onLoadMore={sessionManagement.handleLoadMoreSessions} + /> + + + +
+ {!hasContent && !isLoading ? ( + isAuthenticated === false ? ( + { + vscode.postMessage({ type: 'login', data: {} }); + messageHandling.setWaitingForResponse( + 'Logging in to Qwen Code...', + ); + }} + /> + ) : isAuthenticated === null ? ( + + ) : ( + + ) + ) : ( + <> + {/* Render all messages and tool calls */} + {renderMessages()} + + {/* Waiting message positioned fixed above the input form to avoid layout shifts */} + {messageHandling.isWaitingForResponse && + messageHandling.loadingMessage && ( +
+ +
+ )} +
+ + )} +
+ + {isAuthenticated && ( + setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + onKeyDown={() => {}} + onSubmit={handleSubmitWithScroll} + onCancel={handleCancel} + onToggleEditMode={handleToggleEditMode} + onToggleThinking={handleToggleThinking} + onFocusActiveEditor={fileContext.focusActiveEditor} + onToggleSkipAutoActiveContext={() => + setSkipAutoActiveContext((v) => !v) + } + onShowCommandMenu={async () => { + if (inputFieldRef.current) { + inputFieldRef.current.focus(); + + const selection = window.getSelection(); + let position = { top: 0, left: 0 }; + + if (selection && selection.rangeCount > 0) { + try { + const range = selection.getRangeAt(0); + const rangeRect = range.getBoundingClientRect(); + if (rangeRect.top > 0 && rangeRect.left > 0) { + position = { + top: rangeRect.top, + left: rangeRect.left, + }; + } else { + const inputRect = + inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + } catch (error) { + console.error('[App] Error getting cursor position:', error); + const inputRect = + inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + } else { + const inputRect = inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + + await completion.openCompletion('/', '', position); + } + }} + onAttachContext={handleAttachContextClick} + completionIsOpen={completion.isOpen} + completionItems={completion.items} + onCompletionSelect={handleCompletionSelect} + onCompletionClose={completion.closeCompletion} + /> + )} + + {isAuthenticated && permissionRequest && ( + setPermissionRequest(null)} + /> + )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/MessageHandler.ts new file mode 100644 index 00000000..77d330b6 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/MessageHandler.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QwenAgentManager } from '../services/qwenAgentManager.js'; +import type { ConversationStore } from '../services/conversationStore.js'; +import { MessageRouter } from './handlers/MessageRouter.js'; + +/** + * MessageHandler (Refactored Version) + * This is a lightweight wrapper class that internally uses MessageRouter and various sub-handlers + * Maintains interface compatibility with the original code + */ +export class MessageHandler { + private router: MessageRouter; + + constructor( + agentManager: QwenAgentManager, + conversationStore: ConversationStore, + currentConversationId: string | null, + sendToWebView: (message: unknown) => void, + ) { + this.router = new MessageRouter( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + } + + /** + * Route messages to the corresponding handler + */ + async route(message: { type: string; data?: unknown }): Promise { + await this.router.route(message); + } + + /** + * Set current session ID + */ + setCurrentConversationId(id: string | null): void { + this.router.setCurrentConversationId(id); + } + + /** + * Get current session ID + */ + getCurrentConversationId(): string | null { + return this.router.getCurrentConversationId(); + } + + /** + * Set permission handler + */ + setPermissionHandler( + handler: (message: { type: string; data: { optionId: string } }) => void, + ): void { + this.router.setPermissionHandler(handler); + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.router.setLoginHandler(handler); + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.router.appendStreamContent(chunk); + } +} diff --git a/packages/vscode-ide-companion/src/webview/PanelManager.ts b/packages/vscode-ide-companion/src/webview/PanelManager.ts new file mode 100644 index 00000000..44f1a6ec --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/PanelManager.ts @@ -0,0 +1,385 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; + +/** + * Panel and Tab Manager + * Responsible for managing the creation, display, and tab tracking of WebView Panels + */ +export class PanelManager { + private panel: vscode.WebviewPanel | null = null; + private panelTab: vscode.Tab | null = null; + // Best-effort tracking of the group (by view column) that currently hosts + // the Qwen webview. We update this when creating/revealing the panel and + // whenever we can capture the Tab from the tab model. + private panelGroupViewColumn: vscode.ViewColumn | null = null; + + constructor( + private extensionUri: vscode.Uri, + private onPanelDispose: () => void, + ) {} + + /** + * Get the current Panel + */ + getPanel(): vscode.WebviewPanel | null { + return this.panel; + } + + /** + * Set Panel (for restoration) + */ + setPanel(panel: vscode.WebviewPanel): void { + console.log('[PanelManager] Setting panel for restoration'); + this.panel = panel; + } + + /** + * Create new WebView Panel + * @returns Whether it is a newly created Panel + */ + async createPanel(): Promise { + if (this.panel) { + return false; // Panel already exists + } + + // First, check if there's an existing Qwen Code group + const existingGroup = this.findExistingQwenCodeGroup(); + + if (existingGroup) { + // If Qwen Code webview already exists in a locked group, create the new panel in that same group + console.log( + '[PanelManager] Found existing Qwen Code group, creating panel in same group', + ); + this.panel = vscode.window.createWebviewPanel( + 'qwenCode.chat', + 'Qwen Code', + { viewColumn: existingGroup.viewColumn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }, + ); + // Track the group column hosting this panel + this.panelGroupViewColumn = existingGroup.viewColumn; + } else { + // If no existing Qwen Code group, create a new group to the right of the active editor group + try { + // Create a new group to the right of the current active group + await vscode.commands.executeCommand('workbench.action.newGroupRight'); + } catch (error) { + console.warn( + '[PanelManager] Failed to create right editor group (continuing):', + error, + ); + // Fallback: create in current group + const activeColumn = + vscode.window.activeTextEditor?.viewColumn || vscode.ViewColumn.One; + this.panel = vscode.window.createWebviewPanel( + 'qwenCode.chat', + 'Qwen Code', + { viewColumn: activeColumn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }, + ); + // Lock the group after creation + await this.autoLockEditorGroup(); + return true; + } + + // Get the new group's view column (should be the active one after creating right) + const newGroupColumn = vscode.window.tabGroups.activeTabGroup.viewColumn; + + this.panel = vscode.window.createWebviewPanel( + 'qwenCode.chat', + 'Qwen Code', + { viewColumn: newGroupColumn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }, + ); + + // Lock the group after creation + await this.autoLockEditorGroup(); + + // Track the newly created group's column + this.panelGroupViewColumn = newGroupColumn; + } + + // Set panel icon to Qwen logo + this.panel.iconPath = vscode.Uri.joinPath( + this.extensionUri, + 'assets', + 'icon.png', + ); + + // Try to capture Tab info shortly after creation so we can track the + // precise group even if the user later drags the tab between groups. + this.captureTab(); + + return true; // New panel created + } + + /** + * Find the group and view column where the existing Qwen Code webview is located + * @returns The found group and view column, or undefined if not found + */ + private findExistingQwenCodeGroup(): + | { group: vscode.TabGroup; viewColumn: vscode.ViewColumn } + | undefined { + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const input: unknown = (tab as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + + if ( + isWebviewInput(input) && + input.viewType === 'mainThreadWebview-qwenCode.chat' + ) { + // Found an existing Qwen Code tab + console.log('[PanelManager] Found existing Qwen Code group:', { + viewColumn: group.viewColumn, + tabCount: group.tabs.length, + isActive: group.isActive, + }); + return { + group, + viewColumn: group.viewColumn, + }; + } + } + } + + return undefined; + } + + /** + * Auto-lock editor group (only called when creating a new Panel) + * After creating/revealing the WebviewPanel, lock the active editor group so + * the group stays dedicated (users can still unlock manually). We still + * temporarily unlock before creation to allow adding tabs to an existing + * group; this method restores the locked state afterwards. + */ + async autoLockEditorGroup(): Promise { + if (!this.panel) { + return; + } + + try { + // The newly created panel is focused (preserveFocus: false), so this + // locks the correct, active editor group. + await vscode.commands.executeCommand('workbench.action.lockEditorGroup'); + console.log('[PanelManager] Group locked after panel creation'); + } catch (error) { + console.warn('[PanelManager] Failed to lock editor group:', error); + } + } + + /** + * Show Panel (reveal if exists, otherwise do nothing) + * @param preserveFocus Whether to preserve focus + */ + revealPanel(preserveFocus: boolean = true): void { + if (this.panel) { + // Prefer revealing in the currently tracked group to avoid reflowing groups. + const trackedColumn = ( + this.panelTab as unknown as { + group?: { viewColumn?: vscode.ViewColumn }; + } + )?.group?.viewColumn as vscode.ViewColumn | undefined; + const targetColumn: vscode.ViewColumn = + trackedColumn ?? + this.panelGroupViewColumn ?? + vscode.window.tabGroups.activeTabGroup.viewColumn; + this.panel.reveal(targetColumn, preserveFocus); + } + } + + /** + * Capture the Tab corresponding to the WebView Panel + * Used for tracking and managing Tab state + */ + captureTab(): void { + if (!this.panel) { + return; + } + + // Defer slightly so the tab model is updated after create/reveal + setTimeout(() => { + const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs); + const match = allTabs.find((t) => { + // Type guard for webview tab input + const input: unknown = (t as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + const isWebview = isWebviewInput(input); + const sameViewType = isWebview && input.viewType === 'qwenCode.chat'; + const sameLabel = t.label === this.panel!.title; + return !!(sameViewType || sameLabel); + }); + this.panelTab = match ?? null; + // Update last-known group column if we can read it from the captured tab + try { + const groupViewColumn = ( + this.panelTab as unknown as { + group?: { viewColumn?: vscode.ViewColumn }; + } + )?.group?.viewColumn; + if (groupViewColumn !== null) { + this.panelGroupViewColumn = groupViewColumn as vscode.ViewColumn; + } + } catch { + // Best effort only; ignore if the API shape differs + } + }, 50); + } + + /** + * Register the dispose event handler for the Panel + * @param disposables Array used to store Disposable objects + */ + registerDisposeHandler(disposables: vscode.Disposable[]): void { + if (!this.panel) { + return; + } + + this.panel.onDidDispose( + () => { + // Capture the group we intend to clean up before we clear fields + const targetColumn: vscode.ViewColumn | null = + // Prefer the group from the captured tab if available + (( + this.panelTab as unknown as { + group?: { viewColumn?: vscode.ViewColumn }; + } + )?.group?.viewColumn as vscode.ViewColumn | undefined) ?? + // Fall back to our last-known group column + this.panelGroupViewColumn ?? + null; + + this.panel = null; + this.panelTab = null; + this.onPanelDispose(); + + // After VS Code updates its tab model, check if that group is now + // empty (and typically locked for Qwen). If so, close the group to + // avoid leaving an empty locked column when the user closes Qwen. + if (targetColumn !== null) { + const column: vscode.ViewColumn = targetColumn; + setTimeout(async () => { + try { + const groups = vscode.window.tabGroups.all; + const group = groups.find((g) => g.viewColumn === column); + // If the group that hosted Qwen is now empty, close it to avoid + // leaving an empty locked column around. VS Code's stable API + // does not expose the lock state on TabGroup, so we only check + // for emptiness here. + if (group && group.tabs.length === 0) { + // Focus the group we want to close + await this.focusGroupByColumn(column); + // Try closeGroup first; fall back to removeActiveEditorGroup + try { + await vscode.commands.executeCommand( + 'workbench.action.closeGroup', + ); + } catch { + try { + await vscode.commands.executeCommand( + 'workbench.action.removeActiveEditorGroup', + ); + } catch (err) { + console.warn( + '[PanelManager] Failed to close empty group after Qwen panel disposed:', + err, + ); + } + } + } + } catch (err) { + console.warn( + '[PanelManager] Error while trying to close empty Qwen group:', + err, + ); + } + }, 50); + } + }, + null, + disposables, + ); + } + + /** + * Focus the editor group at the given view column by stepping left/right. + * This avoids depending on Nth-group focus commands that may not exist. + */ + private async focusGroupByColumn(target: vscode.ViewColumn): Promise { + const maxHops = 20; // safety guard for unusual layouts + let hops = 0; + while ( + vscode.window.tabGroups.activeTabGroup.viewColumn !== target && + hops < maxHops + ) { + const current = vscode.window.tabGroups.activeTabGroup.viewColumn; + if (current < target) { + await vscode.commands.executeCommand( + 'workbench.action.focusRightGroup', + ); + } else if (current > target) { + await vscode.commands.executeCommand('workbench.action.focusLeftGroup'); + } else { + break; + } + hops++; + } + } + + /** + * Register the view state change event handler + * @param disposables Array used to store Disposable objects + */ + registerViewStateChangeHandler(disposables: vscode.Disposable[]): void { + if (!this.panel) { + return; + } + + this.panel.onDidChangeViewState( + () => { + if (this.panel && this.panel.visible) { + this.captureTab(); + } + }, + null, + disposables, + ); + } + + /** + * Dispose Panel + */ + dispose(): void { + this.panel?.dispose(); + this.panel = null; + this.panelTab = null; + } +} diff --git a/packages/vscode-ide-companion/src/webview/WebViewContent.ts b/packages/vscode-ide-companion/src/webview/WebViewContent.ts new file mode 100644 index 00000000..8f802c84 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/WebViewContent.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { escapeHtml } from './utils/webviewUtils.js'; + +/** + * WebView HTML Content Generator + * Responsible for generating the HTML content of the WebView + */ +export class WebViewContent { + /** + * Generate HTML content for the WebView + * @param panel WebView Panel + * @param extensionUri Extension URI + * @returns HTML string + */ + static generate( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + ): string { + const scriptUri = panel.webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js'), + ); + + // Convert extension URI for webview access - this allows frontend to construct resource paths + const extensionUriForWebview = panel.webview.asWebviewUri(extensionUri); + + // Escape URI for HTML to prevent potential injection attacks + const safeExtensionUri = escapeHtml(extensionUriForWebview.toString()); + const safeScriptUri = escapeHtml(scriptUri.toString()); + + return ` + + + + + + Qwen Code + + +
+ + +`; + } +} diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts new file mode 100644 index 00000000..4ab55283 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -0,0 +1,1207 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { QwenAgentManager } from '../services/qwenAgentManager.js'; +import { ConversationStore } from '../services/conversationStore.js'; +import type { AcpPermissionRequest } from '../types/acpTypes.js'; +import { PanelManager } from '../webview/PanelManager.js'; +import { MessageHandler } from '../webview/MessageHandler.js'; +import { WebViewContent } from '../webview/WebViewContent.js'; +import { getFileName } from './utils/webviewUtils.js'; +import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; + +export class WebViewProvider { + private panelManager: PanelManager; + private messageHandler: MessageHandler; + private agentManager: QwenAgentManager; + private conversationStore: ConversationStore; + private disposables: vscode.Disposable[] = []; + private agentInitialized = false; // Track if agent has been initialized + // Track a pending permission request and its resolver so extension commands + // can "simulate" user choice from the command palette (e.g. after accepting + // a diff, auto-allow read/execute, or auto-reject on cancel). + private pendingPermissionRequest: AcpPermissionRequest | null = null; + private pendingPermissionResolve: ((optionId: string) => void) | null = null; + // Track current ACP mode id to influence permission/diff behavior + private currentModeId: ApprovalModeValue | null = null; + + constructor( + private context: vscode.ExtensionContext, + private extensionUri: vscode.Uri, + ) { + this.agentManager = new QwenAgentManager(); + this.conversationStore = new ConversationStore(context); + this.panelManager = new PanelManager(extensionUri, () => { + // Panel dispose callback + this.disposables.forEach((d) => d.dispose()); + }); + this.messageHandler = new MessageHandler( + this.agentManager, + this.conversationStore, + null, + (message) => this.sendMessageToWebView(message), + ); + + // Set login handler for /login command - direct force re-login + this.messageHandler.setLoginHandler(async () => { + await this.forceReLogin(); + }); + + // Setup agent callbacks + this.agentManager.onMessage((message) => { + // Do not suppress messages during checkpoint saves. + // Checkpoint persistence now writes directly to disk and should not + // generate ACP session/update traffic. Suppressing here could drop + // legitimate history replay messages (e.g., session/load) or + // assistant replies when a new prompt starts while an async save is + // still finishing. + this.sendMessageToWebView({ + type: 'message', + data: message, + }); + }); + + this.agentManager.onStreamChunk((chunk: string) => { + // Always forward stream chunks; do not gate on checkpoint saves. + // See note in onMessage() above. + this.messageHandler.appendStreamContent(chunk); + this.sendMessageToWebView({ + type: 'streamChunk', + data: { chunk }, + }); + }); + + // Setup thought chunk handler + this.agentManager.onThoughtChunk((chunk: string) => { + // Always forward thought chunks; do not gate on checkpoint saves. + this.messageHandler.appendStreamContent(chunk); + this.sendMessageToWebView({ + type: 'thoughtChunk', + data: { chunk }, + }); + }); + + // Surface available modes and current mode (from ACP initialize) + this.agentManager.onModeInfo((info) => { + try { + const current = (info?.currentModeId || null) as + | 'plan' + | 'default' + | 'auto-edit' + | 'yolo' + | null; + this.currentModeId = current; + } catch (_error) { + // Ignore error when parsing mode info + } + this.sendMessageToWebView({ + type: 'modeInfo', + data: info || {}, + }); + }); + + // Surface mode changes (from ACP or immediate set_mode response) + this.agentManager.onModeChanged((modeId) => { + try { + this.currentModeId = modeId; + } catch (_error) { + // Ignore error when setting mode id + } + this.sendMessageToWebView({ + type: 'modeChanged', + data: { modeId }, + }); + }); + + // Setup end-turn handler from ACP stopReason notifications + this.agentManager.onEndTurn((reason) => { + // Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere + this.sendMessageToWebView({ + type: 'streamEnd', + data: { + timestamp: Date.now(), + reason: reason || 'end_turn', + }, + }); + }); + + // Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager + // and sent via onStreamChunk callback + this.agentManager.onToolCall((update) => { + // Always surface tool calls; they are part of the live assistant flow. + // Cast update to access sessionUpdate property + const updateData = update as unknown as Record; + + // Determine message type from sessionUpdate field + // If sessionUpdate is missing, infer from content: + // - If has kind/title/rawInput, it's likely initial tool_call + // - If only has status/content updates, it's tool_call_update + let messageType = updateData.sessionUpdate as string | undefined; + if (!messageType) { + // Infer type: if has kind or title, assume initial call; otherwise update + if (updateData.kind || updateData.title || updateData.rawInput) { + messageType = 'tool_call'; + } else { + messageType = 'tool_call_update'; + } + } + + this.sendMessageToWebView({ + type: 'toolCall', + data: { + type: messageType, + ...updateData, + }, + }); + }); + + // Setup plan handler + this.agentManager.onPlan((entries) => { + this.sendMessageToWebView({ + type: 'plan', + data: { entries }, + }); + }); + + this.agentManager.onPermissionRequest( + async (request: AcpPermissionRequest) => { + // Auto-approve in auto/yolo mode (no UI, no diff) + if (this.isAutoMode()) { + const options = request.options || []; + const pick = (substr: string) => + options.find((o) => + (o.optionId || '').toLowerCase().includes(substr), + )?.optionId; + const pickByKind = (k: string) => + options.find((o) => (o.kind || '').toLowerCase().includes(k)) + ?.optionId; + const optionId = + pick('allow_once') || + pickByKind('allow') || + pick('proceed') || + options[0]?.optionId || + 'allow_once'; + return optionId; + } + + // Send permission request to WebView + this.sendMessageToWebView({ + type: 'permissionRequest', + data: request, + }); + + // Wait for user response + return new Promise((resolve) => { + // cache the pending request and its resolver so commands can resolve it + this.pendingPermissionRequest = request; + this.pendingPermissionResolve = (optionId: string) => { + try { + resolve(optionId); + } finally { + // Always clear pending state + this.pendingPermissionRequest = null; + this.pendingPermissionResolve = null; + // Also instruct the webview UI to close its drawer if it is open + this.sendMessageToWebView({ + type: 'permissionResolved', + data: { optionId }, + }); + // If allowed/proceeded, close any open qwen-diff editors and suppress re-open briefly + const isCancel = + optionId === 'cancel' || + optionId.toLowerCase().includes('reject'); + if (!isCancel) { + try { + void vscode.commands.executeCommand('qwen.diff.closeAll'); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to close diffs after allow (resolver):', + err, + ); + } + try { + void vscode.commands.executeCommand( + 'qwen.diff.suppressBriefly', + ); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to suppress diffs briefly:', + err, + ); + } + } + } + }; + const handler = (message: { + type: string; + data: { optionId: string }; + }) => { + if (message.type !== 'permissionResponse') { + return; + } + + const optionId = message.data.optionId || ''; + + // 1) First resolve the optionId back to ACP so the agent isn't blocked + this.pendingPermissionResolve?.(optionId); + + // 2) If user cancelled/rejected, proactively stop current generation + const isCancel = + optionId === 'cancel' || + optionId.toLowerCase().includes('reject'); + + if (isCancel) { + // Fire and forget โ€“ do not block the ACP resolve + (async () => { + try { + // Stop server-side generation + await this.agentManager.cancelCurrentPrompt(); + } catch (err) { + console.warn( + '[WebViewProvider] cancelCurrentPrompt error:', + err, + ); + } + + // Ensure the webview exits streaming state immediately + this.sendMessageToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + + // Synthesize a failed tool_call_update to match CLI UX + try { + const toolCallId = + (request.toolCall as { toolCallId?: string } | undefined) + ?.toolCallId || ''; + const title = + (request.toolCall as { title?: string } | undefined) + ?.title || ''; + // Normalize kind for UI โ€“ fall back to 'execute' + let kind = (( + request.toolCall as { kind?: string } | undefined + )?.kind || 'execute') as string; + if (!kind && title) { + const t = title.toLowerCase(); + if (t.includes('read') || t.includes('cat')) { + kind = 'read'; + } else if (t.includes('write') || t.includes('edit')) { + kind = 'edit'; + } else { + kind = 'execute'; + } + } + + this.sendMessageToWebView({ + type: 'toolCall', + data: { + type: 'tool_call_update', + toolCallId, + title, + kind, + status: 'failed', + // Best-effort pass-through (used by UI hints) + rawInput: (request.toolCall as { rawInput?: unknown }) + ?.rawInput, + locations: ( + request.toolCall as { + locations?: Array<{ + path: string; + line?: number | null; + }>; + } + )?.locations, + }, + }); + } catch (err) { + console.warn( + '[WebViewProvider] failed to synthesize failed tool_call_update:', + err, + ); + } + })(); + } + // If user allowed/proceeded, proactively close any open qwen-diff editors and suppress re-open briefly + else { + try { + void vscode.commands.executeCommand('qwen.diff.closeAll'); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to close diffs after allow:', + err, + ); + } + try { + void vscode.commands.executeCommand( + 'qwen.diff.suppressBriefly', + ); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to suppress diffs briefly:', + err, + ); + } + } + }; + // Store handler in message handler + this.messageHandler.setPermissionHandler(handler); + }); + }, + ); + } + + async show(): Promise { + const panel = this.panelManager.getPanel(); + + if (panel) { + // Reveal the existing panel + this.panelManager.revealPanel(true); + this.panelManager.captureTab(); + return; + } + + // Create new panel + const isNewPanel = await this.panelManager.createPanel(); + + if (!isNewPanel) { + return; // Failed to create panel + } + + const newPanel = this.panelManager.getPanel(); + if (!newPanel) { + return; + } + + // Set up state serialization + newPanel.onDidChangeViewState(() => { + console.log( + '[WebViewProvider] Panel view state changed, triggering serialization check', + ); + }); + + // Capture the Tab that corresponds to our WebviewPanel + this.panelManager.captureTab(); + + // Auto-lock editor group when opened in new column + await this.panelManager.autoLockEditorGroup(); + + newPanel.webview.html = WebViewContent.generate( + newPanel, + this.extensionUri, + ); + + // Handle messages from WebView + newPanel.webview.onDidReceiveMessage( + async (message: { type: string; data?: unknown }) => { + // Suppress UI-originated diff opens in auto/yolo mode + if (message.type === 'openDiff' && this.isAutoMode()) { + return; + } + // Allow webview to request updating the VS Code tab title + if (message.type === 'updatePanelTitle') { + const title = String( + (message.data as { title?: unknown } | undefined)?.title ?? '', + ).trim(); + const panelRef = this.panelManager.getPanel(); + if (panelRef) { + panelRef.title = title || 'Qwen Code'; + } + return; + } + await this.messageHandler.route(message); + }, + null, + this.disposables, + ); + + // Listen for view state changes (no pin/lock; just keep tab reference fresh) + this.panelManager.registerViewStateChangeHandler(this.disposables); + + // Register panel dispose handler + this.panelManager.registerDisposeHandler(this.disposables); + + // Listen for active editor changes and notify WebView + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + // If switching to a non-text editor (like webview), keep the last state + if (!editor) { + // Don't update - keep previous state + return; + } + + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (editor && !editor.selection.isEmpty) { + const selection = editor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + }, + ); + this.disposables.push(editorChangeDisposable); + + // Listen for text selection changes + const selectionChangeDisposable = + vscode.window.onDidChangeTextEditorSelection((event) => { + const editor = event.textEditor; + if (editor === vscode.window.activeTextEditor) { + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (!event.selections[0].isEmpty) { + const selection = event.selections[0]; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + + // Mode callbacks are registered in constructor; no-op here + } + }); + this.disposables.push(selectionChangeDisposable); + + // Send initial active editor state to WebView + const initialEditor = vscode.window.activeTextEditor; + if (initialEditor) { + const filePath = initialEditor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + let selectionInfo = null; + if (!initialEditor.selection.isEmpty) { + const selection = initialEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + + // Attempt to restore authentication state and initialize connection + console.log( + '[WebViewProvider] Attempting to restore auth state and connection...', + ); + await this.attemptAuthStateRestoration(); + } + + /** + * Attempt to restore authentication state and initialize connection + * This is called when the webview is first shown + */ + private async attemptAuthStateRestoration(): Promise { + try { + console.log('[WebViewProvider] Attempting connection...'); + // Attempt a connection to detect prior auth without forcing login + await this.initializeAgentConnection({ autoAuthenticate: false }); + } catch (error) { + console.error( + '[WebViewProvider] Error in attemptAuthStateRestoration:', + error, + ); + await this.initializeEmptyConversation(); + } + } + + /** + * Initialize agent connection and session + * Can be called from show() or via /login command + */ + async initializeAgentConnection(options?: { + autoAuthenticate?: boolean; + }): Promise { + return this.doInitializeAgentConnection(options); + } + + /** + * Internal: perform actual connection/initialization (no auth locking). + */ + private async doInitializeAgentConnection(options?: { + autoAuthenticate?: boolean; + }): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; + const run = async () => { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + console.log( + '[WebViewProvider] Starting initialization, workingDir:', + workingDir, + ); + console.log( + `[WebViewProvider] Using CLI-managed authentication (autoAuth=${autoAuthenticate})`, + ); + + const bundledCliEntry = vscode.Uri.joinPath( + this.extensionUri, + 'dist', + 'qwen-cli', + 'cli.js', + ).fsPath; + + try { + console.log('[WebViewProvider] Connecting to agent...'); + + // Pass the detected CLI path to ensure we use the correct installation + const connectResult = await this.agentManager.connect( + workingDir, + bundledCliEntry, + options, + ); + console.log('[WebViewProvider] Agent connected successfully'); + this.agentInitialized = true; + + // If authentication is required and autoAuthenticate is false, + // send authState message and return without creating session + if (connectResult.requiresAuth && !autoAuthenticate) { + console.log( + '[WebViewProvider] Authentication required but auto-auth disabled, sending authState and returning', + ); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + // Initialize empty conversation to allow browsing history + await this.initializeEmptyConversation(); + return; + } + + if (connectResult.requiresAuth) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } + + // Load messages from the current Qwen session + const sessionReady = await this.loadCurrentSessionMessages(options); + + if (sessionReady) { + // Notify webview that agent is connected + this.sendMessageToWebView({ + type: 'agentConnected', + data: {}, + }); + } else { + console.log( + '[WebViewProvider] Session creation deferred until user logs in.', + ); + } + } catch (_error) { + console.error('[WebViewProvider] Agent connection error:', _error); + vscode.window.showWarningMessage( + `Failed to connect to Qwen CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`, + ); + // Fallback to empty conversation + await this.initializeEmptyConversation(); + + // Notify webview that agent connection failed + this.sendMessageToWebView({ + type: 'agentConnectionError', + data: { + message: _error instanceof Error ? _error.message : String(_error), + }, + }); + } + }; + + return run(); + } + + /** + * Force re-login by clearing auth cache and reconnecting + * Called when user explicitly uses /login command + */ + async forceReLogin(): Promise { + console.log('[WebViewProvider] Force re-login requested'); + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, + }, + async (progress) => { + try { + progress.report({ message: 'Preparing sign-in...' }); + + // Disconnect existing connection if any + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + console.log('[WebViewProvider] Existing connection disconnected'); + } catch (_error) { + console.log('[WebViewProvider] Error disconnecting:', _error); + } + this.agentInitialized = false; + } + + // Wait a moment for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 300)); + + progress.report({ + message: 'Connecting to CLI and starting sign-in...', + }); + + // Reinitialize connection (will trigger fresh authentication) + await this.doInitializeAgentConnection({ autoAuthenticate: true }); + console.log( + '[WebViewProvider] Force re-login completed successfully', + ); + + // Send success notification to WebView + this.sendMessageToWebView({ + type: 'loginSuccess', + data: { message: 'Successfully logged in!' }, + }); + } catch (_error) { + console.error('[WebViewProvider] Force re-login failed:', _error); + console.error( + '[WebViewProvider] Error stack:', + _error instanceof Error ? _error.stack : 'N/A', + ); + + // Send error notification to WebView + this.sendMessageToWebView({ + type: 'loginError', + data: { + message: `Login failed: ${_error instanceof Error ? _error.message : String(_error)}`, + }, + }); + + throw _error; + } + }, + ); + } + + /** + * Refresh connection without clearing auth cache + * Called when restoring WebView after VSCode restart + */ + async refreshConnection(): Promise { + console.log('[WebViewProvider] Refresh connection requested'); + + // Disconnect existing connection if any + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + console.log('[WebViewProvider] Existing connection disconnected'); + } catch (_error) { + console.log('[WebViewProvider] Error disconnecting:', _error); + } + this.agentInitialized = false; + } + + // Wait a moment for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Reinitialize connection (will use cached auth if available) + try { + await this.initializeAgentConnection(); + console.log( + '[WebViewProvider] Connection refresh completed successfully', + ); + + // Notify webview that agent is connected after refresh + this.sendMessageToWebView({ + type: 'agentConnected', + data: {}, + }); + } catch (_error) { + console.error('[WebViewProvider] Connection refresh failed:', _error); + + // Notify webview that agent connection failed after refresh + this.sendMessageToWebView({ + type: 'agentConnectionError', + data: { + message: _error instanceof Error ? _error.message : String(_error), + }, + }); + + throw _error; + } + } + + /** + * Load messages from current Qwen session + * Skips session restoration and creates a new session directly + */ + private async loadCurrentSessionMessages(options?: { + autoAuthenticate?: boolean; + }): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; + let sessionReady = false; + try { + console.log( + '[WebViewProvider] Initializing with new session (skipping restoration)', + ); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + // avoid creating another session if connect() already created one. + if (!this.agentManager.currentSessionId) { + if (!autoAuthenticate) { + console.log( + '[WebViewProvider] Skipping ACP session creation until user logs in.', + ); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } else { + try { + await this.agentManager.createNewSession(workingDir, { + autoAuthenticate, + }); + console.log('[WebViewProvider] ACP session created successfully'); + sessionReady = true; + } catch (sessionError) { + const requiresAuth = isAuthenticationRequiredError(sessionError); + if (requiresAuth && !autoAuthenticate) { + console.log( + '[WebViewProvider] ACP session requires authentication; waiting for explicit login.', + ); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } else { + console.error( + '[WebViewProvider] Failed to create ACP session:', + sessionError, + ); + vscode.window.showWarningMessage( + `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, + ); + } + } + } + } else { + console.log( + '[WebViewProvider] Existing ACP session detected, skipping new session creation', + ); + sessionReady = true; + } + + await this.initializeEmptyConversation(); + } catch (_error) { + console.error( + '[WebViewProvider] Failed to load session messages:', + _error, + ); + vscode.window.showErrorMessage( + `Failed to load session messages: ${_error}`, + ); + await this.initializeEmptyConversation(); + return false; + } + + return sessionReady; + } + + /** + * Initialize an empty conversation + * Creates a new conversation and notifies WebView + */ + private async initializeEmptyConversation(): Promise { + try { + console.log('[WebViewProvider] Initializing empty conversation'); + const newConv = await this.conversationStore.createConversation(); + this.messageHandler.setCurrentConversationId(newConv.id); + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: newConv, + }); + console.log( + '[WebViewProvider] Empty conversation initialized:', + this.messageHandler.getCurrentConversationId(), + ); + } catch (_error) { + console.error( + '[WebViewProvider] Failed to initialize conversation:', + _error, + ); + // Send empty state to WebView as fallback + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: { id: 'temp', messages: [] }, + }); + } + } + + /** + * Send message to WebView + */ + private sendMessageToWebView(message: unknown): void { + const panel = this.panelManager.getPanel(); + panel?.webview.postMessage(message); + } + + /** + * Whether there is a pending permission decision awaiting an option. + */ + hasPendingPermission(): boolean { + return !!this.pendingPermissionResolve; + } + + /** Get current ACP mode id (if known). */ + getCurrentModeId(): ApprovalModeValue | null { + return this.currentModeId; + } + + /** True if diffs/permissions should be auto-handled without prompting. */ + isAutoMode(): boolean { + return this.currentModeId === 'auto-edit' || this.currentModeId === 'yolo'; + } + + /** Used by extension to decide if diffs should be suppressed. */ + shouldSuppressDiff(): boolean { + return this.isAutoMode(); + } + + /** + * Simulate selecting a permission option while a request drawer is open. + * The choice can be a concrete optionId or a shorthand intent. + */ + respondToPendingPermission( + choice: { optionId: string } | 'accept' | 'allow' | 'reject' | 'cancel', + ): void { + if (!this.pendingPermissionResolve || !this.pendingPermissionRequest) { + return; // nothing to do + } + + const options = this.pendingPermissionRequest.options || []; + + const pickByKind = (substr: string, preferOnce = false) => { + const lc = substr.toLowerCase(); + const filtered = options.filter((o) => + (o.kind || '').toLowerCase().includes(lc), + ); + if (preferOnce) { + const once = filtered.find((o) => + (o.optionId || '').toLowerCase().includes('once'), + ); + if (once) { + return once.optionId; + } + } + return filtered[0]?.optionId; + }; + + const pickByOptionId = (substr: string) => + options.find((o) => (o.optionId || '').toLowerCase().includes(substr)) + ?.optionId; + + let optionId: string | undefined; + + if (typeof choice === 'object') { + optionId = choice.optionId; + } else { + const c = choice.toLowerCase(); + if (c === 'accept' || c === 'allow') { + // Prefer an allow_once/proceed_once style option, then any allow/proceed + optionId = + pickByKind('allow', true) || + pickByOptionId('proceed_once') || + pickByKind('allow') || + pickByOptionId('proceed') || + options[0]?.optionId; // last resort: first option + } else if (c === 'cancel' || c === 'reject') { + // Prefer explicit cancel, then a reject option + optionId = + options.find((o) => o.optionId === 'cancel')?.optionId || + pickByKind('reject') || + pickByOptionId('cancel') || + pickByOptionId('reject') || + 'cancel'; + } + } + + if (!optionId) { + return; + } + + try { + this.pendingPermissionResolve(optionId); + } catch (_error) { + console.warn( + '[WebViewProvider] respondToPendingPermission failed:', + _error, + ); + } + } + + /** + * Reset agent initialization state + * Call this when auth cache is cleared to force re-authentication + */ + resetAgentState(): void { + console.log('[WebViewProvider] Resetting agent state'); + this.agentInitialized = false; + // Disconnect existing connection + this.agentManager.disconnect(); + } + + /** + * Restore an existing WebView panel (called during VSCode restart) + * This sets up the panel with all event listeners + */ + async restorePanel(panel: vscode.WebviewPanel): Promise { + console.log('[WebViewProvider] Restoring WebView panel'); + console.log( + '[WebViewProvider] Using CLI-managed authentication in restore', + ); + this.panelManager.setPanel(panel); + + // Ensure restored tab title starts from default label + try { + panel.title = 'Qwen Code'; + } catch (e) { + console.warn( + '[WebViewProvider] Failed to reset restored panel title:', + e, + ); + } + + panel.webview.html = WebViewContent.generate(panel, this.extensionUri); + + // Handle messages from WebView (restored panel) + panel.webview.onDidReceiveMessage( + async (message: { type: string; data?: unknown }) => { + // Suppress UI-originated diff opens in auto/yolo mode + if (message.type === 'openDiff' && this.isAutoMode()) { + return; + } + if (message.type === 'updatePanelTitle') { + const title = String( + (message.data as { title?: unknown } | undefined)?.title ?? '', + ).trim(); + const panelRef = this.panelManager.getPanel(); + if (panelRef) { + panelRef.title = title || 'Qwen Code'; + } + return; + } + await this.messageHandler.route(message); + }, + null, + this.disposables, + ); + + // Register view state change handler + this.panelManager.registerViewStateChangeHandler(this.disposables); + + // Register dispose handler + this.panelManager.registerDisposeHandler(this.disposables); + + // Listen for active editor changes and notify WebView + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + // If switching to a non-text editor (like webview), keep the last state + if (!editor) { + // Don't update - keep previous state + return; + } + + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (editor && !editor.selection.isEmpty) { + const selection = editor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + }, + ); + this.disposables.push(editorChangeDisposable); + + // Send initial active editor state to WebView + const initialEditor = vscode.window.activeTextEditor; + if (initialEditor) { + const filePath = initialEditor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + let selectionInfo = null; + if (!initialEditor.selection.isEmpty) { + const selection = initialEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + + // Listen for text selection changes (restore path) + const selectionChangeDisposableRestore = + vscode.window.onDidChangeTextEditorSelection((event) => { + const editor = event.textEditor; + if (editor === vscode.window.activeTextEditor) { + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (!event.selections[0].isEmpty) { + const selection = event.selections[0]; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + }); + this.disposables.push(selectionChangeDisposableRestore); + + // Capture the tab reference on restore + this.panelManager.captureTab(); + + console.log('[WebViewProvider] Panel restored successfully'); + + // Attempt to restore authentication state and initialize connection + console.log( + '[WebViewProvider] Attempting to restore auth state and connection after restore...', + ); + await this.attemptAuthStateRestoration(); + } + + /** + * Get the current state for serialization + * This is used when VSCode restarts to restore the WebView + */ + getState(): { + conversationId: string | null; + agentInitialized: boolean; + } { + console.log('[WebViewProvider] Getting state for serialization'); + console.log( + '[WebViewProvider] Current conversationId:', + this.messageHandler.getCurrentConversationId(), + ); + console.log( + '[WebViewProvider] Current agentInitialized:', + this.agentInitialized, + ); + const state = { + conversationId: this.messageHandler.getCurrentConversationId(), + agentInitialized: this.agentInitialized, + }; + console.log('[WebViewProvider] Returning state:', state); + return state; + } + + /** + * Get the current panel + */ + getPanel(): vscode.WebviewPanel | null { + return this.panelManager.getPanel(); + } + + /** + * Restore state after VSCode restart + */ + restoreState(state: { + conversationId: string | null; + agentInitialized: boolean; + }): void { + console.log('[WebViewProvider] Restoring state:', state); + this.messageHandler.setCurrentConversationId(state.conversationId); + this.agentInitialized = state.agentInitialized; + console.log( + '[WebViewProvider] State restored. agentInitialized:', + this.agentInitialized, + ); + + // Reload content after restore + const panel = this.panelManager.getPanel(); + if (panel) { + panel.webview.html = WebViewContent.generate(panel, this.extensionUri); + } + } + + /** + * Create a new session in the current panel + * This is called when the user clicks the "New Session" button + */ + async createNewSession(): Promise { + // WebView mode - create new session via agent manager + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + // Create new Qwen session via agent manager + await this.agentManager.createNewSession(workingDir); + + // Clear current conversation UI + this.sendMessageToWebView({ + type: 'conversationCleared', + data: {}, + }); + } catch (_error) { + console.error('[WebViewProvider] Failed to create new session:', _error); + vscode.window.showErrorMessage(`Failed to create new session: ${_error}`); + } + } + + /** + * Dispose the WebView provider and clean up resources + */ + dispose(): void { + this.panelManager.dispose(); + this.agentManager.disconnect(); + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx new file mode 100644 index 00000000..00e5bcca --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx @@ -0,0 +1,312 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useState, useRef } from 'react'; +import type { PermissionOption, ToolCall } from './PermissionRequest.js'; + +interface PermissionDrawerProps { + isOpen: boolean; + options: PermissionOption[]; + toolCall: ToolCall; + onResponse: (optionId: string) => void; + onClose?: () => void; +} + +export const PermissionDrawer: React.FC = ({ + isOpen, + options, + toolCall, + onResponse, + onClose, +}) => { + const [focusedIndex, setFocusedIndex] = useState(0); + const [customMessage, setCustomMessage] = useState(''); + const containerRef = useRef(null); + // Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting + const customInputRef = useRef(null); + + console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall); + // Prefer file name from locations, fall back to content[].path if present + const getAffectedFileName = (): string => { + const fromLocations = toolCall.locations?.[0]?.path; + if (fromLocations) { + return fromLocations.split('/').pop() || fromLocations; + } + // Some tool calls (e.g. write/edit with diff content) only include path in content + const fromContent = Array.isArray(toolCall.content) + ? ( + toolCall.content.find( + (c: unknown) => + typeof c === 'object' && + c !== null && + 'path' in (c as Record), + ) as { path?: unknown } | undefined + )?.path + : undefined; + if (typeof fromContent === 'string' && fromContent.length > 0) { + return fromContent.split('/').pop() || fromContent; + } + return 'file'; + }; + + // Get the title for the permission request + const getTitle = () => { + if (toolCall.kind === 'edit' || toolCall.kind === 'write') { + const fileName = getAffectedFileName(); + return ( + <> + Make this edit to{' '} + + {fileName} + + ? + + ); + } + if (toolCall.kind === 'execute' || toolCall.kind === 'bash') { + return 'Allow this bash command?'; + } + if (toolCall.kind === 'read') { + const fileName = getAffectedFileName(); + return ( + <> + Allow read from{' '} + + {fileName} + + ? + + ); + } + return toolCall.title || 'Permission Required'; + }; + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) { + return; + } + + // Number keys 1-9 for quick select + const numMatch = e.key.match(/^[1-9]$/); + if ( + numMatch && + !customInputRef.current?.contains(document.activeElement) + ) { + const index = parseInt(e.key, 10) - 1; + if (index < options.length) { + e.preventDefault(); + onResponse(options[index].optionId); + } + return; + } + + // Arrow keys for navigation + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const totalItems = options.length + 1; // +1 for custom input + if (e.key === 'ArrowDown') { + setFocusedIndex((prev) => (prev + 1) % totalItems); + } else { + setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems); + } + } + + // Enter to select + if ( + e.key === 'Enter' && + !customInputRef.current?.contains(document.activeElement) + ) { + e.preventDefault(); + if (focusedIndex < options.length) { + onResponse(options[focusedIndex].optionId); + } + } + + // Escape to cancel permission and close (align with CLI behavior) + if (e.key === 'Escape') { + e.preventDefault(); + const rejectOptionId = + options.find((o) => o.kind.includes('reject'))?.optionId || + options.find((o) => o.optionId === 'cancel')?.optionId || + 'cancel'; + onResponse(rejectOptionId); + if (onClose) { + onClose(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, options, onResponse, onClose, focusedIndex]); + + // Focus container when opened + useEffect(() => { + if (isOpen && containerRef.current) { + containerRef.current.focus(); + } + }, [isOpen]); + + // Reset focus to the first option when the drawer opens or the options change + useEffect(() => { + if (isOpen) { + setFocusedIndex(0); + } + }, [isOpen, options.length]); + + if (!isOpen) { + return null; + } + + return ( +
+ {/* Main container */} +
+ {/* Background layer */} +
+ + {/* Title + Description (from toolCall.title) */} +
+
+ {getTitle()} +
+ {(toolCall.kind === 'edit' || + toolCall.kind === 'write' || + toolCall.kind === 'read' || + toolCall.kind === 'execute' || + toolCall.kind === 'bash') && + toolCall.title && ( +
+ {toolCall.title} +
+ )} +
+ + {/* Options */} +
+ {options.map((option, index) => { + const isFocused = focusedIndex === index; + + return ( + + ); + })} + + {/* Custom message input (extracted component) */} + {(() => { + const isFocused = focusedIndex === options.length; + const rejectOptionId = options.find((o) => + o.kind.includes('reject'), + )?.optionId; + return ( + setFocusedIndex(options.length)} + onSubmitReject={() => { + if (rejectOptionId) { + onResponse(rejectOptionId); + } + }} + inputRef={customInputRef} + /> + ); + })()} +
+
+ + {/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */} +
+ ); +}; + +/** + * CustomMessageInputRow: Reusable custom input row component (without hooks) + */ +interface CustomMessageInputRowProps { + isFocused: boolean; + customMessage: string; + setCustomMessage: (val: string) => void; + onFocusRow: () => void; // Set focus when mouse enters or input box is focused + onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option) + inputRef: React.RefObject; +} + +const CustomMessageInputRow: React.FC = ({ + isFocused, + customMessage, + setCustomMessage, + onFocusRow, + onSubmitReject, + inputRef, +}) => ( +
inputRef.current?.focus()} + > + | undefined} + type="text" + placeholder="Tell Qwen what to do instead" + spellCheck={false} + className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70" + style={{ color: 'var(--app-input-foreground)' }} + value={customMessage} + onChange={(e) => setCustomMessage(e.target.value)} + onFocus={onFocusRow} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { + e.preventDefault(); + onSubmitReject(); + } + }} + /> +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx new file mode 100644 index 00000000..a7b7356c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface PermissionOption { + name: string; + kind: string; + optionId: string; +} + +export interface ToolCall { + title?: string; + kind?: string; + toolCallId?: string; + rawInput?: { + command?: string; + description?: string; + [key: string]: unknown; + }; + content?: Array<{ + type: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + status?: string; +} + +export interface PermissionRequestProps { + options: PermissionOption[]; + toolCall: ToolCall; + onResponse: (optionId: string) => void; +} diff --git a/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx new file mode 100644 index 00000000..f5e12b33 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Edit mode related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Edit pencil icon (16x16) + * Used for "Ask before edits" mode + */ +export const EditPencilIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Auto/fast-forward icon (16x16) + * Used for "Edit automatically" mode + */ +export const AutoEditIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Plan mode/bars icon (16x16) + * Used for "Plan mode" + */ +export const PlanModeIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Code brackets icon (20x20) + * Used for active file indicator + */ +export const CodeBracketsIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Hide context (eye slash) icon (20x20) + * Used to indicate the active selection will NOT be auto-loaded into context + */ +export const HideContextIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Slash command icon (20x20) + * Used for command menu button + */ +export const SlashCommandIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Link/attachment icon (20x20) + * Used for attach context button + */ +export const LinkIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Open diff icon (16x16) + * Used for opening diff in VS Code + */ +export const OpenDiffIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx new file mode 100644 index 00000000..38bf27f7 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * File and document related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * File document icon (16x16) + * Used for file completion menu + */ +export const FileIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +export const FileListIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Save document icon (16x16) + * Used for save session button + */ +export const SaveDocumentIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Folder icon (16x16) + * Useful for directory entries in completion lists + */ +export const FolderIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx new file mode 100644 index 00000000..9a4e52fb --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Navigation and action icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Chevron down icon (20x20) + * Used for dropdown arrows + */ +export const ChevronDownIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Plus icon (20x20) + * Used for new session button + */ +export const PlusIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Small plus icon (16x16) + * Used for default attachment type + */ +export const PlusSmallIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Arrow up icon (20x20) + * Used for send message button + */ +export const ArrowUpIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Close X icon (14x14) + * Used for close buttons in banners and dialogs + */ +export const CloseIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +export const CloseSmallIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Search/magnifying glass icon (20x20) + * Used for search input + */ +export const SearchIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Refresh/reload icon (16x16) + * Used for refresh session list + */ +export const RefreshIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx new file mode 100644 index 00000000..48c5db84 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Special UI icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +interface ThinkingIconProps extends IconProps { + /** + * Whether thinking is enabled (affects styling) + */ + enabled?: boolean; +} + +export const ThinkingIcon: React.FC = ({ + size = 16, + className, + enabled = false, + style, + ...props +}) => ( + +); + +export const TerminalIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx new file mode 100644 index 00000000..fdaa2943 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Status and state related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Plan completed icon (14x14) + * Used for completed plan items + */ +export const PlanCompletedIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Plan in progress icon (14x14) + * Used for in-progress plan items + */ +export const PlanInProgressIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Plan pending icon (14x14) + * Used for pending plan items + */ +export const PlanPendingIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Warning triangle icon (20x20) + * Used for warning messages + */ +export const WarningTriangleIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * User profile icon (16x16) + * Used for login command + */ +export const UserIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +export const SymbolIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +export const SelectionIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx b/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx new file mode 100644 index 00000000..40c23250 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Stop icon for canceling operations + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Stop/square icon (16x16) + * Used for stop/cancel operations + */ +export const StopIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/index.ts b/packages/vscode-ide-companion/src/webview/components/icons/index.ts new file mode 100644 index 00000000..ffecbbce --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/index.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type { IconProps } from './types.js'; +export { FileIcon, FileListIcon, FolderIcon } from './FileIcons.js'; + +// Navigation icons +export { + ChevronDownIcon, + PlusIcon, + PlusSmallIcon, + ArrowUpIcon, + CloseIcon, + CloseSmallIcon, + SearchIcon, + RefreshIcon, +} from './NavigationIcons.js'; + +// Edit mode icons +export { + EditPencilIcon, + AutoEditIcon, + PlanModeIcon, + CodeBracketsIcon, + HideContextIcon, + SlashCommandIcon, + LinkIcon, + OpenDiffIcon, +} from './EditIcons.js'; + +// Status icons +export { + PlanCompletedIcon, + PlanInProgressIcon, + PlanPendingIcon, + WarningTriangleIcon, + UserIcon, + SymbolIcon, + SelectionIcon, +} from './StatusIcons.js'; + +// Special icons +export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js'; + +// Stop icon +export { StopIcon } from './StopIcon.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/icons/types.ts b/packages/vscode-ide-companion/src/webview/components/icons/types.ts new file mode 100644 index 00000000..6290d720 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/types.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Common icon props interface + */ + +import type React from 'react'; + +export interface IconProps extends React.SVGProps { + /** + * Icon size (width and height) + * @default 16 + */ + size?: number; + + /** + * Additional CSS classes + */ + className?: string; +} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx new file mode 100644 index 00000000..82cc905f --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { ChevronDownIcon, PlusIcon } from '../icons/index.js'; + +interface ChatHeaderProps { + currentSessionTitle: string; + onLoadSessions: () => void; + onNewSession: () => void; +} + +export const ChatHeader: React.FC = ({ + currentSessionTitle, + onLoadSessions, + onNewSession, +}) => ( +
+ + +
+ + +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx new file mode 100644 index 00000000..f667b849 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import type { CompletionItem } from '../../../types/completionItemTypes.js'; + +interface CompletionMenuProps { + items: CompletionItem[]; + onSelect: (item: CompletionItem) => void; + onClose: () => void; + title?: string; + selectedIndex?: number; +} + +export const CompletionMenu: React.FC = ({ + items, + onSelect, + onClose, + title, + selectedIndex = 0, +}) => { + const containerRef = useRef(null); + const [selected, setSelected] = useState(selectedIndex); + // Mount state to drive a simple Tailwind transition (replaces CSS keyframes) + const [mounted, setMounted] = useState(false); + + useEffect(() => setSelected(selectedIndex), [selectedIndex]); + useEffect(() => setMounted(true), []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + onClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelected((prev) => Math.min(prev + 1, items.length - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setSelected((prev) => Math.max(prev - 1, 0)); + break; + case 'Enter': + event.preventDefault(); + if (items[selected]) { + onSelect(items[selected]); + } + break; + case 'Escape': + event.preventDefault(); + onClose(); + break; + default: + break; + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [items, selected, onSelect, onClose]); + + useEffect(() => { + const selectedEl = containerRef.current?.querySelector( + `[data-index="${selected}"]`, + ); + if (selectedEl) { + selectedEl.scrollIntoView({ block: 'nearest' }); + } + }, [selected]); + + if (!items.length) { + return null; + } + + return ( +
+ {/* Optional top spacer for visual separation from the input */} +
+
+ {title && ( +
+ {title} +
+ )} + {items.map((item, index) => { + const isActive = index === selected; + return ( +
onSelect(item)} + onMouseEnter={() => setSelected(index)} + className={[ + // Semantic + 'completion-menu-item', + // Hit area + 'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]', + 'p-[var(--app-list-item-padding)]', + // Active background + isActive ? 'bg-[var(--app-list-active-background)]' : '', + ].join(' ')} + > +
+ {item.icon && ( + + {item.icon} + + )} + + {item.label} + + {item.description && ( + + {item.description} + + )} +
+
+ ); + })} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx new file mode 100644 index 00000000..1b424e24 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { generateIconUrl } from '../../utils/resourceUrl.js'; + +interface EmptyStateProps { + isAuthenticated?: boolean; + loadingMessage?: string; +} + +export const EmptyState: React.FC = ({ + isAuthenticated = false, + loadingMessage, +}) => { + // Generate icon URL using the utility function + const iconUri = generateIconUrl('icon.png'); + + const description = loadingMessage + ? 'Preparing Qwen Codeโ€ฆ' + : isAuthenticated + ? 'What would you like to do? Ask about this codebase or we can start writing code.' + : 'Welcome! Please log in to start using Qwen Code.'; + + return ( +
+
+ {/* Qwen Logo */} +
+ {iconUri ? ( + Qwen Logo { + // Fallback to a div with text if image fails to load + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + const parent = target.parentElement; + if (parent) { + const fallback = document.createElement('div'); + fallback.className = + 'w-[60px] h-[60px] flex items-center justify-center text-2xl font-bold'; + fallback.textContent = 'Q'; + parent.appendChild(fallback); + } + }} + /> + ) : ( +
+ Q +
+ )} +
+
+ {description} +
+
+
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx b/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx new file mode 100644 index 00000000..356ffaf4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * FileLink component - Clickable file path links + * Supports clicking to open files and jump to specified line and column numbers + */ + +import type React from 'react'; +import { useVSCode } from '../../hooks/useVSCode.js'; +// Tailwind rewrite: styles from FileLink.css are now expressed as utility classes + +/** + * Props for FileLink + */ +interface FileLinkProps { + /** File path */ + path: string; + /** Optional line number (starting from 1) */ + line?: number | null; + /** Optional column number (starting from 1) */ + column?: number | null; + /** Whether to show full path, default false (show filename only) */ + showFullPath?: boolean; + /** Optional custom class name */ + className?: string; + /** Whether to disable click behavior (use when parent element handles clicks) */ + disableClick?: boolean; +} + +/** + * Extract filename from full path + * @param path File path + * @returns Filename + */ +function getFileName(path: string): string { + const segments = path.split(/[/\\]/); + return segments[segments.length - 1] || path; +} + +/** + * FileLink component - Clickable file link + * + * Features: + * - Click to open file + * - Support line and column number navigation + * - Hover to show full path + * - Optional display mode (full path vs filename only) + * + * @example + * ```tsx + * + * + * ``` + */ +export const FileLink: React.FC = ({ + path, + line, + column, + showFullPath = false, + className = '', + disableClick = false, +}) => { + const vscode = useVSCode(); + + /** + * Handle click event - Send message to VSCode to open file + */ + const handleClick = (e: React.MouseEvent) => { + // Always prevent default behavior (prevent tag # navigation) + e.preventDefault(); + + if (disableClick) { + // If click is disabled, return directly without stopping propagation + // This allows parent elements to handle click events + return; + } + + // If click is enabled, stop event propagation + e.stopPropagation(); + + // Build full path including line and column numbers + let fullPath = path; + if (line !== null && line !== undefined) { + fullPath += `:${line}`; + if (column !== null && column !== undefined) { + fullPath += `:${column}`; + } + } + + console.log('[FileLink] Opening file:', fullPath); + + vscode.postMessage({ + type: 'openFile', + data: { path: fullPath }, + }); + }; + + // Build display text + const displayPath = showFullPath ? path : getFileName(path); + + // Build hover tooltip (always show full path) + const fullDisplayText = + line !== null && line !== undefined + ? column !== null && column !== undefined + ? `${path}:${line}:${column}` + : `${path}:${line}` + : path; + + return ( + + {displayPath} + {line !== null && line !== undefined && ( + + :{line} + {column !== null && column !== undefined && <>:{column}} + + )} + + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx new file mode 100644 index 00000000..86ba42be --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -0,0 +1,301 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { + EditPencilIcon, + AutoEditIcon, + PlanModeIcon, + CodeBracketsIcon, + HideContextIcon, + // ThinkingIcon, // Temporarily disabled + SlashCommandIcon, + LinkIcon, + ArrowUpIcon, + StopIcon, +} from '../icons/index.js'; +import { CompletionMenu } from '../layout/CompletionMenu.js'; +import type { CompletionItem } from '../../../types/completionItemTypes.js'; +import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; +import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; + +interface InputFormProps { + inputText: string; + // Note: RefObject carries nullability in its `current` property, so the + // generic should be `HTMLDivElement` (not `HTMLDivElement | null`). + inputFieldRef: React.RefObject; + isStreaming: boolean; + isWaitingForResponse: boolean; + isComposing: boolean; + editMode: ApprovalModeValue; + thinkingEnabled: boolean; + activeFileName: string | null; + activeSelection: { startLine: number; endLine: number } | null; + // Whether to auto-load the active editor selection/path into context + skipAutoActiveContext: boolean; + onInputChange: (text: string) => void; + onCompositionStart: () => void; + onCompositionEnd: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + onToggleEditMode: () => void; + onToggleThinking: () => void; + onFocusActiveEditor: () => void; + onToggleSkipAutoActiveContext: () => void; + onShowCommandMenu: () => void; + onAttachContext: () => void; + completionIsOpen: boolean; + completionItems?: CompletionItem[]; + onCompletionSelect?: (item: CompletionItem) => void; + onCompletionClose?: () => void; +} + +// Get edit mode display info using helper function +const getEditModeInfo = (editMode: ApprovalModeValue) => { + const info = getApprovalModeInfoFromString(editMode); + + // Map icon types to actual icons + let icon = null; + switch (info.iconType) { + case 'edit': + icon = ; + break; + case 'auto': + icon = ; + break; + case 'plan': + icon = ; + break; + case 'yolo': + icon = ; + break; + default: + icon = null; + break; + } + + return { + text: info.label, + title: info.title, + icon, + }; +}; + +export const InputForm: React.FC = ({ + inputText, + inputFieldRef, + isStreaming, + isWaitingForResponse, + isComposing, + editMode, + // thinkingEnabled, // Temporarily disabled + activeFileName, + activeSelection, + skipAutoActiveContext, + onInputChange, + onCompositionStart, + onCompositionEnd, + onKeyDown, + onSubmit, + onCancel, + onToggleEditMode, + // onToggleThinking, // Temporarily disabled + onToggleSkipAutoActiveContext, + onShowCommandMenu, + onAttachContext, + completionIsOpen, + completionItems, + onCompletionSelect, + onCompletionClose, +}) => { + const editModeInfo = getEditModeInfo(editMode); + const composerDisabled = isStreaming || isWaitingForResponse; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // ESC should cancel the current interaction (stop generation) + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + return; + } + // If composing (Chinese IME input), don't process Enter key + if (e.key === 'Enter' && !e.shiftKey && !isComposing) { + // If CompletionMenu is open, let it handle Enter key + if (completionIsOpen) { + return; + } + e.preventDefault(); + onSubmit(e); + } + onKeyDown(e); + }; + + // Selection label like "6 lines selected"; no line numbers + const selectedLinesCount = activeSelection + ? Math.max(1, activeSelection.endLine - activeSelection.startLine + 1) + : 0; + const selectedLinesText = + selectedLinesCount > 0 + ? `${selectedLinesCount} ${selectedLinesCount === 1 ? 'line' : 'lines'} selected` + : ''; + + return ( +
+
+
+ {/* Inner background layer */} +
+ + {/* Banner area */} +
+ +
+ {completionIsOpen && + completionItems && + completionItems.length > 0 && + onCompletionSelect && + onCompletionClose && ( + + )} + +
into contentEditable (so :empty no longer matches) + data-empty={ + inputText.replace(/\u200B/g, '').trim().length === 0 + ? 'true' + : 'false' + } + onInput={(e) => { + const target = e.target as HTMLDivElement; + // Filter out zero-width space that we use to maintain height + const text = target.textContent?.replace(/\u200B/g, '') || ''; + onInputChange(text); + }} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} + onKeyDown={handleKeyDown} + suppressContentEditableWarning + /> +
+ +
+ {/* Edit mode button */} + + + {/* Active file indicator */} + {activeFileName && ( + + )} + + {/* Spacer */} +
+ + {/* @yiliang114. closed temporarily */} + {/* Thinking button */} + {/* */} + + {/* Command button */} + + + {/* Attach button */} + + + {/* Send/Stop button */} + {isStreaming || isWaitingForResponse ? ( + + ) : ( + + )} +
+ +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx new file mode 100644 index 00000000..2eddc4d3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateIconUrl } from '../../utils/resourceUrl.js'; + +interface OnboardingPageProps { + onLogin: () => void; +} + +export const Onboarding: React.FC = ({ onLogin }) => { + const iconUri = generateIconUrl('icon.png'); + + return ( +
+
+
+ {/* Application icon container */} +
+ Qwen Code Logo +
+ +
+

+ Welcome to Qwen Code +

+

+ Unlock the power of AI to understand, navigate, and transform your + codebase faster than ever before. +

+
+ + +
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx new file mode 100644 index 00000000..1b744c1d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + getTimeAgo, + groupSessionsByDate, +} from '../../utils/sessionGrouping.js'; +import { SearchIcon } from '../icons/index.js'; + +interface SessionSelectorProps { + visible: boolean; + sessions: Array>; + currentSessionId: string | null; + searchQuery: string; + onSearchChange: (query: string) => void; + onSelectSession: (sessionId: string) => void; + onClose: () => void; + hasMore?: boolean; + isLoading?: boolean; + onLoadMore?: () => void; +} + +/** + * Session selector component + * Display session list and support search and selection + */ +export const SessionSelector: React.FC = ({ + visible, + sessions, + currentSessionId, + searchQuery, + onSearchChange, + onSelectSession, + onClose, + hasMore = false, + isLoading = false, + onLoadMore, +}) => { + if (!visible) { + return null; + } + + const hasNoSessions = sessions.length === 0; + + return ( + <> +
+
e.stopPropagation()} + > + {/* Search Box */} +
+ + onSearchChange(e.target.value)} + /> +
+ + {/* Session List with Grouping */} +
{ + const el = e.currentTarget; + const distanceToBottom = + el.scrollHeight - (el.scrollTop + el.clientHeight); + if (distanceToBottom < 48 && hasMore && !isLoading) { + onLoadMore?.(); + } + }} + > + {hasNoSessions ? ( +
+ {searchQuery ? 'No matching sessions' : 'No sessions available'} +
+ ) : ( + groupSessionsByDate(sessions).map((group) => ( + +
+ {group.label} +
+
+ {group.sessions.map((session) => { + const sessionId = + (session.id as string) || + (session.sessionId as string) || + ''; + const title = + (session.title as string) || + (session.name as string) || + 'Untitled'; + const lastUpdated = + (session.lastUpdated as string) || + (session.startTime as string) || + ''; + const isActive = sessionId === currentSessionId; + + return ( + + ); + })} +
+
+ )) + )} + {hasMore && ( +
+ {isLoading ? 'Loadingโ€ฆ' : ''} +
+ )} +
+
+ + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css new file mode 100644 index 00000000..56946662 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * AssistantMessage Component Styles + * Pseudo-elements (::before) for bullet points and (::after) for timeline connectors + */ + +/* Bullet point indicator using ::before pseudo-element */ +.assistant-message-container.assistant-message-default::before, +.assistant-message-container.assistant-message-success::before, +.assistant-message-container.assistant-message-error::before, +.assistant-message-container.assistant-message-warning::before, +.assistant-message-container.assistant-message-loading::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + z-index: 1; +} + +/* Default state - secondary foreground color */ +.assistant-message-container.assistant-message-default::before { + color: var(--app-secondary-foreground); +} + +/* Success state - green bullet (maps to .ge) */ +.assistant-message-container.assistant-message-success::before { + color: #74c991; +} + +/* Error state - red bullet (maps to .be) */ +.assistant-message-container.assistant-message-error::before { + color: #c74e39; +} + +/* Warning state - yellow/orange bullet (maps to .ue) */ +.assistant-message-container.assistant-message-warning::before { + color: #e1c08d; +} + +/* Loading state - static bullet (maps to .he) */ +.assistant-message-container.assistant-message-loading::before { + color: var(--app-secondary-foreground); + background-color: var(--app-secondary-background); +} + +.assistant-message-container.assistant-message-loading::after { + display: none +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx new file mode 100644 index 00000000..84712efa --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from '../MessageContent.js'; +import './AssistantMessage.css'; + +interface AssistantMessageProps { + content: string; + timestamp: number; + onFileClick?: (path: string) => void; + status?: 'default' | 'success' | 'error' | 'warning' | 'loading'; + // When true, render without the left status bullet (no ::before dot) + hideStatusIcon?: boolean; +} + +/** + * AssistantMessage component - renders AI responses with Qwen Code styling + * Supports different states: default, success, error, warning, loading + */ +export const AssistantMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, + status = 'default', + hideStatusIcon = false, +}) => { + // Empty content not rendered directly, avoid poor visual experience from only showing ::before dot + if (!content || content.trim().length === 0) { + return null; + } + + // Map status to CSS class (only for ::before pseudo-element) + const getStatusClass = () => { + if (hideStatusIcon) { + return ''; + } + switch (status) { + case 'success': + return 'assistant-message-success'; + case 'error': + return 'assistant-message-error'; + case 'warning': + return 'assistant-message-warning'; + case 'loading': + return 'assistant-message-loading'; + default: + return 'assistant-message-default'; + } + }; + + return ( +
+ +
+ +
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css new file mode 100644 index 00000000..37a3485a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Styles for MarkdownRenderer component + */ + +.markdown-content { + /* Base styles for markdown content */ + line-height: 1.6; + color: var(--app-primary-foreground); +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.markdown-content h1 { + font-size: 1.75em; + border-bottom: 1px solid var(--app-primary-border-color); + padding-bottom: 0.3em; +} + +.markdown-content h2 { + font-size: 1.5em; + border-bottom: 1px solid var(--app-primary-border-color); + padding-bottom: 0.3em; +} + +.markdown-content h3 { + font-size: 1.25em; +} + +.markdown-content h4 { + font-size: 1.1em; +} + +.markdown-content h5, +.markdown-content h6 { + font-size: 1em; +} + +.markdown-content p { + margin-top: 0; + /* margin-bottom: 1em; */ +} + +.markdown-content ul, +.markdown-content ol { + margin-top: 1em; + margin-bottom: 1em; + padding-left: 2em; +} + +/* Ensure list markers are visible even with global CSS resets */ +.markdown-content ul { + list-style-type: disc; + list-style-position: outside; +} + +.markdown-content ol { + list-style-type: decimal; + list-style-position: outside; +} + +/* Nested list styles */ +.markdown-content ul ul { + list-style-type: circle; +} + +.markdown-content ul ul ul { + list-style-type: square; +} + +.markdown-content ol ol { + list-style-type: lower-alpha; +} + +.markdown-content ol ol ol { + list-style-type: lower-roman; +} + +/* Style the marker explicitly so themes don't hide it */ +.markdown-content li::marker { + color: var(--app-secondary-foreground); +} + +.markdown-content li { + margin-bottom: 0.25em; +} + +.markdown-content li > p { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.markdown-content blockquote { + margin: 0 0 1em; + padding: 0 1em; + border-left: 0.25em solid var(--app-primary-border-color); + color: var(--app-secondary-foreground); +} + +.markdown-content a { + color: var(--app-link-foreground, #007acc); + text-decoration: none; +} + +.markdown-content a:hover { + color: var(--app-link-active-foreground, #005a9e); + text-decoration: underline; +} + +.markdown-content code { + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.9em; + background-color: var(--app-code-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--app-primary-border-color); + border-radius: var(--corner-radius-small, 4px); + padding: 0.2em 0.4em; + white-space: pre-wrap; /* Support automatic line wrapping */ + word-break: break-word; /* Break words when necessary */ +} + +.markdown-content pre { + margin: 1em 0; + padding: 1em; + overflow-x: auto; + background-color: var(--app-code-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--app-primary-border-color); + border-radius: var(--corner-radius-small, 4px); + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.9em; + line-height: 1.5; +} + +.markdown-content pre code { + background: none; + border: none; + padding: 0; + white-space: pre-wrap; /* Support automatic line wrapping */ + word-break: break-word; /* Break words when necessary */ +} + +.markdown-content .file-path-link { + background: transparent; + border: none; + padding: 0; + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.95em; + color: var(--app-link-foreground, #007acc); + text-decoration: underline; + cursor: pointer; + transition: color 0.1s ease; +} + +.markdown-content .file-path-link:hover { + color: var(--app-link-active-foreground, #005a9e); +} + +.markdown-content hr { + border: none; + border-top: 1px solid var(--app-primary-border-color); + margin: 1.5em 0; +} + +.markdown-content img { + max-width: 100%; + height: auto; +} + +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; +} + +.markdown-content th, +.markdown-content td { + padding: 0.5em 1em; + border: 1px solid var(--app-primary-border-color); + text-align: left; +} + +.markdown-content th { + background-color: var(--app-secondary-background); + font-weight: 600; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx new file mode 100644 index 00000000..11246420 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx @@ -0,0 +1,392 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths + */ + +import type React from 'react'; +import MarkdownIt from 'markdown-it'; +import type { Options as MarkdownItOptions } from 'markdown-it'; +import './MarkdownRenderer.css'; + +interface MarkdownRendererProps { + content: string; + onFileClick?: (filePath: string) => void; + /** When false, do not convert file paths into clickable links. Default: true */ + enableFileLinks?: boolean; +} + +/** + * Regular expressions for parsing content + */ +// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts +const FILE_PATH_REGEX = + /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi; +// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7 +const FILE_PATH_WITH_LINES_REGEX = + /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi; + +/** + * MarkdownRenderer component - renders markdown content with enhanced features + */ +export const MarkdownRenderer: React.FC = ({ + content, + onFileClick, + enableFileLinks = true, +}) => { + /** + * Initialize markdown-it with plugins + */ + const getMarkdownInstance = (): MarkdownIt => { + // Create markdown-it instance with options + const md = new MarkdownIt({ + html: false, // Disable HTML for security + xhtmlOut: false, + breaks: true, + linkify: true, + typographer: true, + } as MarkdownItOptions); + + return md; + }; + + /** + * Render markdown content to HTML + */ + const renderMarkdown = (): string => { + try { + const md = getMarkdownInstance(); + + // Process the markdown content + let html = md.render(content); + + // Post-process to add file path click handlers unless disabled + if (enableFileLinks) { + html = processFilePaths(html); + } + + return html; + } catch (error) { + console.error('Error rendering markdown:', error); + // Fallback to plain text if markdown rendering fails + return escapeHtml(content); + } + }; + + /** + * Escape HTML characters for security + */ + const escapeHtml = (unsafe: string): string => + unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + /** + * Process file paths in HTML to make them clickable + */ + const processFilePaths = (html: string): string => { + // If DOM is not available, bail out to avoid breaking SSR + if (typeof document === 'undefined') { + return html; + } + + // Build non-global variants to avoid .test() statefulness + const FILE_PATH_NO_G = new RegExp( + FILE_PATH_REGEX.source, + FILE_PATH_REGEX.flags.replace('g', ''), + ); + const FILE_PATH_WITH_LINES_NO_G = new RegExp( + FILE_PATH_WITH_LINES_REGEX.source, + FILE_PATH_WITH_LINES_REGEX.flags.replace('g', ''), + ); + // Match a bare file name like README.md (no leading slash) + const BARE_FILE_REGEX = + /[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)/i; + + // Parse HTML into a DOM tree so we don't replace inside attributes + const container = document.createElement('div'); + container.innerHTML = html; + + const union = new RegExp( + `${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}|${BARE_FILE_REGEX.source}`, + 'gi', + ); + + // Convert a "path#fragment" into VS Code friendly "path:line" (we only keep the start line) + const normalizePathAndLine = ( + raw: string, + ): { displayText: string; dataPath: string } => { + const displayText = raw; + let base = raw; + // Extract hash fragment like #12, #L12 or #12-34 and keep only the first number + const hashIndex = raw.indexOf('#'); + if (hashIndex >= 0) { + const frag = raw.slice(hashIndex + 1); + // Accept L12, 12 or 12-34 + const m = frag.match(/^L?(\d+)(?:-\d+)?$/i); + if (m) { + const line = parseInt(m[1], 10); + base = raw.slice(0, hashIndex); + return { displayText, dataPath: `${base}:${line}` }; + } + } + return { displayText, dataPath: base }; + }; + + const makeLink = (text: string) => { + const link = document.createElement('a'); + // Pass base path (with optional :line) to the handler; keep the full text as label + const { dataPath } = normalizePathAndLine(text); + link.className = 'file-path-link'; + link.textContent = text; + link.setAttribute('href', '#'); + link.setAttribute('title', `Open ${text}`); + // Carry file path via data attribute; click handled by event delegation + link.setAttribute('data-file-path', dataPath); + return link; + }; + + const upgradeAnchorIfFilePath = (a: HTMLAnchorElement) => { + const href = a.getAttribute('href') || ''; + const text = (a.textContent || '').trim(); + + // Helper: identify dot-chained code refs (e.g. vscode.commands.register) + // but DO NOT treat filenames/paths as code refs. + const isCodeReference = (str: string): boolean => { + if (BARE_FILE_REGEX.test(str)) { + return false; // looks like a filename + } + if (/[/\\]/.test(str)) { + return false; // contains a path separator + } + const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; + return codeRefPattern.test(str); + }; + + // If linkify turned a bare filename (e.g. README.md) into http://, convert it back + const httpMatch = href.match(/^https?:\/\/(.+)$/i); + if (httpMatch) { + try { + const url = new URL(href); + const host = url.hostname || ''; + const pathname = url.pathname || ''; + const noPath = pathname === '' || pathname === '/'; + + // Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md) + if ( + noPath && + BARE_FILE_REGEX.test(text) && + host.toLowerCase() === text.toLowerCase() + ) { + const { dataPath } = normalizePathAndLine(text); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text}`); + a.setAttribute('data-file-path', dataPath); + return; + } + + // Case 2: host itself looks like a filename (rare but happens), use it + if (noPath && BARE_FILE_REGEX.test(host)) { + const { dataPath } = normalizePathAndLine(host); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || host}`); + a.setAttribute('data-file-path', dataPath); + return; + } + } catch { + // fall through; unparseable URL + } + } + + // Ignore other external protocols + if (/^(https?|mailto|ftp|data):/i.test(href)) { + return; + } + + const candidate = href || text; + + // Skip if it looks like a code reference + if (isCodeReference(candidate)) { + return; + } + + if ( + FILE_PATH_WITH_LINES_NO_G.test(candidate) || + FILE_PATH_NO_G.test(candidate) + ) { + const { dataPath } = normalizePathAndLine(candidate); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || href}`); + a.setAttribute('data-file-path', dataPath); + return; + } + + // Bare file name or relative path (e.g. README.md or docs/README.md) + if (BARE_FILE_REGEX.test(candidate)) { + const { dataPath } = normalizePathAndLine(candidate); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || href}`); + a.setAttribute('data-file-path', dataPath); + } + }; + + // Helper: identify dot-chained code refs (e.g. vscode.commands.register) + // but DO NOT treat filenames/paths as code refs. + const isCodeReference = (str: string): boolean => { + if (BARE_FILE_REGEX.test(str)) { + return false; // looks like a filename + } + if (/[/\\]/.test(str)) { + return false; // contains a path separator + } + const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; + return codeRefPattern.test(str); + }; + + const walk = (node: Node) => { + // Do not transform inside existing anchors + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + if (el.tagName.toLowerCase() === 'a') { + upgradeAnchorIfFilePath(el as HTMLAnchorElement); + return; // Don't descend into + } + // Avoid transforming inside code/pre blocks + const tag = el.tagName.toLowerCase(); + if (tag === 'code' || tag === 'pre') { + return; + } + } + + for (let child = node.firstChild; child; ) { + const next = child.nextSibling; // child may be replaced + if (child.nodeType === Node.TEXT_NODE) { + const text = child.nodeValue || ''; + union.lastIndex = 0; + const hasMatch = union.test(text); + union.lastIndex = 0; + if (hasMatch) { + const frag = document.createDocumentFragment(); + let lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = union.exec(text))) { + const matchText = m[0]; + const idx = m.index; + + // Skip if it looks like a code reference + if (isCodeReference(matchText)) { + // Just add the text as-is without creating a link + if (idx > lastIndex) { + frag.appendChild( + document.createTextNode(text.slice(lastIndex, idx)), + ); + } + frag.appendChild(document.createTextNode(matchText)); + lastIndex = idx + matchText.length; + continue; + } + + if (idx > lastIndex) { + frag.appendChild( + document.createTextNode(text.slice(lastIndex, idx)), + ); + } + frag.appendChild(makeLink(matchText)); + lastIndex = idx + matchText.length; + } + if (lastIndex < text.length) { + frag.appendChild(document.createTextNode(text.slice(lastIndex))); + } + node.replaceChild(frag, child); + } + } else if (child.nodeType === Node.ELEMENT_NODE) { + walk(child); + } + child = next; + } + }; + + walk(container); + return container.innerHTML; + }; + + // Event delegation: intercept clicks on generated file-path links + const handleContainerClick = ( + e: React.MouseEvent, + ) => { + // If file links disabled, do nothing + if (!enableFileLinks) { + return; + } + const target = e.target as HTMLElement | null; + if (!target) { + return; + } + + // Find nearest anchor with our marker class + const anchor = (target.closest && + target.closest('a.file-path-link')) as HTMLAnchorElement | null; + if (anchor) { + const filePath = anchor.getAttribute('data-file-path'); + if (!filePath) { + return; + } + e.preventDefault(); + e.stopPropagation(); + onFileClick?.(filePath); + return; + } + + // Fallback: intercept "http://README.md" style links that slipped through + const anyAnchor = (target.closest && + target.closest('a')) as HTMLAnchorElement | null; + if (!anyAnchor) { + return; + } + + const href = anyAnchor.getAttribute('href') || ''; + if (!/^https?:\/\//i.test(href)) { + return; + } + try { + const url = new URL(href); + const host = url.hostname || ''; + const path = url.pathname || ''; + const noPath = path === '' || path === '/'; + + // Basic bare filename heuristic on the host part (e.g. README.md) + if (noPath && /\.[a-z0-9]+$/i.test(host)) { + // Prefer the readable text content if it looks like a file + const text = (anyAnchor.textContent || '').trim(); + const candidate = /\.[a-z0-9]+$/i.test(text) ? text : host; + e.preventDefault(); + e.stopPropagation(); + onFileClick?.(candidate); + } + } catch { + // ignore + } + }; + + return ( +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx b/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx new file mode 100644 index 00000000..3381e90d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js'; + +interface MessageContentProps { + content: string; + onFileClick?: (filePath: string) => void; + enableFileLinks?: boolean; +} + +export const MessageContent: React.FC = ({ + content, + onFileClick, + enableFileLinks, +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx new file mode 100644 index 00000000..1f92e1f4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from './MessageContent.js'; + +interface ThinkingMessageProps { + content: string; + timestamp: number; + onFileClick?: (path: string) => void; +} + +export const ThinkingMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, +}) => ( +
+
+ + + + + + +
+
+); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx new file mode 100644 index 00000000..1014736a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from './MessageContent.js'; + +interface FileContext { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; +} + +interface UserMessageProps { + content: string; + timestamp: number; + onFileClick?: (path: string) => void; + fileContext?: FileContext; +} + +export const UserMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, + fileContext, +}) => { + // Generate display text for file context + const getFileContextDisplay = () => { + if (!fileContext) { + return null; + } + const { fileName, startLine, endLine } = fileContext; + if (startLine && endLine) { + return startLine === endLine + ? `${fileName}#${startLine}` + : `${fileName}#${startLine}-${endLine}`; + } + return fileName; + }; + + const fileContextDisplay = getFileContextDisplay(); + + return ( +
+
+ {/* For user messages, do NOT convert filenames to clickable links */} + +
+ + {/* File context indicator */} + {fileContextDisplay && ( +
+
fileContext && onFileClick?.(fileContext.filePath)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + fileContext && onFileClick?.(fileContext.filePath); + } + }} + > +
+ {fileContextDisplay} +
+
+
+ )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx new file mode 100644 index 00000000..0c0e4c8d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +interface InterruptedMessageProps { + text?: string; +} + +// A lightweight status line similar to WaitingMessage but without the left status icon. +export const InterruptedMessage: React.FC = ({ + text = 'Interrupted', +}) => ( +
+
+ {text} +
+
+); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css new file mode 100644 index 00000000..9a109a08 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +@import url('../Assistant/AssistantMessage.css'); + +/* Subtle shimmering highlight across the loading text */ +@keyframes waitingMessageShimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.loading-text-shimmer { + /* Use the theme foreground as the base color, with a moving light band */ + background-image: linear-gradient( + 90deg, + var(--app-secondary-foreground) 0%, + var(--app-secondary-foreground) 40%, + rgba(255, 255, 255, 0.95) 50%, + var(--app-secondary-foreground) 60%, + var(--app-secondary-foreground) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; /* text color comes from the gradient */ + animation: waitingMessageShimmer 1.6s linear infinite; +} + +.interrupted-item::after { + display: none; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx new file mode 100644 index 00000000..68aceac8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import './WaitingMessage.css'; +import { WITTY_LOADING_PHRASES } from '../../../../constants/loadingMessages.js'; + +interface WaitingMessageProps { + loadingMessage: string; +} + +// Rotate message every few seconds while waiting +const ROTATE_INTERVAL_MS = 3000; // rotate every 3s per request + +export const WaitingMessage: React.FC = ({ + loadingMessage, +}) => { + // Build a phrase list that starts with the provided message (if any), then witty fallbacks + const phrases = useMemo(() => { + const set = new Set(); + const list: string[] = []; + if (loadingMessage && loadingMessage.trim()) { + list.push(loadingMessage); + set.add(loadingMessage); + } + for (const p of WITTY_LOADING_PHRASES) { + if (!set.has(p)) { + list.push(p); + } + } + return list; + }, [loadingMessage]); + + const [index, setIndex] = useState(0); + + // Reset to the first phrase whenever the incoming message changes + useEffect(() => { + setIndex(0); + }, [phrases]); + + // Periodically rotate to a different phrase + useEffect(() => { + if (phrases.length <= 1) { + return; + } + const id = setInterval(() => { + setIndex((prev) => { + // pick a different random index to avoid immediate repeats + let next = Math.floor(Math.random() * phrases.length); + if (phrases.length > 1) { + let guard = 0; + while (next === prev && guard < 5) { + next = Math.floor(Math.random() * phrases.length); + guard++; + } + } + return next; + }); + }, ROTATE_INTERVAL_MS); + return () => clearInterval(id); + }, [phrases]); + + return ( +
+ {/* Use the same left status icon (pseudo-element) style as assistant-message-container */} +
+ + {phrases[index]} + +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx new file mode 100644 index 00000000..2ec06e87 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { UserMessage } from './UserMessage.js'; +export { AssistantMessage } from './Assistant/AssistantMessage.js'; +export { ThinkingMessage } from './ThinkingMessage.js'; +export { WaitingMessage } from './Waiting/WaitingMessage.js'; +export { InterruptedMessage } from './Waiting/InterruptedMessage.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css new file mode 100644 index 00000000..bbd8080d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call styles - Enhanced styling with semantic class names + */ + +/* Root container for execute tool call output */ +.bash-toolcall-card { + border: 0.5px solid var(--app-input-border); + border-radius: 5px; + background: var(--app-tool-background); + margin: 8px 0; + max-width: 100%; + font-size: 1em; + align-items: start; +} + +/* Content wrapper inside the card */ +.bash-toolcall-content { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px; +} + +/* Individual input/output row */ +.bash-toolcall-row { + display: grid; + grid-template-columns: max-content 1fr; + border-top: 0.5px solid var(--app-input-border); + padding: 4px; +} + +/* First row has no top border */ +.bash-toolcall-row:first-child { + border-top: none; +} + +/* Row label (IN/OUT/ERROR) */ +.bash-toolcall-label { + grid-column: 1; + color: var(--app-secondary-foreground); + text-align: left; + opacity: 50%; + padding: 4px 8px 4px 4px; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Row content area */ +.bash-toolcall-row-content { + grid-column: 2; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 4px; +} + +/* Truncated content styling */ +.bash-toolcall-row-content:not(.bash-toolcall-full) { + max-height: 60px; + mask-image: linear-gradient( + to bottom, + var(--app-primary-background) 40px, + transparent 60px + ); + overflow: hidden; +} + +/* Preformatted content */ +.bash-toolcall-pre { + margin-block: 0; + overflow: hidden; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Code content */ +.bash-toolcall-code { + margin: 0; + padding: 0; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Output content with subtle styling */ +.bash-toolcall-output-subtle { + background-color: var(--app-code-background); + white-space: pre; + overflow-x: auto; + max-width: 100%; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +/* Error content styling */ +.bash-toolcall-error-content { + color: #c74e39; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx new file mode 100644 index 00000000..acd1fe26 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call component - specialized for command execution operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { ToolCallContainer } from '../shared/LayoutComponents.js'; +import { safeTitle, groupContent } from '../shared/utils.js'; +import { useVSCode } from '../../../../hooks/useVSCode.js'; +import { createAndOpenTempFile } from '../../../../utils/tempFileManager.js'; +import './Bash.css'; + +/** + * Specialized component for Execute/Bash tool calls + * Shows: Bash bullet + description + IN/OUT card + */ +export const ExecuteToolCall: React.FC = ({ toolCall }) => { + const { title, content, rawInput, toolCallId } = toolCall; + const commandText = safeTitle(title); + const vscode = useVSCode(); + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Extract command from rawInput if available + let inputCommand = commandText; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as { command?: string }; + inputCommand = inputObj.command || commandText; + } else if (typeof rawInput === 'string') { + inputCommand = rawInput; + } + + // Handle click on IN section + const handleInClick = () => { + createAndOpenTempFile( + vscode.postMessage, + inputCommand, + 'bash-input', + '.sh', + ); + }; + + // Handle click on OUT section + const handleOutClick = () => { + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt'); + } + }; + + // Map tool status to container status for proper bullet coloring + const containerStatus: + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default' = + errors.length > 0 + ? 'error' + : toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + + // Error case + if (errors.length > 0) { + return ( + + {/* Branch connector summary */} +
+ โŽฟ + {commandText} +
+ {/* Error card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* ERROR row */} +
+
Error
+
+
+                  {errors.join('\n')}
+                
+
+
+
+
+
+ ); + } + + // Success with output + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + const truncatedOutput = + output.length > 500 ? output.substring(0, 500) + '...' : output; + + return ( + + {/* Branch connector summary */} +
+ โŽฟ + {commandText} +
+ {/* Output card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* OUT row */} +
+
OUT
+
+
+
{truncatedOutput}
+
+
+
+
+
+
+ ); + } + + // Success without output: show command with branch connector + return ( + +
+ โŽฟ + {commandText} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx new file mode 100644 index 00000000..a8485316 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Edit tool call component - specialized for file editing operations + */ + +import { useMemo } from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; +import { FileLink } from '../../../layout/FileLink.js'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
{children}
+ )} +
+
+); + +/** + * Calculate diff summary (added/removed lines) + */ +const getDiffSummary = ( + oldText: string | null | undefined, + newText: string | undefined, +): string => { + const oldLines = oldText ? oldText.split('\n').length : 0; + const newLines = newText ? newText.split('\n').length : 0; + const diff = newLines - oldLines; + + if (diff > 0) { + return `+${diff} lines`; + } else if (diff < 0) { + return `${diff} lines`; + } else { + return 'Modified'; + } +}; + +/** + * Specialized component for Edit tool calls + * Optimized for displaying file editing operations with diffs + */ +export const EditToolCall: React.FC = ({ toolCall }) => { + const { content, locations, toolCallId } = toolCall; + + // Group content by type; memoize to avoid new array identities on every render + const { errors, diffs } = useMemo(() => groupContent(content), [content]); + + // Failed case: show explicit failed message and render inline diffs + if (toolCall.status === 'failed') { + const firstDiff = diffs[0]; + const path = firstDiff?.path || locations?.[0]?.path || ''; + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( +
+
+
+
+ + Edit + + {path && ( + + )} +
+
+ {/* Failed state text (replace summary) */} +
+ edit failed +
+
+
+ ); + } + + // Error case: show error + if (errors.length > 0) { + const path = diffs[0]?.path || locations?.[0]?.path || ''; + return ( + + ) : undefined + } + > + {errors.join('\n')} + + ); + } + + // Success case with diff: show minimal inline preview; clicking the title opens VS Code diff + if (diffs.length > 0) { + const firstDiff = diffs[0]; + const path = firstDiff.path || (locations && locations[0]?.path) || ''; + const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText); + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( +
+
+
+
+ {/* Align the inline Edit label styling with shared toolcall label: larger + bold */} + + Edit + + {path && ( + + )} +
+
+
+ โŽฟ + {summary} +
+
+
+ ); + } + + // Success case without diff: show file in compact format + if (locations && locations.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + } + > +
+ โŽฟ + +
+
+ ); + } + + // No output, don't show anything + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css new file mode 100644 index 00000000..97a561c5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call styles - Enhanced styling with semantic class names + */ + +/* Root container for execute tool call output */ +.execute-toolcall-card { + border: 0.5px solid var(--app-input-border); + border-radius: 5px; + background: var(--app-tool-background); + margin: 8px 0; + max-width: 100%; + font-size: 1em; + align-items: start; +} + +/* Content wrapper inside the card */ +.execute-toolcall-content { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px; +} + +/* Individual input/output row */ +.execute-toolcall-row { + display: grid; + grid-template-columns: max-content 1fr; + border-top: 0.5px solid var(--app-input-border); + padding: 4px; +} + +/* First row has no top border */ +.execute-toolcall-row:first-child { + border-top: none; +} + +/* Row label (IN/OUT/ERROR) */ +.execute-toolcall-label { + grid-column: 1; + color: var(--app-secondary-foreground); + text-align: left; + opacity: 50%; + padding: 4px 8px 4px 4px; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Row content area */ +.execute-toolcall-row-content { + grid-column: 2; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 4px; +} + +/* Truncated content styling */ +.execute-toolcall-row-content:not(.execute-toolcall-full) { + max-height: 60px; + mask-image: linear-gradient( + to bottom, + var(--app-primary-background) 40px, + transparent 60px + ); + overflow: hidden; +} + +/* Preformatted content */ +.execute-toolcall-pre { + margin-block: 0; + overflow: hidden; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Code content */ +.execute-toolcall-code { + margin: 0; + padding: 0; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Output content with subtle styling */ +.execute-toolcall-output-subtle { + background-color: var(--app-code-background); + white-space: pre; + overflow-x: auto; + max-width: 100%; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +/* Error content styling */ +.execute-toolcall-error-content { + color: #c74e39; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx new file mode 100644 index 00000000..1a2f2754 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call component - specialized for command execution operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { safeTitle, groupContent } from '../shared/utils.js'; +import './Execute.css'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
{children}
+ )} +
+
+); + +/** + * Specialized component for Execute tool calls + * Shows: Execute bullet + description + IN/OUT card + */ +export const ExecuteToolCall: React.FC = ({ toolCall }) => { + const { title, content, rawInput, toolCallId } = toolCall; + const commandText = safeTitle( + (rawInput as Record)?.description || title, + ); + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Extract command from rawInput if available + let inputCommand = commandText; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as Record; + inputCommand = (inputObj.command as string | undefined) || commandText; + } else if (typeof rawInput === 'string') { + inputCommand = rawInput; + } + + // Map tool status to container status for proper bullet coloring + const containerStatus: + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default' = + errors.length > 0 || toolCall.status === 'failed' + ? 'error' + : toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + + // Error case + if (errors.length > 0) { + return ( + + {/* Branch connector summary */} +
+ โŽฟ + {commandText} +
+ {/* Error card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* ERROR row */} +
+
Error
+
+
+                  {errors.join('\n')}
+                
+
+
+
+
+
+ ); + } + + // Success with output + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + const truncatedOutput = + output.length > 500 ? output.substring(0, 500) + '...' : output; + + return ( + + {/* Branch connector summary */} +
+ โŽฟ + {commandText} +
+ {/* Output card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* OUT row */} +
+
OUT
+
+
+
{truncatedOutput}
+
+
+
+
+
+
+ ); + } + + // Success without output: show command with branch connector + return ( + +
+ โŽฟ + {commandText} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx new file mode 100644 index 00000000..50e88443 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Generic tool call component - handles all tool call types as fallback + */ + +import type React from 'react'; +import type { BaseToolCallProps } from './shared/types.js'; +import { + ToolCallContainer, + ToolCallCard, + ToolCallRow, + LocationsList, +} from './shared/LayoutComponents.js'; +import { safeTitle, groupContent } from './shared/utils.js'; + +/** + * Generic tool call component that can display any tool call type + * Used as fallback for unknown tool call kinds + * Minimal display: show description and outcome + */ +export const GenericToolCall: React.FC = ({ toolCall }) => { + const { kind, title, content, locations, toolCallId } = toolCall; + const operationText = safeTitle(title); + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Error case: show operation + error in card layout + if (errors.length > 0) { + return ( + + +
{operationText}
+
+ +
{errors.join('\n')}
+
+
+ ); + } + + // Success with output: use card for long output, compact for short + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + const isLong = output.length > 150; + + if (isLong) { + const truncatedOutput = + output.length > 300 ? output.substring(0, 300) + '...' : output; + + return ( + + +
{operationText}
+
+ +
+ {truncatedOutput} +
+
+
+ ); + } + + // Short output - compact format + const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = + toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + return ( + + {operationText || output} + + ); + } + + // Success with files: show operation + file list in compact format + if (locations && locations.length > 0) { + const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = + toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + return ( + + + + ); + } + + // No output - show just the operation + if (operationText) { + const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = + toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + return ( + + {operationText} + + ); + } + + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx new file mode 100644 index 00000000..fcd1576c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Read tool call component - specialized for file reading operations + */ + +import type React from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; +import { FileLink } from '../../../layout/FileLink.js'; +import { useVSCode } from '../../../../hooks/useVSCode.js'; +import { handleOpenDiff } from '../../../../utils/diffUtils.js'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +/** + * Specialized component for Read tool calls + * Optimized for displaying file reading operations + * Shows: Read filename (no content preview) + */ +export const ReadToolCall: React.FC = ({ toolCall }) => { + const { content, locations, toolCallId } = toolCall; + const vscode = useVSCode(); + + // Group content by type; memoize to avoid new array identities on every render + const { errors, diffs } = useMemo(() => groupContent(content), [content]); + + // Post a message to the extension host to open a VS Code diff tab + const handleOpenDiffInternal = useCallback( + ( + path: string | undefined, + oldText: string | null | undefined, + newText: string | undefined, + ) => { + handleOpenDiff(vscode, path, oldText, newText); + }, + [vscode], + ); + + // Auto-open diff when a read call returns diff content. + // Only trigger once per toolCallId so we don't spam as in-progress updates stream in. + useEffect(() => { + if (diffs.length > 0) { + const firstDiff = diffs[0]; + const path = firstDiff.path || (locations && locations[0]?.path) || ''; + + if ( + path && + firstDiff.oldText !== undefined && + firstDiff.newText !== undefined + ) { + const timer = setTimeout(() => { + handleOpenDiffInternal(path, firstDiff.oldText, firstDiff.newText); + }, 100); + return () => timer && clearTimeout(timer); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toolCallId]); + + // Compute container status based on toolCall.status (pending/in_progress -> loading) + const containerStatus: + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default' = mapToolStatusToContainerStatus(toolCall.status); + + // Error case: show error + if (errors.length > 0) { + const path = locations?.[0]?.path || ''; + return ( + + ) : undefined + } + > + {errors.join('\n')} + + ); + } + + // Success case with diff: keep UI compact; VS Code diff is auto-opened above + if (diffs.length > 0) { + const path = diffs[0]?.path || locations?.[0]?.path || ''; + return ( + + ) : undefined + } + > + {null} + + ); + } + + // Success case: show which file was read with filename in label + if (locations && locations.length > 0) { + const path = locations[0].path; + return ( + + ) : undefined + } + > + {null} + + ); + } + + // No file info, don't show + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx new file mode 100644 index 00000000..5a57c443 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Search tool call component - specialized for search operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { FileLink } from '../../../layout/FileLink.js'; +import { + safeTitle, + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; + +/** + * Specialized component for Search tool calls + * Optimized for displaying search operations and results + * Shows query + result count or file list + */ +const InlineContainer: React.FC<{ + status: 'success' | 'error' | 'warning' | 'loading' | 'default'; + labelSuffix?: string; + children?: React.ReactNode; + isFirst?: boolean; + isLast?: boolean; +}> = ({ status, labelSuffix, children, isFirst, isLast }) => { + const beforeStatusClass = `toolcall-container toolcall-status-${status}`; + const lineCropTop = isFirst ? 'top-[24px]' : 'top-0'; + const lineCropBottom = isLast + ? 'bottom-auto h-[calc(100%-24px)]' + : 'bottom-0'; + return ( +
+ {/* timeline vertical line */} +
+
+
+ + Search + + {labelSuffix ? ( + + {labelSuffix} + + ) : null} +
+ {children ? ( +
+ {children} +
+ ) : null} +
+
+ ); +}; + +// Local card layout for multi-result or error display +const SearchCard: React.FC<{ + status: 'success' | 'error' | 'warning' | 'loading' | 'default'; + children: React.ReactNode; + isFirst?: boolean; + isLast?: boolean; +}> = ({ status, children, isFirst, isLast }) => { + const beforeStatusClass = + status === 'success' + ? 'before:text-qwen-success' + : status === 'error' + ? 'before:text-qwen-error' + : status === 'warning' + ? 'before:text-qwen-warning' + : 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow'; + const lineCropTop = isFirst ? 'top-[24px]' : 'top-0'; + const lineCropBottom = isLast + ? 'bottom-auto h-[calc(100%-24px)]' + : 'bottom-0'; + return ( +
+ {/* timeline vertical line */} +
+
+
{children}
+
+
+ ); +}; + +const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({ + label, + children, +}) => ( +
+
+ {label} +
+
+ {children} +
+
+); + +const LocationsListLocal: React.FC<{ + locations: Array<{ path: string; line?: number | null }>; +}> = ({ locations }) => ( +
+ {locations.map((loc, idx) => ( + + ))} +
+); + +export const SearchToolCall: React.FC = ({ + toolCall, + isFirst, + isLast, +}) => { + const { title, content, locations } = toolCall; + const queryText = safeTitle(title); + + // Group content by type + const { errors, textOutputs } = groupContent(content); + + // Error case: show search query + error in card layout + if (errors.length > 0) { + return ( + + +
{queryText}
+
+ +
{errors.join('\n')}
+
+
+ ); + } + + // Success case with results: show search query + file list + if (locations && locations.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + // If multiple results, use card layout; otherwise use compact format + if (locations.length > 1) { + return ( + + +
{queryText}
+
+ + + +
+ ); + } + // Single result - compact format + return ( + + โ†’ + + + ); + } + + // Show content text if available (e.g., "Listed 4 item(s).") + if (textOutputs.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + +
+ {textOutputs.map((text, index) => ( +
+ โŽฟ + {text} +
+ ))} +
+
+ ); + } + + // No results - show query only + if (queryText) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + {queryText} + + ); + } + + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx new file mode 100644 index 00000000..4c49b2cc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Think tool call component - specialized for thinking/reasoning operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { + ToolCallContainer, + ToolCallCard, + ToolCallRow, +} from '../shared/LayoutComponents.js'; +import { groupContent } from '../shared/utils.js'; + +/** + * Specialized component for Think tool calls + * Optimized for displaying AI reasoning and thought processes + * Minimal display: just show the thoughts (no context) + */ +export const ThinkToolCall: React.FC = ({ toolCall }) => { + const { content } = toolCall; + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Error case (rare for thinking) + if (errors.length > 0) { + return ( + + {errors.join('\n')} + + ); + } + + // Show thoughts - use card for long content, compact for short + if (textOutputs.length > 0) { + const thoughts = textOutputs.join('\n\n'); + const isLong = thoughts.length > 200; + + if (isLong) { + const truncatedThoughts = + thoughts.length > 500 ? thoughts.substring(0, 500) + '...' : thoughts; + + return ( + + +
+ {truncatedThoughts} +
+
+
+ ); + } + + // Short thoughts - compact format + const status = + toolCall.status === 'pending' || toolCall.status === 'in_progress' + ? 'loading' + : 'default'; + return ( + + {thoughts} + + ); + } + + // Empty thoughts + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx new file mode 100644 index 00000000..6cda54a2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Main ToolCall component - uses factory pattern to route to specialized components + * + * This file serves as the public API for tool call rendering. + * It re-exports the router and types from the toolcalls module. + */ + +import type React from 'react'; +import { ToolCallRouter } from './index.js'; + +// Re-export types from the toolcalls module for backward compatibility +export type { + ToolCallData, + BaseToolCallProps as ToolCallProps, +} from './shared/types.js'; + +// Re-export the content type for external use +export type { ToolCallContent } from './shared/types.js'; +export const ToolCall: React.FC<{ + toolCall: import('./shared/types.js').ToolCallData; + isFirst?: boolean; + isLast?: boolean; +}> = ({ toolCall, isFirst, isLast }) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx new file mode 100644 index 00000000..d17ed073 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +export interface CheckboxDisplayProps { + checked?: boolean; + indeterminate?: boolean; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + title?: string; +} + +/** + * Display-only checkbox styled via Tailwind classes. + * - Renders a custom-looking checkbox using appearance-none and pseudo-elements. + * - Supports indeterminate (middle) state using the DOM property and a data- attribute. + * - Intended for read-only display (disabled by default). + */ +export const CheckboxDisplay: React.FC = ({ + checked = false, + indeterminate = false, + disabled = true, + className = '', + style, + title, +}) => { + // Render as a span (not ) so we can draw a checkmark with CSS. + // Pseudo-elements do not reliably render on in Chromium (VS Code webviews), + // which caused the missing icon. This version is font-free and uses borders. + const showCheck = !!checked && !indeterminate; + const showInProgress = !!indeterminate; + + return ( + + {showCheck ? ( + + ) : null} + {showInProgress ? ( + + * + + ) : null} + + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx new file mode 100644 index 00000000..70e38bd7 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * UpdatedPlan tool call component - specialized for plan update operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; +import { groupContent, safeTitle } from '../shared/utils.js'; +import { CheckboxDisplay } from './CheckboxDisplay.js'; +import type { PlanEntry } from '../../../../../types/chatTypes.js'; + +type EntryStatus = 'pending' | 'in_progress' | 'completed'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +const mapToolStatusToBullet = ( + status: import('../shared/types.js').ToolCallStatus, +): 'success' | 'error' | 'warning' | 'loading' | 'default' => { + switch (status) { + case 'completed': + return 'success'; + case 'failed': + return 'error'; + case 'in_progress': + return 'warning'; + case 'pending': + return 'loading'; + default: + return 'default'; + } +}; + +// Parse plan entries with - [ ] / - [x] from text as much as possible +const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => { + const text = textOutputs.join('\n'); + const lines = text.split(/\r?\n/); + const entries: PlanEntry[] = []; + + // Accept [ ], [x]/[X] and in-progress markers [-] or [*] + const todoRe = /^(?:\s*(?:[-*]|\d+[.)])\s*)?\[( |x|X|-|\*)\]\s+(.*)$/; + for (const line of lines) { + const m = line.match(todoRe); + if (m) { + const mark = m[1]; + const title = m[2].trim(); + const status: EntryStatus = + mark === 'x' || mark === 'X' + ? 'completed' + : mark === '-' || mark === '*' + ? 'in_progress' + : 'pending'; + if (title) { + entries.push({ content: title, status }); + } + } + } + + // If no match is found, fall back to treating non-empty lines as pending items + if (entries.length === 0) { + for (const line of lines) { + const title = line.trim(); + if (title) { + entries.push({ content: title, status: 'pending' }); + } + } + } + + return entries; +}; + +/** + * Specialized component for UpdatedPlan tool calls + * Optimized for displaying plan update operations + */ +export const UpdatedPlanToolCall: React.FC = ({ + toolCall, +}) => { + const { content, status } = toolCall; + const { errors, textOutputs } = groupContent(content); + + // Error-first display + if (errors.length > 0) { + return ( + + {errors.join('\n')} + + ); + } + + const entries = parsePlanEntries(textOutputs); + + const label = safeTitle(toolCall.title) || 'Updated Plan'; + + return ( + +
    + {entries.map((entry, idx) => { + const isDone = entry.status === 'completed'; + const isIndeterminate = entry.status === 'in_progress'; + return ( +
  • + + +
    + {entry.content} +
    +
  • + ); + })} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx new file mode 100644 index 00000000..d0e6307b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Write tool call component - specialized for file writing operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { ToolCallContainer } from '../shared/LayoutComponents.js'; +import { + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; +import { FileLink } from '../../../layout/FileLink.js'; + +/** + * Specialized component for Write tool calls + * Shows: Write filename + error message + content preview + */ +export const WriteToolCall: React.FC = ({ toolCall }) => { + const { content, locations, rawInput, toolCallId } = toolCall; + + // Group content by type + const { errors, textOutputs } = groupContent(content); + + // Extract filename from path + // const getFileName = (path: string): string => path.split('/').pop() || path; + + // Extract content to write from rawInput + let writeContent = ''; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as { content?: string }; + writeContent = inputObj.content || ''; + } else if (typeof rawInput === 'string') { + writeContent = rawInput; + } + + // Error case: show filename + error message + content preview + if (errors.length > 0) { + const path = locations?.[0]?.path || ''; + const errorMessage = errors.join('\n'); + + // Truncate content preview + const truncatedContent = + writeContent.length > 200 + ? writeContent.substring(0, 200) + '...' + : writeContent; + + return ( + + ) : undefined + } + > +
+ โŽฟ + {errorMessage} +
+ {truncatedContent && ( +
+
+              {truncatedContent}
+            
+
+ )} +
+ ); + } + + // Success case: show filename + line count + if (locations && locations.length > 0) { + const path = locations[0].path; + const lineCount = writeContent.split('\n').length; + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + ) : undefined + } + > +
+ โŽฟ + {lineCount} lines +
+
+ ); + } + + // Fallback: show generic success + if (textOutputs.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + {textOutputs.join('\n')} + + ); + } + + // No output, don't show anything + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx new file mode 100644 index 00000000..3f7a2bc0 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Tool call component factory - routes to specialized components by kind + */ + +import type React from 'react'; +import type { BaseToolCallProps } from './shared/types.js'; +import { shouldShowToolCall } from './shared/utils.js'; +import { GenericToolCall } from './GenericToolCall.js'; +import { ReadToolCall } from './Read/ReadToolCall.js'; +import { WriteToolCall } from './Write/WriteToolCall.js'; +import { EditToolCall } from './Edit/EditToolCall.js'; +import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js'; +import { ExecuteToolCall } from './Execute/Execute.js'; +import { UpdatedPlanToolCall } from './UpdatedPlan/UpdatedPlanToolCall.js'; +import { SearchToolCall } from './Search/SearchToolCall.js'; +import { ThinkToolCall } from './Think/ThinkToolCall.js'; + +/** + * Factory function that returns the appropriate tool call component based on kind + */ +export const getToolCallComponent = ( + kind: string, +): React.FC => { + const normalizedKind = kind.toLowerCase(); + + // Route to specialized components + switch (normalizedKind) { + case 'read': + return ReadToolCall; + + case 'write': + return WriteToolCall; + + case 'edit': + return EditToolCall; + + case 'execute': + return ExecuteToolCall; + + case 'bash': + case 'command': + return BashExecuteToolCall; + + case 'updated_plan': + case 'updatedplan': + case 'todo_write': + case 'update_todos': + case 'todowrite': + return UpdatedPlanToolCall; + + case 'search': + case 'grep': + case 'glob': + case 'find': + return SearchToolCall; + + case 'think': + case 'thinking': + return ThinkToolCall; + + default: + // Fallback to generic component + return GenericToolCall; + } +}; + +/** + * Main tool call component that routes to specialized implementations + */ +export const ToolCallRouter: React.FC< + BaseToolCallProps & { isFirst?: boolean; isLast?: boolean } +> = ({ toolCall, isFirst, isLast }) => { + // Check if we should show this tool call (hide internal ones) + if (!shouldShowToolCall(toolCall.kind)) { + return null; + } + + // Get the appropriate component for this kind + const Component = getToolCallComponent(toolCall.kind); + + // Render the specialized component + return ; +}; + +// Re-export types for convenience +export type { BaseToolCallProps, ToolCallData } from './shared/types.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css new file mode 100644 index 00000000..39846d77 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * LayoutComponents.css - Tool call layout styles with timeline support + */ + +/* ToolCallContainer with timeline support */ +.toolcall-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; + user-select: text; + align-items: flex-start; +} + +/* Default timeline connector line */ +.toolcall-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.toolcall-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.toolcall-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* Status-specific styles using ::before pseudo-element for bullet points */ +.toolcall-container.toolcall-status-default::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: var(--app-secondary-foreground); + z-index: 1; +} + +.toolcall-container.toolcall-status-success::before { + content: '\25cf'; + position: absolute; + left: 8px; + font-size: 10px; + color: #74c991; + z-index: 1; +} + +.toolcall-container.toolcall-status-error::before { + content: '\25cf'; + position: absolute; + left: 8px; + font-size: 10px; + color: #c74e39; + z-index: 1; +} + +.toolcall-container.toolcall-status-warning::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: #e1c08d; + z-index: 1; +} + +.toolcall-container.toolcall-status-loading::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: var(--app-secondary-foreground); + background-color: var(--app-secondary-background); + animation: toolcallPulse 1s linear infinite; + z-index: 1; +} + +/* Loading animation */ +@keyframes toolcallPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Content wrapper */ +.toolcall-content-wrapper { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +/* Legacy card styles */ +.toolcall-card { + grid-template-columns: auto 1fr; + gap: var(--spacing-medium); + background: var(--app-input-background); + border: 1px solid var(--app-input-border); + border-radius: var(--border-radius-medium); + padding: var(--spacing-large); + margin: var(--spacing-medium) 0; + align-items: start; + animation: fadeIn 0.2s ease-in; +} + +/* Legacy row styles */ +.toolcall-row { + grid-template-columns: 80px 1fr; + gap: var(--spacing-medium); + min-width: 0; +} + +.toolcall-row-label { + font-size: var(--font-size-xs); + color: var(--app-secondary-foreground); + font-weight: 500; + padding-top: 2px; +} + +.toolcall-row-content { + color: var(--app-primary-foreground); + min-width: 0; + word-break: break-word; +} + +/* Locations list */ +.toolcall-locations-list { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 100%; +} + +/* ToolCall header with loading indicator */ +.toolcall-header { + position: relative; +} + +.toolcall-header::before { + content: '\25cf'; + position: absolute; + left: -22px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + line-height: 1; + z-index: 1; + color: #e1c08d; + animation: toolcallHeaderPulse 1.5s ease-in-out infinite; +} + +/* Loading animation for toolcall header */ +@keyframes toolcallHeaderPulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* In-progress toolcall specific styles */ +.in-progress-toolcall .toolcall-content-wrapper { + display: flex; + flex-direction: column; + gap: 1; + min-width: 0; + max-width: 100%; +} + +.in-progress-toolcall .toolcall-header { + display: flex; + align-items: center; + gap: 2; + position: relative; + min-width: 0; +} + +.in-progress-toolcall .toolcall-content-text { + word-break: break-word; + white-space: pre-wrap; + width: 100%; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx new file mode 100644 index 00000000..89a0b14c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared layout components for tool call UI + */ + +import type React from 'react'; +import { FileLink } from '../../../layout/FileLink.js'; +import './LayoutComponents.css'; + +/** + * Props for ToolCallContainer + */ +export interface ToolCallContainerProps { + /** Operation label (e.g., "Read", "Write", "Search") */ + label: string; + /** Status for bullet color: 'success' | 'error' | 'warning' | 'loading' | 'default' */ + status?: 'success' | 'error' | 'warning' | 'loading' | 'default'; + /** Main content to display */ + children: React.ReactNode; + /** Tool call ID for debugging */ + toolCallId?: string; + /** Optional trailing content rendered next to label (e.g., clickable filename) */ + labelSuffix?: React.ReactNode; + /** Optional custom class name */ + className?: string; +} + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +interface ToolCallCardProps { + icon: string; + children: React.ReactNode; +} + +/** + * Legacy card wrapper - kept for backward compatibility with complex layouts like diffs + */ +export const ToolCallCard: React.FC = ({ + icon: _icon, + children, +}) => ( +
+
{children}
+
+); + +interface ToolCallRowProps { + label: string; + children: React.ReactNode; +} + +/** + * A single row in the tool call grid (legacy - for complex layouts) + */ +export const ToolCallRow: React.FC = ({ + label, + children, +}) => ( +
+
+ {label} +
+
+ {children} +
+
+); + +/** + * Props for StatusIndicator + */ +interface StatusIndicatorProps { + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + text: string; +} + +/** + * Get status color class + */ +const getStatusColorClass = ( + status: 'pending' | 'in_progress' | 'completed' | 'failed', +): string => { + switch (status) { + case 'pending': + return 'bg-[#ffc107]'; + case 'in_progress': + return 'bg-[#2196f3]'; + case 'completed': + return 'bg-[#4caf50]'; + case 'failed': + return 'bg-[#f44336]'; + default: + return 'bg-gray-500'; + } +}; + +/** + * Status indicator with colored dot + */ +export const StatusIndicator: React.FC = ({ + status, + text, +}) => ( +
+ + {text} +
+); + +interface CodeBlockProps { + children: string; +} + +/** + * Code block for displaying formatted code or output + */ +export const CodeBlock: React.FC = ({ children }) => ( +
+    {children}
+  
+); + +/** + * Props for LocationsList + */ +interface LocationsListProps { + locations: Array<{ + path: string; + line?: number | null; + }>; +} + +/** + * List of file locations with clickable links + */ +export const LocationsList: React.FC = ({ locations }) => ( +
+ {locations.map((loc, idx) => ( + + ))} +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts new file mode 100644 index 00000000..0fccb186 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared types for tool call components + */ + +/** + * Tool call content types + */ +export interface ToolCallContent { + type: 'content' | 'diff'; + // For content type + content?: { + type: string; + text?: string; + error?: unknown; + [key: string]: unknown; + }; + // For diff type + path?: string; + oldText?: string | null; + newText?: string; +} + +/** + * Tool call location type + */ +export interface ToolCallLocation { + path: string; + line?: number | null; +} + +/** + * Tool call status type + */ +export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +/** + * Base tool call data interface + */ +export interface ToolCallData { + toolCallId: string; + kind: string; + title: string | object; + status: ToolCallStatus; + rawInput?: string | object; + content?: ToolCallContent[]; + locations?: ToolCallLocation[]; + timestamp?: number; // Add a timestamp field for message sorting +} + +/** + * Base props for all tool call components + */ +export interface BaseToolCallProps { + toolCall: ToolCallData; + // Optional timeline flags for rendering connector line cropping + isFirst?: boolean; + isLast?: boolean; +} + +/** + * Grouped content structure for rendering + */ +export interface GroupedContent { + textOutputs: string[]; + errors: string[]; + diffs: ToolCallContent[]; + otherData: unknown[]; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts new file mode 100644 index 00000000..ceb2cb2b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared utility functions for tool call components + */ + +import type { + ToolCallContent, + GroupedContent, + ToolCallStatus, +} from './types.js'; + +/** + * Format any value to a string for display + */ +export const formatValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + // TODO: Trying to take out the Output part from the string + try { + value = (JSON.parse(value) as { output?: unknown }).output ?? value; + } catch (_error) { + // ignore JSON parse errors + } + return value as string; + } + // Handle Error objects specially + if (value instanceof Error) { + return value.message || value.toString(); + } + // Handle error-like objects with message property + if (typeof value === 'object' && value !== null && 'message' in value) { + const errorObj = value as { message?: string; stack?: string }; + return errorObj.message || String(value); + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (_e) { + return String(value); + } + } + return String(value); +}; + +/** + * Safely convert title to string, handling object types + * Returns empty string if no meaningful title + */ +export const safeTitle = (title: unknown): string => { + if (typeof title === 'string' && title.trim()) { + return title; + } + if (title && typeof title === 'object') { + return JSON.stringify(title); + } + return ''; +}; + +/** + * Check if a tool call should be displayed + * Hides internal tool calls + */ +export const shouldShowToolCall = (kind: string): boolean => + !kind.includes('internal'); + +/** + * Check if a tool call has actual output to display + * Returns false for tool calls that completed successfully but have no visible output + */ +export const hasToolCallOutput = ( + toolCall: import('./types.js').ToolCallData, +): boolean => { + // Always show failed tool calls (even without content) + if (toolCall.status === 'failed') { + return true; + } + + // Always show execute/bash/command tool calls (they show the command in title) + const kind = toolCall.kind.toLowerCase(); + if (kind === 'execute' || kind === 'bash' || kind === 'command') { + // But only if they have a title + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + } + + // Show if there are locations (file paths) + if (toolCall.locations && toolCall.locations.length > 0) { + return true; + } + + // Show if there is content + if (toolCall.content && toolCall.content.length > 0) { + const grouped = groupContent(toolCall.content); + // Has any meaningful content? + if ( + grouped.textOutputs.length > 0 || + grouped.errors.length > 0 || + grouped.diffs.length > 0 || + grouped.otherData.length > 0 + ) { + return true; + } + } + + // Show if there's a meaningful title for generic tool calls + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + + // No output, don't show + return false; +}; + +/** + * Group tool call content by type to avoid duplicate labels + */ +export const groupContent = (content?: ToolCallContent[]): GroupedContent => { + const textOutputs: string[] = []; + const errors: string[] = []; + const diffs: ToolCallContent[] = []; + const otherData: unknown[] = []; + + content?.forEach((item) => { + if (item.type === 'diff') { + diffs.push(item); + } else if (item.content) { + const contentObj = item.content; + + // Handle error content + if (contentObj.type === 'error' || 'error' in contentObj) { + // Try to extract meaningful error message + let errorMsg = ''; + + // Check if error is a string + if (typeof contentObj.error === 'string') { + errorMsg = contentObj.error; + } + // Check if error has a message property + else if ( + contentObj.error && + typeof contentObj.error === 'object' && + 'message' in contentObj.error + ) { + errorMsg = (contentObj.error as { message: string }).message; + } + // Try text field + else if (contentObj.text) { + errorMsg = formatValue(contentObj.text); + } + // Format the error object itself + else if (contentObj.error) { + errorMsg = formatValue(contentObj.error); + } + // Fallback + else { + errorMsg = 'An error occurred'; + } + + errors.push(errorMsg); + } + // Handle text content + else if (contentObj.text) { + textOutputs.push(formatValue(contentObj.text)); + } + // Handle other content + else { + otherData.push(contentObj); + } + } + }); + + return { textOutputs, errors, diffs, otherData }; +}; + +/** + * Map a tool call status to a ToolCallContainer status (bullet color) + * - pending/in_progress -> loading + * - completed -> success + * - failed -> error + * - default fallback + */ +export const mapToolStatusToContainerStatus = ( + status: ToolCallStatus, +): 'success' | 'error' | 'warning' | 'loading' | 'default' => { + switch (status) { + case 'pending': + case 'in_progress': + return 'loading'; + case 'failed': + return 'error'; + case 'completed': + return 'success'; + default: + return 'default'; + } +}; diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts new file mode 100644 index 00000000..ab4b70b2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; + +/** + * Auth message handler + * Handles all authentication-related messages + */ +export class AuthMessageHandler extends BaseMessageHandler { + private loginHandler: (() => Promise) | null = null; + + canHandle(messageType: string): boolean { + return ['login'].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + switch (message.type) { + case 'login': + await this.handleLogin(); + break; + + default: + console.warn( + '[AuthMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.loginHandler = handler; + } + + /** + * Handle login request + */ + private async handleLogin(): Promise { + try { + console.log('[AuthMessageHandler] Login requested'); + console.log( + '[AuthMessageHandler] Login handler available:', + !!this.loginHandler, + ); + + // Direct login without additional confirmation + if (this.loginHandler) { + console.log('[AuthMessageHandler] Calling login handler'); + await this.loginHandler(); + console.log( + '[AuthMessageHandler] Login handler completed successfully', + ); + } else { + console.log('[AuthMessageHandler] Using fallback login method'); + // Fallback: show message and use command + vscode.window.showInformationMessage( + 'Please wait while we connect to Qwen Code...', + ); + await vscode.commands.executeCommand('qwen-code.login'); + } + } catch (error) { + console.error('[AuthMessageHandler] Login failed:', error); + console.error( + '[AuthMessageHandler] Error stack:', + error instanceof Error ? error.stack : 'N/A', + ); + this.sendToWebView({ + type: 'loginError', + data: { + message: `Login failed: ${error instanceof Error ? error.message : String(error)}`, + }, + }); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts new file mode 100644 index 00000000..4d01fd02 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; + +/** + * Base message handler interface + * All sub-handlers should implement this interface + */ +export interface IMessageHandler { + /** + * Handle message + * @param message - Message object + * @returns Promise + */ + handle(message: { type: string; data?: unknown }): Promise; + + /** + * Check if this handler can handle the message type + * @param messageType - Message type + * @returns boolean + */ + canHandle(messageType: string): boolean; +} + +/** + * Base message handler class + * Provides common dependency injection and helper methods + */ +export abstract class BaseMessageHandler implements IMessageHandler { + constructor( + protected agentManager: QwenAgentManager, + protected conversationStore: ConversationStore, + protected currentConversationId: string | null, + protected sendToWebView: (message: unknown) => void, + ) {} + + abstract handle(message: { type: string; data?: unknown }): Promise; + abstract canHandle(messageType: string): boolean; + + /** + * Update current conversation ID + */ + setCurrentConversationId(id: string | null): void { + this.currentConversationId = id; + } + + /** + * Get current conversation ID + */ + getCurrentConversationId(): string | null { + return this.currentConversationId; + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts new file mode 100644 index 00000000..7d82315d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import { getFileName } from '../utils/webviewUtils.js'; + +/** + * Editor message handler + * Handles all editor state-related messages + */ +export class EditorMessageHandler extends BaseMessageHandler { + canHandle(messageType: string): boolean { + return ['getActiveEditor', 'focusActiveEditor'].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + switch (message.type) { + case 'getActiveEditor': + await this.handleGetActiveEditor(); + break; + + case 'focusActiveEditor': + await this.handleFocusActiveEditor(); + break; + + default: + console.warn( + '[EditorMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Get current active editor info + */ + private async handleGetActiveEditor(): Promise { + try { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor) { + const filePath = activeEditor.document.uri.fsPath; + const fileName = getFileName(filePath); + + let selectionInfo = null; + if (!activeEditor.selection.isEmpty) { + const selection = activeEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } else { + this.sendToWebView({ + type: 'activeEditorChanged', + data: { fileName: null, filePath: null, selection: null }, + }); + } + } catch (error) { + console.error( + '[EditorMessageHandler] Failed to get active editor:', + error, + ); + } + } + + /** + * Focus on active editor + */ + private async handleFocusActiveEditor(): Promise { + try { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor) { + await vscode.window.showTextDocument(activeEditor.document, { + viewColumn: activeEditor.viewColumn, + preserveFocus: false, + }); + } else { + // If no active editor, show file picker + const uri = await vscode.window.showOpenDialog({ + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Open', + }); + + if (uri && uri.length > 0) { + await vscode.window.showTextDocument(uri[0]); + } + } + } catch (error) { + console.error( + '[EditorMessageHandler] Failed to focus active editor:', + error, + ); + vscode.window.showErrorMessage(`Failed to focus editor: ${error}`); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts new file mode 100644 index 00000000..28ecbbd3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -0,0 +1,445 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import { getFileName } from '../utils/webviewUtils.js'; +import { showDiffCommand } from '../../commands/index.js'; + +/** + * File message handler + * Handles all file-related messages + */ +export class FileMessageHandler extends BaseMessageHandler { + canHandle(messageType: string): boolean { + return [ + 'attachFile', + 'showContextPicker', + 'getWorkspaceFiles', + 'openFile', + 'openDiff', + 'createAndOpenTempFile', + ].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + const data = message.data as Record | undefined; + + switch (message.type) { + case 'attachFile': + await this.handleAttachFile(); + break; + + case 'showContextPicker': + await this.handleShowContextPicker(); + break; + + case 'getWorkspaceFiles': + await this.handleGetWorkspaceFiles(data?.query as string | undefined); + break; + + case 'openFile': + await this.handleOpenFile(data?.path as string | undefined); + break; + + case 'openDiff': + await this.handleOpenDiff(data); + break; + + case 'createAndOpenTempFile': + await this.handleCreateAndOpenTempFile(data); + break; + + default: + console.warn( + '[FileMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Handle attach file request + */ + private async handleAttachFile(): Promise { + try { + const uris = await vscode.window.showOpenDialog({ + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uris && uris.length > 0) { + const uri = uris[0]; + const fileName = getFileName(uri.fsPath); + + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri.fsPath, + }, + }); + } + } catch (error) { + console.error('[FileMessageHandler] Failed to attach file:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to attach file: ${error}` }, + }); + } + } + + /** + * Handle show context picker request + */ + private async handleShowContextPicker(): Promise { + try { + const items: vscode.QuickPickItem[] = []; + + // Add current file + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + items.push({ + label: `$(file) ${fileName}`, + description: 'Current file', + detail: activeEditor.document.uri.fsPath, + }); + } + + // Add file picker option + items.push({ + label: '$(file) File...', + description: 'Choose a file to attach', + }); + + // Add workspace files option + items.push({ + label: '$(search) Search files...', + description: 'Search workspace files', + }); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Attach context', + matchOnDescription: true, + matchOnDetail: true, + }); + + if (selected) { + if (selected.label.includes('Current file') && activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: activeEditor.document.uri.fsPath, + }, + }); + } else if (selected.label.includes('File...')) { + await this.handleAttachFile(); + } else if (selected.label.includes('Search files')) { + const uri = await vscode.window.showOpenDialog({ + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uri && uri.length > 0) { + const fileName = getFileName(uri[0].fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri[0].fsPath, + }, + }); + } + } + } + } catch (error) { + console.error( + '[FileMessageHandler] Failed to show context picker:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to show context picker: ${error}` }, + }); + } + } + + /** + * Get workspace files + */ + private async handleGetWorkspaceFiles(query?: string): Promise { + try { + console.log('[FileMessageHandler] handleGetWorkspaceFiles start', { + query, + }); + const files: Array<{ + id: string; + label: string; + description: string; + path: string; + }> = []; + const addedPaths = new Set(); + + const addFile = (uri: vscode.Uri, isCurrentFile = false) => { + if (addedPaths.has(uri.fsPath)) { + return; + } + + const fileName = getFileName(uri.fsPath); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + const relativePath = workspaceFolder + ? vscode.workspace.asRelativePath(uri, false) + : uri.fsPath; + + // Filter by query if provided + if ( + query && + !fileName.toLowerCase().includes(query.toLowerCase()) && + !relativePath.toLowerCase().includes(query.toLowerCase()) + ) { + return; + } + + files.push({ + id: isCurrentFile ? 'current-file' : uri.fsPath, + label: fileName, + description: relativePath, + path: uri.fsPath, + }); + addedPaths.add(uri.fsPath); + }; + + // Search or show recent files + if (query) { + // Query mode: perform filesystem search (may take longer on large workspaces) + console.log( + '[FileMessageHandler] Searching workspace files for query', + query, + ); + const uris = await vscode.workspace.findFiles( + `**/*${query}*`, + '**/node_modules/**', + 50, + ); + + for (const uri of uris) { + addFile(uri); + } + } else { + // Non-query mode: respond quickly with currently active and open files + // Add current active file first + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + addFile(activeEditor.document.uri, true); + } + + // Add all open tabs + const tabGroups = vscode.window.tabGroups.all; + for (const tabGroup of tabGroups) { + for (const tab of tabGroup.tabs) { + const input = tab.input as { uri?: vscode.Uri } | undefined; + if (input && input.uri instanceof vscode.Uri) { + addFile(input.uri); + } + } + } + + // Send an initial quick response so UI can render immediately + try { + this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + console.log( + '[FileMessageHandler] Sent initial workspaceFiles (open tabs/active)', + files.length, + ); + } catch (e) { + console.warn( + '[FileMessageHandler] Failed sending initial response', + e, + ); + } + + // If not enough files, add some workspace files (bounded) + if (files.length < 10) { + const recentUris = await vscode.workspace.findFiles( + '**/*', + '**/node_modules/**', + 20, + ); + + for (const uri of recentUris) { + if (files.length >= 20) { + break; + } + addFile(uri); + } + } + } + + this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + console.log( + '[FileMessageHandler] Sent final workspaceFiles', + files.length, + ); + } catch (error) { + console.error( + '[FileMessageHandler] Failed to get workspace files:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to get workspace files: ${error}` }, + }); + } + } + + /** + * Open file + */ + private async handleOpenFile(filePath?: string): Promise { + if (!filePath) { + console.warn('[FileMessageHandler] No path provided for openFile'); + return; + } + + try { + console.log('[FileOperations] Opening file:', filePath); + + // Parse file path, line number, and column number + // Formats: path/to/file.ts, path/to/file.ts:123, path/to/file.ts:123:45 + const match = filePath.match(/^(.+?)(?::(\d+))?(?::(\d+))?$/); + if (!match) { + console.warn('[FileOperations] Invalid file path format:', filePath); + return; + } + + const [, path, lineStr, columnStr] = match; + const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line numbers + const columnNumber = columnStr ? parseInt(columnStr, 10) - 1 : 0; // VS Code uses 0-based column numbers + + // Convert to absolute path if relative + let absolutePath = path; + if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) { + // Relative path - resolve against workspace + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath; + } + } + + // Open the document + const uri = vscode.Uri.file(absolutePath); + const document = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }); + + // Navigate to line and column if specified + if (lineStr) { + const position = new vscode.Position(lineNumber, columnNumber); + editor.selection = new vscode.Selection(position, position); + editor.revealRange( + new vscode.Range(position, position), + vscode.TextEditorRevealType.InCenter, + ); + } + + console.log('[FileOperations] File opened successfully:', absolutePath); + } catch (error) { + console.error('[FileMessageHandler] Failed to open file:', error); + vscode.window.showErrorMessage(`Failed to open file: ${error}`); + } + } + + /** + * Open diff view + */ + private async handleOpenDiff( + data: Record | undefined, + ): Promise { + if (!data) { + console.warn('[FileMessageHandler] No data provided for openDiff'); + return; + } + + try { + await vscode.commands.executeCommand(showDiffCommand, { + path: (data.path as string) || '', + oldText: (data.oldText as string) || '', + newText: (data.newText as string) || '', + }); + } catch (error) { + console.error('[FileMessageHandler] Failed to open diff:', error); + vscode.window.showErrorMessage(`Failed to open diff: ${error}`); + } + } + + /** + * Create and open temporary file + */ + private async handleCreateAndOpenTempFile( + data: Record | undefined, + ): Promise { + if (!data) { + console.warn( + '[FileMessageHandler] No data provided for createAndOpenTempFile', + ); + return; + } + + try { + const content = (data.content as string) || ''; + const fileName = (data.fileName as string) || 'temp'; + const fileExtension = (data.fileExtension as string) || '.txt'; + + // Create temporary file path + const tempDir = os.tmpdir(); + const tempFileName = `${fileName}-${Date.now()}${fileExtension}`; + const tempFilePath = path.join(tempDir, tempFileName); + + // Write content to temporary file + await fs.promises.writeFile(tempFilePath, content, 'utf8'); + + // Open the temporary file in VS Code + const uri = vscode.Uri.file(tempFilePath); + await vscode.window.showTextDocument(uri, { + preview: false, + preserveFocus: false, + }); + + console.log( + '[FileMessageHandler] Created and opened temporary file:', + tempFilePath, + ); + } catch (error) { + console.error( + '[FileMessageHandler] Failed to create and open temporary file:', + error, + ); + vscode.window.showErrorMessage( + `Failed to create and open temporary file: ${error}`, + ); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts new file mode 100644 index 00000000..353dbaaf --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IMessageHandler } from './BaseMessageHandler.js'; +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; +import { SessionMessageHandler } from './SessionMessageHandler.js'; +import { FileMessageHandler } from './FileMessageHandler.js'; +import { EditorMessageHandler } from './EditorMessageHandler.js'; +import { AuthMessageHandler } from './AuthMessageHandler.js'; + +/** + * Message Router + * Routes messages to appropriate handlers + */ +export class MessageRouter { + private handlers: IMessageHandler[] = []; + private sessionHandler: SessionMessageHandler; + private authHandler: AuthMessageHandler; + private currentConversationId: string | null = null; + private permissionHandler: + | ((message: { type: string; data: { optionId: string } }) => void) + | null = null; + + constructor( + agentManager: QwenAgentManager, + conversationStore: ConversationStore, + currentConversationId: string | null, + sendToWebView: (message: unknown) => void, + ) { + this.currentConversationId = currentConversationId; + + // Initialize all handlers + this.sessionHandler = new SessionMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const fileHandler = new FileMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const editorHandler = new EditorMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + this.authHandler = new AuthMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + // Register handlers in order of priority + this.handlers = [ + this.sessionHandler, + fileHandler, + editorHandler, + this.authHandler, + ]; + } + + /** + * Route message to appropriate handler + */ + async route(message: { type: string; data?: unknown }): Promise { + console.log('[MessageRouter] Routing message:', message.type); + + // Handle permission response specially + if (message.type === 'permissionResponse') { + if (this.permissionHandler) { + this.permissionHandler( + message as { type: string; data: { optionId: string } }, + ); + } + return; + } + + // Find appropriate handler + const handler = this.handlers.find((h) => h.canHandle(message.type)); + + if (handler) { + try { + await handler.handle(message); + } catch (error) { + console.error('[MessageRouter] Handler error:', error); + throw error; + } + } else { + console.warn( + '[MessageRouter] No handler found for message type:', + message.type, + ); + } + } + + /** + * Set current conversation ID + */ + setCurrentConversationId(id: string | null): void { + this.currentConversationId = id; + // Update all handlers + this.handlers.forEach((handler) => { + if ('setCurrentConversationId' in handler) { + ( + handler as { setCurrentConversationId: (id: string | null) => void } + ).setCurrentConversationId(id); + } + }); + } + + /** + * Get current conversation ID + */ + getCurrentConversationId(): string | null { + return this.currentConversationId; + } + + /** + * Set permission handler + */ + setPermissionHandler( + handler: (message: { type: string; data: { optionId: string } }) => void, + ): void { + this.permissionHandler = handler; + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.authHandler.setLoginHandler(handler); + this.sessionHandler?.setLoginHandler?.(handler); + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.sessionHandler.appendStreamContent(chunk); + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts new file mode 100644 index 00000000..51dfbdd9 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -0,0 +1,995 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import type { ChatMessage } from '../../services/qwenAgentManager.js'; +import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; + +/** + * Session message handler + * Handles all session-related messages + */ +export class SessionMessageHandler extends BaseMessageHandler { + private currentStreamContent = ''; + private loginHandler: (() => Promise) | null = null; + private isTitleSet = false; // Flag to track if title has been set + + canHandle(messageType: string): boolean { + return [ + 'sendMessage', + 'newQwenSession', + 'switchQwenSession', + 'getQwenSessions', + 'saveSession', + 'resumeSession', + 'cancelStreaming', + // UI action: open a new chat tab (new WebviewPanel) + 'openNewChatTab', + // Settings-related messages + 'setApprovalMode', + ].includes(messageType); + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.loginHandler = handler; + } + + async handle(message: { type: string; data?: unknown }): Promise { + const data = message.data as Record | undefined; + + switch (message.type) { + case 'sendMessage': + await this.handleSendMessage( + (data?.text as string) || '', + data?.context as + | Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }> + | undefined, + data?.fileContext as + | { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + } + | undefined, + ); + break; + + case 'newQwenSession': + await this.handleNewQwenSession(); + break; + + case 'switchQwenSession': + await this.handleSwitchQwenSession((data?.sessionId as string) || ''); + break; + + case 'getQwenSessions': + await this.handleGetQwenSessions( + (data?.cursor as number | undefined) ?? undefined, + (data?.size as number | undefined) ?? undefined, + ); + break; + + case 'saveSession': + await this.handleSaveSession((data?.tag as string) || ''); + break; + + case 'resumeSession': + await this.handleResumeSession((data?.sessionId as string) || ''); + break; + + case 'openNewChatTab': + // Open a brand new chat tab (WebviewPanel) via the extension command + // This does not alter the current conversation in this tab; the new tab + // will initialize its own state and (optionally) create a new session. + try { + await vscode.commands.executeCommand('qwenCode.openNewChatTab'); + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to open new chat tab:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to open new chat tab: ${error}` }, + }); + } + break; + + case 'cancelStreaming': + // Handle cancel streaming request from webview + await this.handleCancelStreaming(); + break; + + case 'setApprovalMode': + await this.handleSetApprovalMode( + message.data as { + modeId?: ApprovalModeValue; + }, + ); + break; + + default: + console.warn( + '[SessionMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Get current stream content + */ + getCurrentStreamContent(): string { + return this.currentStreamContent; + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.currentStreamContent += chunk; + } + + /** + * Reset stream content + */ + resetStreamContent(): void { + this.currentStreamContent = ''; + } + + /** + * Prompt user to login and invoke the registered login handler/command. + * Returns true if a login was initiated. + */ + private async promptLogin(message: string): Promise { + const result = await vscode.window.showWarningMessage(message, 'Login Now'); + if (result === 'Login Now') { + if (this.loginHandler) { + await this.loginHandler(); + } else { + await vscode.commands.executeCommand('qwen-code.login'); + } + return true; + } + return false; + } + + /** + * Prompt user to login or view offline. Returns 'login', 'offline', or 'dismiss'. + * When login is chosen, it triggers the login handler/command. + */ + private async promptLoginOrOffline( + message: string, + ): Promise<'login' | 'offline' | 'dismiss'> { + const selection = await vscode.window.showWarningMessage( + message, + 'Login Now', + 'View Offline', + ); + + if (selection === 'Login Now') { + if (this.loginHandler) { + await this.loginHandler(); + } else { + await vscode.commands.executeCommand('qwen-code.login'); + } + return 'login'; + } + if (selection === 'View Offline') { + return 'offline'; + } + return 'dismiss'; + } + + /** + * Handle send message request + */ + private async handleSendMessage( + text: string, + context?: Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }>, + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }, + ): Promise { + console.log('[SessionMessageHandler] handleSendMessage called with:', text); + + // Format message with file context if present + let formattedText = text; + if (context && context.length > 0) { + const contextParts = context + .map((ctx) => { + if (ctx.startLine && ctx.endLine) { + return `${ctx.value}#${ctx.startLine}${ctx.startLine !== ctx.endLine ? `-${ctx.endLine}` : ''}`; + } + return ctx.value; + }) + .join('\n'); + + formattedText = `${contextParts}\n\n${text}`; + } + + // Ensure we have an active conversation + if (!this.currentConversationId) { + console.log( + '[SessionMessageHandler] No active conversation, creating one...', + ); + try { + const newConv = await this.conversationStore.createConversation(); + this.currentConversationId = newConv.id; + this.sendToWebView({ + type: 'conversationLoaded', + data: newConv, + }); + } catch (error) { + const errorMsg = `Failed to create conversation: ${error}`; + console.error('[SessionMessageHandler]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + } + + if (!this.currentConversationId) { + const errorMsg = + 'Failed to create conversation. Please restart the extension.'; + console.error('[SessionMessageHandler]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + + // Check if this is the first message + let isFirstMessage = false; + try { + const conversation = await this.conversationStore.getConversation( + this.currentConversationId, + ); + isFirstMessage = !conversation || conversation.messages.length === 0; + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to check conversation:', + error, + ); + } + + // Generate title for first message, but only if it hasn't been set yet + if (isFirstMessage && !this.isTitleSet) { + const title = text.substring(0, 50) + (text.length > 50 ? '...' : ''); + this.sendToWebView({ + type: 'sessionTitleUpdated', + data: { + sessionId: this.currentConversationId, + title, + }, + }); + this.isTitleSet = true; // Mark title as set + } + + // Save user message + const userMessage: ChatMessage = { + role: 'user', + content: text, + timestamp: Date.now(), + }; + + await this.conversationStore.addMessage( + this.currentConversationId, + userMessage, + ); + + // Send to WebView + this.sendToWebView({ + type: 'message', + data: { ...userMessage, fileContext }, + }); + + // Check if agent is connected + if (!this.agentManager.isConnected) { + console.warn('[SessionMessageHandler] Agent not connected'); + + // Show non-modal notification with Login button + await this.promptLogin('You need to login first to use Qwen Code.'); + return; + } + + // Ensure an ACP session exists before sending prompt + if (!this.agentManager.currentSessionId) { + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + await this.agentManager.createNewSession(workingDir); + } catch (createErr) { + console.error( + '[SessionMessageHandler] Failed to create session before sending message:', + createErr, + ); + const errorMsg = + createErr instanceof Error ? createErr.message : String(createErr); + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') + ) { + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', + ); + return; + } + vscode.window.showErrorMessage(`Failed to create session: ${errorMsg}`); + return; + } + } + + // Send to agent + try { + this.resetStreamContent(); + + this.sendToWebView({ + type: 'streamStart', + data: { timestamp: Date.now() }, + }); + + await this.agentManager.sendMessage(formattedText); + + // Save assistant message + if (this.currentStreamContent && this.currentConversationId) { + const assistantMessage: ChatMessage = { + role: 'assistant', + content: this.currentStreamContent, + timestamp: Date.now(), + }; + await this.conversationStore.addMessage( + this.currentConversationId, + assistantMessage, + ); + } + + this.sendToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now() }, + }); + } catch (error) { + console.error('[SessionMessageHandler] Error sending message:', error); + + const err = error as unknown as Error; + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + const lower = errorMsg.toLowerCase(); + + // Suppress user-cancelled/aborted errors (ESC/Stop button) + const isAbortLike = + (err && (err as Error).name === 'AbortError') || + lower.includes('abort') || + lower.includes('aborted') || + lower.includes('request was aborted') || + lower.includes('canceled') || + lower.includes('cancelled') || + lower.includes('user_cancelled'); + + if (isAbortLike) { + // Do not show VS Code error popup for intentional cancellations. + // Ensure the webview knows the stream ended due to user action. + this.sendToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + return; + } + // Check for session not found error and handle it appropriately + if ( + errorMsg.includes('Session not found') || + errorMsg.includes('No active ACP session') || + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + vscode.window.showErrorMessage(`Error sending message: ${error}`); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + } + } + } + + /** + * Handle new Qwen session request + */ + private async handleNewQwenSession(): Promise { + try { + console.log('[SessionMessageHandler] Creating new Qwen session...'); + + // Ensure connection (login) before creating a new session + if (!this.agentManager.isConnected) { + const proceeded = await this.promptLogin( + 'You need to login before creating a new session.', + ); + if (!proceeded) { + return; + } + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + await this.agentManager.createNewSession(workingDir); + + this.sendToWebView({ + type: 'conversationCleared', + data: {}, + }); + + // Reset title flag when creating a new session + this.isTitleSet = false; + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to create new session:', + error, + ); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to create a new session.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to create new session: ${error}` }, + }); + } + } + } + + /** + * Handle switch Qwen session request + */ + private async handleSwitchQwenSession(sessionId: string): Promise { + try { + console.log('[SessionMessageHandler] Switching to session:', sessionId); + + // If not connected yet, offer to login or view offline + if (!this.agentManager.isConnected) { + const choice = await this.promptLoginOrOffline( + 'You are not logged in. Login now to fully restore this session, or view it offline.', + ); + + if (choice === 'offline') { + // Show messages from local cache only + const messages = + await this.agentManager.getSessionMessages(sessionId); + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages }, + }); + vscode.window.showInformationMessage( + 'Showing cached session content. Login to interact with the AI.', + ); + return; + } else if (choice !== 'login') { + // User dismissed; do nothing + return; + } + } + + // Get session details (includes cwd and filePath when using ACP) + let sessionDetails: Record | null = null; + try { + const allSessions = await this.agentManager.getSessionList(); + sessionDetails = + allSessions.find( + (s: { id?: string; sessionId?: string }) => + s.id === sessionId || s.sessionId === sessionId, + ) || null; + } catch (err) { + console.log( + '[SessionMessageHandler] Could not get session details:', + err, + ); + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + // Try to load session via ACP (now we should be connected) + try { + // Set current id and clear UI first so replayed updates append afterwards + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages: [], session: sessionDetails }, + }); + + const loadResponse = await this.agentManager.loadSessionViaAcp( + sessionId, + (sessionDetails?.cwd as string | undefined) || undefined, + ); + console.log( + '[SessionMessageHandler] session/load succeeded (per ACP spec result is null; actual history comes via session/update):', + loadResponse, + ); + + // Reset title flag when switching sessions + this.isTitleSet = false; + + // Successfully loaded session, return early to avoid fallback logic + return; + } catch (loadError) { + console.warn( + '[SessionMessageHandler] session/load failed, using fallback:', + loadError, + ); + + // Safely convert error to string + const errorMsg = loadError ? String(loadError) : 'Unknown error'; + + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to switch sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + + // Fallback: create new session + const messages = await this.agentManager.getSessionMessages(sessionId); + + // If we are connected, try to create a fresh ACP session so user can interact + if (this.agentManager.isConnected) { + try { + const newAcpSessionId = + await this.agentManager.createNewSession(workingDir); + + this.currentConversationId = newAcpSessionId; + + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + + // Only show the cache warning if we actually fell back to local cache + // and didn't successfully load via ACP + // Check if we truly fell back by checking if loadError is not null/undefined + // and if it's not a successful response that looks like an error + if ( + loadError && + typeof loadError === 'object' && + !('result' in loadError) + ) { + vscode.window.showWarningMessage( + 'Session restored from local cache. Some context may be incomplete.', + ); + } + } catch (createError) { + console.error( + '[SessionMessageHandler] Failed to create session:', + createError, + ); + + // Safely convert error to string + const createErrorMsg = createError + ? String(createError) + : 'Unknown error'; + // Check for authentication/session expiration errors in session creation + if ( + createErrorMsg.includes('Authentication required') || + createErrorMsg.includes('(code: -32000)') || + createErrorMsg.includes('Unauthorized') || + createErrorMsg.includes('Invalid token') || + createErrorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to switch sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + + throw createError; + } + } else { + // Offline view only + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + vscode.window.showWarningMessage( + 'Showing cached session content. Login to interact with the AI.', + ); + } + } + } catch (error) { + console.error('[SessionMessageHandler] Failed to switch session:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to switch sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to switch session: ${error}` }, + }); + } + } + } + + /** + * Handle get Qwen sessions request + */ + private async handleGetQwenSessions( + cursor?: number, + size?: number, + ): Promise { + try { + // Paged when possible; falls back to full list if ACP not supported + const page = await this.agentManager.getSessionListPaged({ + cursor, + size, + }); + const append = typeof cursor === 'number'; + this.sendToWebView({ + type: 'qwenSessionList', + data: { + sessions: page.sessions, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + append, + }, + }); + } catch (error) { + console.error('[SessionMessageHandler] Failed to get sessions:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to view sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to get sessions: ${error}` }, + }); + } + } + } + + /** + * Handle save session request + */ + private async handleSaveSession(tag: string): Promise { + try { + if (!this.currentConversationId) { + throw new Error('No active conversation to save'); + } + + // Try ACP save first + try { + const response = await this.agentManager.saveSessionViaAcp( + this.currentConversationId, + tag, + ); + + this.sendToWebView({ + type: 'saveSessionResponse', + data: response, + }); + } catch (acpError) { + // Safely convert error to string + const errorMsg = acpError ? String(acpError) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to save sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + } + + await this.handleGetQwenSessions(); + } catch (error) { + console.error('[SessionMessageHandler] Failed to save session:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to save sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'saveSessionResponse', + data: { + success: false, + message: `Failed to save session: ${error}`, + }, + }); + } + } + } + + /** + * Handle cancel streaming request + */ + private async handleCancelStreaming(): Promise { + try { + console.log('[SessionMessageHandler] Canceling streaming...'); + + // Cancel the current streaming operation in the agent manager + await this.agentManager.cancelCurrentPrompt(); + + // Send streamEnd message to WebView to update UI + this.sendToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + + console.log('[SessionMessageHandler] Streaming cancelled successfully'); + } catch (_error) { + console.log('[SessionMessageHandler] Streaming cancelled (interrupted)'); + + // Always send streamEnd to update UI, regardless of errors + this.sendToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + } + } + + /** + * Handle resume session request + */ + private async handleResumeSession(sessionId: string): Promise { + try { + // If not connected, offer to login or view offline + if (!this.agentManager.isConnected) { + const choice = await this.promptLoginOrOffline( + 'You are not logged in. Login now to fully restore this session, or view it offline.', + ); + + if (choice === 'offline') { + const messages = + await this.agentManager.getSessionMessages(sessionId); + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages }, + }); + vscode.window.showInformationMessage( + 'Showing cached session content. Login to interact with the AI.', + ); + return; + } else if (choice !== 'login') { + return; + } + } + + // Try ACP load first + try { + // Pre-clear UI so replayed updates append afterwards + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages: [] }, + }); + + await this.agentManager.loadSessionViaAcp(sessionId); + + // Reset title flag when resuming sessions + this.isTitleSet = false; + + // Successfully loaded session, return early to avoid fallback logic + await this.handleGetQwenSessions(); + return; + } catch (acpError) { + // Safely convert error to string + const errorMsg = acpError ? String(acpError) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to resume sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + } + + await this.handleGetQwenSessions(); + } catch (error) { + console.error('[SessionMessageHandler] Failed to resume session:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to resume sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to resume session: ${error}` }, + }); + } + } + } + + /** + * Set approval mode via agent (ACP session/set_mode) + */ + private async handleSetApprovalMode(data?: { + modeId?: ApprovalModeValue; + }): Promise { + try { + const modeId = data?.modeId || 'default'; + await this.agentManager.setApprovalModeFromUi(modeId); + // No explicit response needed; WebView listens for modeChanged + } catch (error) { + console.error('[SessionMessageHandler] Failed to set mode:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to set mode: ${error}` }, + }); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts new file mode 100644 index 00000000..8bccc658 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useRef } from 'react'; +import type { VSCodeAPI } from '../../hooks/useVSCode.js'; + +/** + * File context management Hook + * Manages active file, selection content, and workspace file list + */ +export const useFileContext = (vscode: VSCodeAPI) => { + const [activeFileName, setActiveFileName] = useState(null); + const [activeFilePath, setActiveFilePath] = useState(null); + const [activeSelection, setActiveSelection] = useState<{ + startLine: number; + endLine: number; + } | null>(null); + + const [workspaceFiles, setWorkspaceFiles] = useState< + Array<{ + id: string; + label: string; + description: string; + path: string; + }> + >([]); + + // File reference mapping: @filename -> full path + const fileReferenceMap = useRef>(new Map()); + + // Whether workspace files have been requested + const hasRequestedFilesRef = useRef(false); + + // Last non-empty query to decide when to refetch full list + const lastQueryRef = useRef(undefined); + + // Search debounce timer + const searchTimerRef = useRef(null); + + /** + * Request workspace files + */ + const requestWorkspaceFiles = useCallback( + (query?: string) => { + const normalizedQuery = query?.trim(); + + // If there's a query, clear previous timer and set up debounce + if (normalizedQuery && normalizedQuery.length >= 1) { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + + searchTimerRef.current = setTimeout(() => { + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: { query: normalizedQuery }, + }); + }, 300); + lastQueryRef.current = normalizedQuery; + } else { + // For empty query, request once initially and whenever we are returning from a search + const shouldRequestFullList = + !hasRequestedFilesRef.current || lastQueryRef.current !== undefined; + + if (shouldRequestFullList) { + lastQueryRef.current = undefined; + hasRequestedFilesRef.current = true; + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: {}, + }); + } + } + }, + [vscode], + ); + + /** + * Add file reference + */ + const addFileReference = useCallback((fileName: string, filePath: string) => { + fileReferenceMap.current.set(fileName, filePath); + }, []); + + /** + * Get file reference + */ + const getFileReference = useCallback( + (fileName: string) => fileReferenceMap.current.get(fileName), + [], + ); + + /** + * Clear file references + */ + const clearFileReferences = useCallback(() => { + fileReferenceMap.current.clear(); + }, []); + + /** + * Request active editor info + */ + const requestActiveEditor = useCallback(() => { + vscode.postMessage({ type: 'getActiveEditor', data: {} }); + }, [vscode]); + + /** + * Focus on active editor + */ + const focusActiveEditor = useCallback(() => { + vscode.postMessage({ + type: 'focusActiveEditor', + data: {}, + }); + }, [vscode]); + + return { + // State + activeFileName, + activeFilePath, + activeSelection, + workspaceFiles, + hasRequestedFiles: hasRequestedFilesRef.current, + + // State setters + setActiveFileName, + setActiveFilePath, + setActiveSelection, + setWorkspaceFiles, + + // File reference operations + addFileReference, + getFileReference, + clearFileReferences, + + // Operations + requestWorkspaceFiles, + requestActiveEditor, + focusActiveEditor, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts new file mode 100644 index 00000000..17fde331 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useRef, useCallback } from 'react'; + +export interface TextMessage { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; +} + +/** + * Message handling Hook + * Manages message list, streaming responses, and loading state + */ +export const useMessageHandling = () => { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(''); + // Track the index of the assistant placeholder message during streaming + const streamingMessageIndexRef = useRef(null); + // Track the index of the current aggregated thinking message + const thinkingMessageIndexRef = useRef(null); + + /** + * Add message + */ + const addMessage = useCallback((message: TextMessage) => { + setMessages((prev) => [...prev, message]); + }, []); + + /** + * Clear messages + */ + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + /** + * Start streaming response + */ + const startStreaming = useCallback((timestamp?: number) => { + // Create an assistant placeholder message immediately so tool calls won't jump before it + setMessages((prev) => { + // Record index of the placeholder to update on chunks + streamingMessageIndexRef.current = prev.length; + return [ + ...prev, + { + role: 'assistant', + content: '', + // Use provided timestamp (from extension) to keep ordering stable + timestamp: typeof timestamp === 'number' ? timestamp : Date.now(), + }, + ]; + }); + setIsStreaming(true); + }, []); + + /** + * Add stream chunk + */ + const appendStreamChunk = useCallback( + (chunk: string) => { + // Ignore late chunks after user cancelled streaming (until next streamStart) + if (!isStreaming) { + return; + } + + setMessages((prev) => { + let idx = streamingMessageIndexRef.current; + const next = prev.slice(); + + // If there is no active placeholder (e.g., after a tool call), start a new one + if (idx === null) { + idx = next.length; + streamingMessageIndexRef.current = idx; + next.push({ role: 'assistant', content: '', timestamp: Date.now() }); + } + + if (idx < 0 || idx >= next.length) { + return prev; + } + const target = next[idx]; + next[idx] = { ...target, content: (target.content || '') + chunk }; + return next; + }); + }, + [isStreaming], + ); + + /** + * Break current assistant stream segment (e.g., when a tool call starts/updates) + * Next incoming chunk will create a new assistant placeholder + */ + const breakAssistantSegment = useCallback(() => { + streamingMessageIndexRef.current = null; + }, []); + + /** + * End streaming response + */ + const endStreaming = useCallback(() => { + // Finalize streaming; content already lives in the placeholder message + setIsStreaming(false); + streamingMessageIndexRef.current = null; + // Remove the thinking message if it exists (collapse thoughts) + setMessages((prev) => { + const idx = thinkingMessageIndexRef.current; + thinkingMessageIndexRef.current = null; + if (idx === null || idx < 0 || idx >= prev.length) { + return prev; + } + const next = prev.slice(); + next.splice(idx, 1); + return next; + }); + }, []); + + /** + * Set waiting for response state + */ + const setWaitingForResponse = useCallback((message: string) => { + setIsWaitingForResponse(true); + setLoadingMessage(message); + }, []); + + /** + * Clear waiting for response state + */ + const clearWaitingForResponse = useCallback(() => { + setIsWaitingForResponse(false); + setLoadingMessage(''); + }, []); + + return { + // State + messages, + isStreaming, + isWaitingForResponse, + loadingMessage, + + // Operations + addMessage, + clearMessages, + startStreaming, + appendStreamChunk, + endStreaming, + // Thought handling + appendThinkingChunk: (chunk: string) => { + // Ignore late thoughts after user cancelled streaming + if (!isStreaming) { + return; + } + setMessages((prev) => { + let idx = thinkingMessageIndexRef.current; + const next = prev.slice(); + if (idx === null) { + idx = next.length; + thinkingMessageIndexRef.current = idx; + next.push({ role: 'thinking', content: '', timestamp: Date.now() }); + } + if (idx >= 0 && idx < next.length) { + const target = next[idx]; + next[idx] = { ...target, content: (target.content || '') + chunk }; + } + return next; + }); + }, + clearThinking: () => { + setMessages((prev) => { + const idx = thinkingMessageIndexRef.current; + thinkingMessageIndexRef.current = null; + if (idx === null || idx < 0 || idx >= prev.length) { + return prev; + } + const next = prev.slice(); + next.splice(idx, 1); + return next; + }); + }, + breakAssistantSegment, + setWaitingForResponse, + clearWaitingForResponse, + setMessages, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts new file mode 100644 index 00000000..9fba4a80 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useMemo } from 'react'; +import type { VSCodeAPI } from '../../hooks/useVSCode.js'; + +/** + * Session management Hook + * Manages session list, current session, session switching, and search + */ +export const useSessionManagement = (vscode: VSCodeAPI) => { + const [qwenSessions, setQwenSessions] = useState< + Array> + >([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [currentSessionTitle, setCurrentSessionTitle] = + useState('Past Conversations'); + const [showSessionSelector, setShowSessionSelector] = useState(false); + const [sessionSearchQuery, setSessionSearchQuery] = useState(''); + const [savedSessionTags, setSavedSessionTags] = useState([]); + const [nextCursor, setNextCursor] = useState(undefined); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const PAGE_SIZE = 20; + + /** + * Filter session list + */ + const filteredSessions = useMemo(() => { + if (!sessionSearchQuery.trim()) { + return qwenSessions; + } + const query = sessionSearchQuery.toLowerCase(); + return qwenSessions.filter((session) => { + const title = ( + (session.title as string) || + (session.name as string) || + '' + ).toLowerCase(); + return title.includes(query); + }); + }, [qwenSessions, sessionSearchQuery]); + + /** + * Load session list + */ + const handleLoadQwenSessions = useCallback(() => { + // Reset pagination state and load first page + setQwenSessions([]); + setNextCursor(undefined); + setHasMore(true); + setIsLoading(true); + vscode.postMessage({ type: 'getQwenSessions', data: { size: PAGE_SIZE } }); + setShowSessionSelector(true); + }, [vscode]); + + const handleLoadMoreSessions = useCallback(() => { + if (!hasMore || isLoading || nextCursor === undefined) { + return; + } + setIsLoading(true); + vscode.postMessage({ + type: 'getQwenSessions', + data: { cursor: nextCursor, size: PAGE_SIZE }, + }); + }, [hasMore, isLoading, nextCursor, vscode]); + + /** + * Create new session + */ + const handleNewQwenSession = useCallback(() => { + vscode.postMessage({ type: 'openNewChatTab', data: {} }); + setShowSessionSelector(false); + }, [vscode]); + + /** + * Switch session + */ + const handleSwitchSession = useCallback( + (sessionId: string) => { + if (sessionId === currentSessionId) { + console.log('[useSessionManagement] Already on this session, ignoring'); + setShowSessionSelector(false); + return; + } + + console.log('[useSessionManagement] Switching to session:', sessionId); + vscode.postMessage({ + type: 'switchQwenSession', + data: { sessionId }, + }); + }, + [currentSessionId, vscode], + ); + + /** + * Save session + */ + const handleSaveSession = useCallback( + (tag: string) => { + vscode.postMessage({ + type: 'saveSession', + data: { tag }, + }); + }, + [vscode], + ); + + /** + * Handle Save session response + */ + const handleSaveSessionResponse = useCallback( + (response: { success: boolean; message?: string }) => { + if (response.success) { + if (response.message) { + const tagMatch = response.message.match(/tag: (.+)$/); + if (tagMatch) { + setSavedSessionTags((prev) => [...prev, tagMatch[1]]); + } + } + } else { + console.error('Failed to save session:', response.message); + } + }, + [], + ); + + return { + // State + qwenSessions, + currentSessionId, + currentSessionTitle, + showSessionSelector, + sessionSearchQuery, + filteredSessions, + savedSessionTags, + nextCursor, + hasMore, + isLoading, + + // State setters + setQwenSessions, + setCurrentSessionId, + setCurrentSessionTitle, + setShowSessionSelector, + setSessionSearchQuery, + setSavedSessionTags, + setNextCursor, + setHasMore, + setIsLoading, + + // Operations + handleLoadQwenSessions, + handleNewQwenSession, + handleSwitchSession, + handleSaveSession, + handleSaveSessionResponse, + handleLoadMoreSessions, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts new file mode 100644 index 00000000..b18843ef --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -0,0 +1,341 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { RefObject } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import type { CompletionItem } from '../../types/completionItemTypes.js'; + +interface CompletionTriggerState { + isOpen: boolean; + triggerChar: '@' | '/' | null; + query: string; + position: { top: number; left: number }; + items: CompletionItem[]; +} + +/** + * Hook to handle @ and / completion triggers in contentEditable + * Based on vscode-copilot-chat's AttachContextAction + */ +export function useCompletionTrigger( + inputRef: RefObject, + getCompletionItems: ( + trigger: '@' | '/', + query: string, + ) => Promise, +) { + // Show immediate loading and provide a timeout fallback for slow sources + const LOADING_ITEM = useMemo( + () => ({ + id: 'loading', + label: 'Loadingโ€ฆ', + type: 'info', + }), + [], + ); + + const TIMEOUT_ITEM = useMemo( + () => ({ + id: 'timeout', + label: 'Timeout', + type: 'info', + }), + [], + ); + const TIMEOUT_MS = 5000; + + const [state, setState] = useState({ + isOpen: false, + triggerChar: null, + query: '', + position: { top: 0, left: 0 }, + items: [], + }); + + // Timer for loading timeout + const timeoutRef = useRef | null>(null); + + const closeCompletion = useCallback(() => { + // Clear pending timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setState({ + isOpen: false, + triggerChar: null, + query: '', + position: { top: 0, left: 0 }, + items: [], + }); + }, []); + + const openCompletion = useCallback( + async ( + trigger: '@' | '/', + query: string, + position: { top: number; left: number }, + ) => { + // Clear previous timeout if any + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + // Open immediately with a loading placeholder + setState({ + isOpen: true, + triggerChar: trigger, + query, + position, + items: [LOADING_ITEM], + }); + + // Schedule a timeout fallback if loading takes too long + timeoutRef.current = setTimeout(() => { + setState((prev) => { + // Only show timeout if still open and still for the same request + if ( + prev.isOpen && + prev.triggerChar === trigger && + prev.query === query && + prev.items.length > 0 && + prev.items[0]?.id === 'loading' + ) { + return { ...prev, items: [TIMEOUT_ITEM] }; + } + return prev; + }); + }, TIMEOUT_MS); + + const items = await getCompletionItems(trigger, query); + + // Clear timeout on success + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + setState((prev) => ({ + ...prev, + isOpen: true, + triggerChar: trigger, + query, + position, + items, + })); + }, + [getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM], + ); + + // Helper function to compare completion items arrays + const areItemsEqual = ( + items1: CompletionItem[], + items2: CompletionItem[], + ): boolean => { + if (items1.length !== items2.length) { + return false; + } + + // Compare each item by stable fields (ignore non-deterministic props like icons) + for (let i = 0; i < items1.length; i++) { + const a = items1[i]; + const b = items2[i]; + if (a.id !== b.id) { + return false; + } + if (a.label !== b.label) { + return false; + } + if ((a.description ?? '') !== (b.description ?? '')) { + return false; + } + if (a.type !== b.type) { + return false; + } + if ((a.value ?? '') !== (b.value ?? '')) { + return false; + } + if ((a.path ?? '') !== (b.path ?? '')) { + return false; + } + } + + return true; + }; + + const refreshCompletion = useCallback(async () => { + if (!state.isOpen || !state.triggerChar) { + return; + } + const items = await getCompletionItems(state.triggerChar, state.query); + + // Only update state if items have actually changed + setState((prev) => { + if (areItemsEqual(prev.items, items)) { + return prev; + } + return { ...prev, items }; + }); + }, [state.isOpen, state.triggerChar, state.query, getCompletionItems]); + + useEffect(() => { + const inputElement = inputRef.current; + if (!inputElement) { + return; + } + + const getCursorPosition = (): { top: number; left: number } | null => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + + try { + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + // If the range has a valid position, use it + if (rect.top > 0 && rect.left > 0) { + return { + top: rect.top, + left: rect.left, + }; + } + + // Fallback: use input element's position + const inputRect = inputElement.getBoundingClientRect(); + return { + top: inputRect.top, + left: inputRect.left, + }; + } catch (error) { + console.error( + '[useCompletionTrigger] Error getting cursor position:', + error, + ); + const inputRect = inputElement.getBoundingClientRect(); + return { + top: inputRect.top, + left: inputRect.left, + }; + } + }; + + const handleInput = async () => { + const text = inputElement.textContent || ''; + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + console.log('[useCompletionTrigger] No selection or rangeCount === 0'); + return; + } + + const range = selection.getRangeAt(0); + + // Get cursor position more reliably + // For contentEditable, we need to calculate the actual text offset + let cursorPosition = text.length; // Default to end of text + + if (range.startContainer === inputElement) { + // Cursor is directly in the container (e.g., empty or at boundary) + // Use childNodes to determine position + const childIndex = range.startOffset; + let offset = 0; + for ( + let i = 0; + i < childIndex && i < inputElement.childNodes.length; + i++ + ) { + offset += inputElement.childNodes[i].textContent?.length || 0; + } + cursorPosition = offset || text.length; + } else if (range.startContainer.nodeType === Node.TEXT_NODE) { + // Cursor is in a text node - calculate offset from start of input + const walker = document.createTreeWalker( + inputElement, + NodeFilter.SHOW_TEXT, + null, + ); + + let offset = 0; + let found = false; + let node: Node | null = walker.nextNode(); + while (node) { + if (node === range.startContainer) { + offset += range.startOffset; + found = true; + break; + } + offset += node.textContent?.length || 0; + node = walker.nextNode(); + } + // If we found the node, use the calculated offset; otherwise use text length + cursorPosition = found ? offset : text.length; + } + + // Find trigger character before cursor + // Use text length if cursorPosition is 0 but we have text (edge case for first character) + const effectiveCursorPosition = + cursorPosition === 0 && text.length > 0 ? text.length : cursorPosition; + + const textBeforeCursor = text.substring(0, effectiveCursorPosition); + const lastAtMatch = textBeforeCursor.lastIndexOf('@'); + const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); + + // Check if we're in a trigger context + let triggerPos = -1; + let triggerChar: '@' | '/' | null = null; + + if (lastAtMatch > lastSlashMatch) { + triggerPos = lastAtMatch; + triggerChar = '@'; + } else if (lastSlashMatch > lastAtMatch) { + triggerPos = lastSlashMatch; + triggerChar = '/'; + } + + // Check if trigger is at word boundary (start of line or after space) + if (triggerPos >= 0 && triggerChar) { + const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' '; + const isValidTrigger = + charBefore === ' ' || charBefore === '\n' || triggerPos === 0; + + if (isValidTrigger) { + const query = text.substring(triggerPos + 1, effectiveCursorPosition); + + // Only show if query doesn't contain spaces (still typing the reference) + if (!query.includes(' ') && !query.includes('\n')) { + // Get precise cursor position for menu + const cursorPos = getCursorPosition(); + if (cursorPos) { + await openCompletion(triggerChar, query, cursorPos); + return; + } + } + } + } + + // Close if no valid trigger + if (state.isOpen) { + closeCompletion(); + } + }; + + inputElement.addEventListener('input', handleInput); + return () => inputElement.removeEventListener('input', handleInput); + }, [inputRef, state.isOpen, openCompletion, closeCompletion]); + + return { + isOpen: state.isOpen, + triggerChar: state.triggerChar, + query: state.query, + position: state.position, + items: state.items, + closeCompletion, + openCompletion, + refreshCompletion, + }; +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts new file mode 100644 index 00000000..a91594c0 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +import type { VSCodeAPI } from './useVSCode.js'; +import { getRandomLoadingMessage } from '../../constants/loadingMessages.js'; + +interface UseMessageSubmitProps { + vscode: VSCodeAPI; + inputText: string; + setInputText: (text: string) => void; + inputFieldRef: React.RefObject; + isStreaming: boolean; + isWaitingForResponse: boolean; + // When true, do NOT auto-attach the active editor file/selection to context + skipAutoActiveContext?: boolean; + + fileContext: { + getFileReference: (fileName: string) => string | undefined; + activeFilePath: string | null; + activeFileName: string | null; + activeSelection: { startLine: number; endLine: number } | null; + clearFileReferences: () => void; + }; + + messageHandling: { + setWaitingForResponse: (message: string) => void; + }; +} + +/** + * Message submit Hook + * Handles message submission logic and context parsing + */ +export const useMessageSubmit = ({ + vscode, + inputText, + setInputText, + inputFieldRef, + isStreaming, + isWaitingForResponse, + skipAutoActiveContext = false, + fileContext, + messageHandling, +}: UseMessageSubmitProps) => { + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (!inputText.trim() || isStreaming || isWaitingForResponse) { + return; + } + + // Handle /login command - show inline loading while extension authenticates + if (inputText.trim() === '/login') { + setInputText(''); + if (inputFieldRef.current) { + // Use a zero-width space to maintain the height of the contentEditable element + inputFieldRef.current.textContent = '\u200B'; + // Set the data-empty attribute to show the placeholder + inputFieldRef.current.setAttribute('data-empty', 'true'); + } + vscode.postMessage({ + type: 'login', + data: {}, + }); + // Show a friendly loading message in the chat while logging in + try { + messageHandling.setWaitingForResponse('Logging in to Qwen Code...'); + } catch (_err) { + // Best-effort UI hint; ignore if hook not available + } + return; + } + + messageHandling.setWaitingForResponse(getRandomLoadingMessage()); + + // Parse @file references from input text + const context: Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }> = []; + const fileRefPattern = /@([^\s]+)/g; + let match; + + while ((match = fileRefPattern.exec(inputText)) !== null) { + const fileName = match[1]; + const filePath = fileContext.getFileReference(fileName); + + if (filePath) { + context.push({ + type: 'file', + name: fileName, + value: filePath, + }); + } + } + + // Add active file selection context if present and not skipped + if (fileContext.activeFilePath && !skipAutoActiveContext) { + const fileName = fileContext.activeFileName || 'current file'; + context.push({ + type: 'file', + name: fileName, + value: fileContext.activeFilePath, + startLine: fileContext.activeSelection?.startLine, + endLine: fileContext.activeSelection?.endLine, + }); + } + + let fileContextForMessage: + | { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + } + | undefined; + + if ( + fileContext.activeFilePath && + fileContext.activeFileName && + !skipAutoActiveContext + ) { + fileContextForMessage = { + fileName: fileContext.activeFileName, + filePath: fileContext.activeFilePath, + startLine: fileContext.activeSelection?.startLine, + endLine: fileContext.activeSelection?.endLine, + }; + } + + vscode.postMessage({ + type: 'sendMessage', + data: { + text: inputText, + context: context.length > 0 ? context : undefined, + fileContext: fileContextForMessage, + }, + }); + + setInputText(''); + if (inputFieldRef.current) { + // Use a zero-width space to maintain the height of the contentEditable element + inputFieldRef.current.textContent = '\u200B'; + // Set the data-empty attribute to show the placeholder + inputFieldRef.current.setAttribute('data-empty', 'true'); + } + fileContext.clearFileReferences(); + }, + [ + inputText, + isStreaming, + setInputText, + inputFieldRef, + vscode, + fileContext, + skipAutoActiveContext, + isWaitingForResponse, + messageHandling, + ], + ); + + return { handleSubmit }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts new file mode 100644 index 00000000..1b994afd --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import type { ToolCallData } from '../components/messages/toolcalls/ToolCall.js'; +import type { ToolCallUpdate } from '../../types/chatTypes.js'; + +/** + * Tool call management Hook + * Manages tool call states and updates + */ +export const useToolCalls = () => { + const [toolCalls, setToolCalls] = useState>( + new Map(), + ); + + /** + * Handle tool call update + */ + const handleToolCallUpdate = useCallback((update: ToolCallUpdate) => { + setToolCalls((prevToolCalls) => { + const newMap = new Map(prevToolCalls); + const existing = newMap.get(update.toolCallId); + + // Helpers for todo/todos plan merging & content replacement + const isTodoWrite = (kind?: string) => + (kind || '').toLowerCase() === 'todo_write' || + (kind || '').toLowerCase() === 'todowrite' || + (kind || '').toLowerCase() === 'update_todos'; + + const normTitle = (t: unknown) => + typeof t === 'string' ? t.trim().toLowerCase() : ''; + + const isTodoTitleMergeable = (t?: unknown) => { + const nt = normTitle(t); + return nt === 'updated plan' || nt === 'update todos'; + }; + + const extractText = ( + content?: Array<{ + type: 'content' | 'diff'; + content?: { text?: string }; + }>, + ): string => { + if (!content || content.length === 0) { + return ''; + } + const parts: string[] = []; + for (const item of content) { + if (item.type === 'content' && item.content?.text) { + parts.push(String(item.content.text)); + } + } + return parts.join('\n'); + }; + + const normalizeTodoLines = (text: string): string[] => { + if (!text) { + return []; + } + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + return lines.map((line) => { + const idx = line.indexOf('] '); + return idx >= 0 ? line.slice(idx + 2).trim() : line; + }); + }; + + const isSameOrSupplement = ( + prevText: string, + nextText: string, + ): { same: boolean; supplement: boolean } => { + const prev = normalizeTodoLines(prevText); + const next = normalizeTodoLines(nextText); + if (prev.length === next.length) { + const same = prev.every((l, i) => l === next[i]); + if (same) { + return { same: true, supplement: false }; + } + } + // supplement = prev set is subset of next set + const setNext = new Set(next); + const subset = prev.every((l) => setNext.has(l)); + return { same: false, supplement: subset }; + }; + + const safeTitle = (title: unknown): string => { + if (typeof title === 'string') { + return title; + } + if (title && typeof title === 'object') { + return JSON.stringify(title); + } + return 'Tool Call'; + }; + + if (update.type === 'tool_call') { + const content = update.content?.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })); + + // Merge strategy: For todo_write + mergeable titles (Updated Plan/Update Todos), + // if it is the same as or a supplement to the most recent similar card, merge the update instead of adding new. + if (isTodoWrite(update.kind) && isTodoTitleMergeable(update.title)) { + const nextText = extractText(content); + // Find the most recent card with todo_write + mergeable title + let lastId: string | null = null; + let lastText = ''; + let lastTimestamp = 0; + for (const tc of newMap.values()) { + if ( + isTodoWrite(tc.kind) && + isTodoTitleMergeable(tc.title) && + typeof tc.timestamp === 'number' && + tc.timestamp >= lastTimestamp + ) { + lastId = tc.toolCallId; + lastText = extractText(tc.content); + lastTimestamp = tc.timestamp || 0; + } + } + + if (lastId) { + const cmp = isSameOrSupplement(lastText, nextText); + if (cmp.same) { + // Completely identical: Ignore this addition + return newMap; + } + if (cmp.supplement) { + // Supplement: Replace content to the previous item (using update semantics) + const prev = newMap.get(lastId); + if (prev) { + newMap.set(lastId, { + ...prev, + content, // Override (do not append) + status: update.status || prev.status, + timestamp: update.timestamp || Date.now(), + }); + return newMap; + } + } + } + } + + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: safeTitle(update.title), + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content, + locations: update.locations, + timestamp: update.timestamp || Date.now(), // Add timestamp + }); + } else if (update.type === 'tool_call_update') { + const updatedContent = update.content + ? update.content.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })) + : undefined; + + if (existing) { + // Default behavior is to append; but for todo_write + mergeable titles, use replacement to avoid stacking duplicates + let mergedContent = existing.content; + if (updatedContent) { + if ( + isTodoWrite(update.kind || existing.kind) && + (isTodoTitleMergeable(update.title) || + isTodoTitleMergeable(existing.title)) + ) { + mergedContent = updatedContent; // Override + } else { + mergedContent = [...(existing.content || []), ...updatedContent]; + } + } + // If tool call has just completed/failed, bump timestamp to now for correct ordering + const isFinal = + update.status === 'completed' || update.status === 'failed'; + const nextTimestamp = isFinal + ? Date.now() + : update.timestamp || existing.timestamp || Date.now(); + + newMap.set(update.toolCallId, { + ...existing, + ...(update.kind && { kind: update.kind }), + ...(update.title && { title: safeTitle(update.title) }), + ...(update.status && { status: update.status }), + content: mergedContent, + ...(update.locations && { locations: update.locations }), + timestamp: nextTimestamp, // Update timestamp (use completion time when completed/failed) + }); + } else { + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: update.title ? safeTitle(update.title) : '', + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content: updatedContent, + locations: update.locations, + timestamp: update.timestamp || Date.now(), // Add timestamp + }); + } + } + + return newMap; + }); + }, []); + + /** + * Clear all tool calls + */ + const clearToolCalls = useCallback(() => { + setToolCalls(new Map()); + }, []); + + /** + * Get in-progress tool calls + */ + const inProgressToolCalls = Array.from(toolCalls.values()).filter( + (toolCall) => + toolCall.status === 'pending' || toolCall.status === 'in_progress', + ); + + /** + * Get completed tool calls + */ + const completedToolCalls = Array.from(toolCalls.values()).filter( + (toolCall) => + toolCall.status === 'completed' || toolCall.status === 'failed', + ); + + return { + toolCalls, + inProgressToolCalls, + completedToolCalls, + handleToolCallUpdate, + clearToolCalls, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts new file mode 100644 index 00000000..1a161346 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface VSCodeAPI { + postMessage: (message: unknown) => void; + getState: () => unknown; + setState: (state: unknown) => void; +} + +declare const acquireVsCodeApi: () => VSCodeAPI; + +/** + * Module-level VS Code API instance cache + * acquireVsCodeApi() can only be called once, must be cached at module level + */ +let vscodeApiInstance: VSCodeAPI | null = null; + +/** + * Get VS Code API instance + * Uses module-level cache to ensure acquireVsCodeApi() is only called once + */ +function getVSCodeAPI(): VSCodeAPI { + if (vscodeApiInstance) { + return vscodeApiInstance; + } + + if (typeof acquireVsCodeApi !== 'undefined') { + vscodeApiInstance = acquireVsCodeApi(); + return vscodeApiInstance; + } + + // Fallback for development/testing + vscodeApiInstance = { + postMessage: (message: unknown) => { + console.log('Mock postMessage:', message); + }, + getState: () => ({}), + setState: (state: unknown) => { + console.log('Mock setState:', state); + }, + }; + return vscodeApiInstance; +} + +/** + * Hook to get VS Code API + * Multiple components can safely call this hook, API instance will be reused + */ +export function useVSCode(): VSCodeAPI { + return getVSCodeAPI(); +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts new file mode 100644 index 00000000..c8d507f2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -0,0 +1,832 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { useVSCode } from './useVSCode.js'; +import type { Conversation } from '../../services/conversationStore.js'; +import type { + PermissionOption, + ToolCall as PermissionToolCall, +} from '../components/PermissionDrawer/PermissionRequest.js'; +import type { ToolCallUpdate } from '../../types/chatTypes.js'; +import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; +import type { PlanEntry } from '../../types/chatTypes.js'; + +interface UseWebViewMessagesProps { + // Session management + sessionManagement: { + currentSessionId: string | null; + setQwenSessions: ( + sessions: + | Array> + | (( + prev: Array>, + ) => Array>), + ) => void; + setCurrentSessionId: (id: string | null) => void; + setCurrentSessionTitle: (title: string) => void; + setShowSessionSelector: (show: boolean) => void; + setNextCursor: (cursor: number | undefined) => void; + setHasMore: (hasMore: boolean) => void; + setIsLoading: (loading: boolean) => void; + handleSaveSessionResponse: (response: { + success: boolean; + message?: string; + }) => void; + }; + + // File context + fileContext: { + setActiveFileName: (name: string | null) => void; + setActiveFilePath: (path: string | null) => void; + setActiveSelection: ( + selection: { startLine: number; endLine: number } | null, + ) => void; + setWorkspaceFiles: ( + files: Array<{ + id: string; + label: string; + description: string; + path: string; + }>, + ) => void; + addFileReference: (name: string, path: string) => void; + }; + + // Message handling + messageHandling: { + setMessages: ( + messages: Array<{ + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; + }>, + ) => void; + addMessage: (message: { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + }) => void; + clearMessages: () => void; + startStreaming: (timestamp?: number) => void; + appendStreamChunk: (chunk: string) => void; + endStreaming: () => void; + breakAssistantSegment: () => void; + appendThinkingChunk: (chunk: string) => void; + clearThinking: () => void; + setWaitingForResponse: (message: string) => void; + clearWaitingForResponse: () => void; + }; + + // Tool calls + handleToolCallUpdate: (update: ToolCallUpdate) => void; + clearToolCalls: () => void; + + // Plan + setPlanEntries: (entries: PlanEntry[]) => void; + + // Permission + // When request is non-null, open/update the permission drawer. + // When null, close the drawer (used when extension simulates a choice). + handlePermissionRequest: ( + request: { + options: PermissionOption[]; + toolCall: PermissionToolCall; + } | null, + ) => void; + + // Input + inputFieldRef: React.RefObject; + setInputText: (text: string) => void; + // Edit mode setter (maps ACP modes to UI modes) + setEditMode?: (mode: ApprovalModeValue) => void; + // Authentication state setter + setIsAuthenticated?: (authenticated: boolean | null) => void; +} + +/** + * WebView message handling Hook + * Handles all messages from VSCode Extension uniformly + */ +export const useWebViewMessages = ({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + inputFieldRef, + setInputText, + setEditMode, + setIsAuthenticated, +}: UseWebViewMessagesProps) => { + // VS Code API for posting messages back to the extension host + const vscode = useVSCode(); + // Track active long-running tool calls (execute/bash/command) so we can + // keep the bottom "waiting" message visible until all of them complete. + const activeExecToolCallsRef = useRef>(new Set()); + // Use ref to store callbacks to avoid useEffect dependency issues + const handlersRef = useRef({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + setIsAuthenticated, + }); + + // Track last "Updated Plan" snapshot toolcall to support merge/dedupe + const lastPlanSnapshotRef = useRef<{ + id: string; + text: string; // joined lines + lines: string[]; + } | null>(null); + + const buildPlanLines = (entries: PlanEntry[]): string[] => + entries.map((e) => { + const mark = + e.status === 'completed' ? 'x' : e.status === 'in_progress' ? '-' : ' '; + return `- [${mark}] ${e.content}`.trim(); + }); + + const isSupplementOf = ( + prevLines: string[], + nextLines: string[], + ): boolean => { + // Consider "supplement" = old content text collection (ignoring status) is contained in new content + const key = (line: string) => { + const idx = line.indexOf('] '); + return idx >= 0 ? line.slice(idx + 2).trim() : line.trim(); + }; + const nextSet = new Set(nextLines.map(key)); + for (const pl of prevLines) { + if (!nextSet.has(key(pl))) { + return false; + } + } + return true; + }; + + // Update refs + useEffect(() => { + handlersRef.current = { + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + setIsAuthenticated, + }; + }); + + const handleMessage = useCallback( + (event: MessageEvent) => { + const message = event.data; + const handlers = handlersRef.current; + + switch (message.type) { + case 'modeInfo': { + // Initialize UI mode from ACP initialize + try { + const current = (message.data?.currentModeId || + 'default') as ApprovalModeValue; + setEditMode?.(current); + } catch (_error) { + // best effort + } + break; + } + + case 'modeChanged': { + try { + const modeId = (message.data?.modeId || + 'default') as ApprovalModeValue; + setEditMode?.(modeId); + } catch (_error) { + // Ignore error when setting mode + } + break; + } + + case 'loginSuccess': { + // Clear loading state and show a short assistant notice + handlers.messageHandling.clearWaitingForResponse(); + handlers.messageHandling.addMessage({ + role: 'assistant', + content: 'Successfully logged in. You can continue chatting.', + timestamp: Date.now(), + }); + // Set authentication state to true + handlers.setIsAuthenticated?.(true); + break; + } + + case 'agentConnected': { + // Agent connected successfully; clear any pending spinner + handlers.messageHandling.clearWaitingForResponse(); + // Set authentication state to true + handlers.setIsAuthenticated?.(true); + break; + } + + case 'agentConnectionError': { + // Agent connection failed; surface the error and unblock the UI + handlers.messageHandling.clearWaitingForResponse(); + const errorMsg = + (message?.data?.message as string) || + 'Failed to connect to Qwen agent.'; + + handlers.messageHandling.addMessage({ + role: 'assistant', + content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`, + timestamp: Date.now(), + }); + // Set authentication state to false + handlers.setIsAuthenticated?.(false); + break; + } + + case 'loginError': { + // Clear loading state and show error notice + handlers.messageHandling.clearWaitingForResponse(); + const errorMsg = + (message?.data?.message as string) || + 'Login failed. Please try again.'; + handlers.messageHandling.addMessage({ + role: 'assistant', + content: errorMsg, + timestamp: Date.now(), + }); + // Set authentication state to false + handlers.setIsAuthenticated?.(false); + break; + } + + case 'authState': { + const state = ( + message?.data as { authenticated?: boolean | null } | undefined + )?.authenticated; + if (typeof state === 'boolean') { + handlers.setIsAuthenticated?.(state); + } else { + handlers.setIsAuthenticated?.(null); + } + break; + } + + case 'conversationLoaded': { + const conversation = message.data as Conversation; + handlers.messageHandling.setMessages(conversation.messages); + break; + } + + case 'message': { + const msg = message.data as { + role?: 'user' | 'assistant' | 'thinking'; + content?: string; + timestamp?: number; + }; + handlers.messageHandling.addMessage( + msg as unknown as Parameters< + typeof handlers.messageHandling.addMessage + >[0], + ); + // Robustness: if an assistant message arrives outside the normal stream + // pipeline (no explicit streamEnd), ensure we clear streaming/waiting states + if (msg.role === 'assistant') { + try { + handlers.messageHandling.endStreaming(); + } catch (_error) { + // no-op: stream might not have been started + console.warn('[PanelManager] Failed to end streaming:', _error); + } + // Important: Do NOT blindly clear the waiting message if there are + // still active tool calls running. We keep the waiting indicator + // tied to tool-call lifecycle instead. + if (activeExecToolCallsRef.current.size === 0) { + try { + handlers.messageHandling.clearWaitingForResponse(); + } catch (_error) { + // no-op: already cleared + console.warn( + '[PanelManager] Failed to clear waiting for response:', + _error, + ); + } + } + } + break; + } + + case 'streamStart': + handlers.messageHandling.startStreaming( + (message.data as { timestamp?: number } | undefined)?.timestamp, + ); + break; + + case 'streamChunk': { + handlers.messageHandling.appendStreamChunk(message.data.chunk); + break; + } + + case 'thoughtChunk': { + const chunk = message.data.content || message.data.chunk || ''; + handlers.messageHandling.appendThinkingChunk(chunk); + break; + } + + case 'streamEnd': { + // Always end local streaming state and clear thinking state + handlers.messageHandling.endStreaming(); + handlers.messageHandling.clearThinking(); + + // If stream ended due to explicit user cancellation, proactively clear + // waiting indicator and reset tracked execution calls. + // This avoids UI getting stuck with Stop button visible after + // rejecting a permission request. + try { + const reason = ( + (message.data as { reason?: string } | undefined)?.reason || '' + ).toLowerCase(); + + /** + * Handle different types of stream end reasons: + * - 'user_cancelled': User explicitly cancelled operation + * - 'cancelled': General cancellation + * For these cases, immediately clear all active states + */ + if (reason === 'user_cancelled' || reason === 'cancelled') { + // Clear active execution tool call tracking, reset state + activeExecToolCallsRef.current.clear(); + // Clear waiting response state to ensure UI returns to normal + handlers.messageHandling.clearWaitingForResponse(); + break; + } + } catch (_error) { + // Best-effort handling, errors don't affect main flow + } + + /** + * For other types of stream end (non-user cancellation): + * Only clear generic waiting indicator when there are no active + * long-running tool calls. If there are still active execute/bash/command + * calls, keep the hint visible. + */ + if (activeExecToolCallsRef.current.size === 0) { + handlers.messageHandling.clearWaitingForResponse(); + } + break; + } + + case 'error': + handlers.messageHandling.clearWaitingForResponse(); + break; + + case 'permissionRequest': { + handlers.handlePermissionRequest(message.data); + + const permToolCall = message.data?.toolCall as { + toolCallId?: string; + kind?: string; + title?: string; + status?: string; + content?: unknown[]; + locations?: Array<{ path: string; line?: number | null }>; + }; + + if (permToolCall?.toolCallId) { + // Infer kind more robustly for permission preview: + // - If content contains a diff entry, force 'edit' so the EditToolCall can handle it properly + // - Else try title-based hints; fall back to provided kind or 'execute' + let kind = permToolCall.kind || 'execute'; + const contentArr = (permToolCall.content as unknown[]) || []; + const hasDiff = Array.isArray(contentArr) + ? contentArr.some( + (c: unknown) => + !!c && + typeof c === 'object' && + (c as { type?: string }).type === 'diff', + ) + : false; + if (hasDiff) { + kind = 'edit'; + + // Auto-open diff view for edit operations with diff content + // This replaces the useEffect auto-trigger in EditToolCall component + const diffContent = contentArr.find( + (c: unknown) => + !!c && + typeof c === 'object' && + (c as { type?: string }).type === 'diff', + ) as + | { path?: string; oldText?: string; newText?: string } + | undefined; + + if ( + diffContent?.path && + diffContent?.oldText !== undefined && + diffContent?.newText !== undefined + ) { + vscode.postMessage({ + type: 'openDiff', + data: { + path: diffContent.path, + oldText: diffContent.oldText, + newText: diffContent.newText, + }, + }); + } + } else if (permToolCall.title) { + const title = permToolCall.title.toLowerCase(); + if (title.includes('touch') || title.includes('echo')) { + kind = 'execute'; + } else if (title.includes('read') || title.includes('cat')) { + kind = 'read'; + } else if (title.includes('write') || title.includes('edit')) { + kind = 'edit'; + } + } + + const normalizedStatus = ( + permToolCall.status === 'pending' || + permToolCall.status === 'in_progress' || + permToolCall.status === 'completed' || + permToolCall.status === 'failed' + ? permToolCall.status + : 'pending' + ) as ToolCallUpdate['status']; + + handlers.handleToolCallUpdate({ + type: 'tool_call', + toolCallId: permToolCall.toolCallId, + kind, + title: permToolCall.title, + status: normalizedStatus, + content: permToolCall.content as ToolCallUpdate['content'], + locations: permToolCall.locations, + }); + + // Split assistant stream so subsequent chunks start a new assistant message + handlers.messageHandling.breakAssistantSegment(); + } + break; + } + + case 'permissionResolved': { + // Extension proactively resolved a pending permission; close drawer. + try { + handlers.handlePermissionRequest(null); + } catch (_error) { + console.warn( + '[useWebViewMessages] failed to close permission UI:', + _error, + ); + } + break; + } + + case 'plan': + if (message.data.entries && Array.isArray(message.data.entries)) { + const entries = message.data.entries as PlanEntry[]; + handlers.setPlanEntries(entries); + + // Generate new snapshot text + const lines = buildPlanLines(entries); + const text = lines.join('\n'); + const prev = lastPlanSnapshotRef.current; + + // 1) Identical -> Skip + if (prev && prev.text === text) { + break; + } + + try { + const ts = Date.now(); + + // 2) Supplement or status update -> Merge to previous (use tool_call_update to override content) + if (prev && isSupplementOf(prev.lines, lines)) { + handlers.handleToolCallUpdate({ + type: 'tool_call_update', + toolCallId: prev.id, + kind: 'todo_write', + title: 'Updated Plan', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text }, + }, + ], + timestamp: ts, + }); + lastPlanSnapshotRef.current = { id: prev.id, text, lines }; + } else { + // 3) Other cases -> Add a new history card + const toolCallId = `plan-snapshot-${ts}`; + handlers.handleToolCallUpdate({ + type: 'tool_call', + toolCallId, + kind: 'todo_write', + title: 'Updated Plan', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text }, + }, + ], + timestamp: ts, + }); + lastPlanSnapshotRef.current = { id: toolCallId, text, lines }; + } + + // Split assistant message segments, keep rendering blocks independent + handlers.messageHandling.breakAssistantSegment?.(); + } catch (_error) { + console.warn( + '[useWebViewMessages] failed to push/merge plan snapshot toolcall:', + _error, + ); + } + } + break; + + case 'toolCall': + case 'toolCallUpdate': { + const toolCallData = message.data; + if (toolCallData.sessionUpdate && !toolCallData.type) { + toolCallData.type = toolCallData.sessionUpdate; + } + handlers.handleToolCallUpdate(toolCallData); + + // Split assistant stream + const status = (toolCallData.status || '').toString(); + const isStart = toolCallData.type === 'tool_call'; + const isFinalUpdate = + toolCallData.type === 'tool_call_update' && + (status === 'completed' || status === 'failed'); + if (isStart || isFinalUpdate) { + handlers.messageHandling.breakAssistantSegment(); + } + + // While long-running tools (e.g., execute/bash/command) are in progress, + // surface a lightweight loading indicator and expose the Stop button. + try { + const id = (toolCallData.toolCallId || '').toString(); + const kind = (toolCallData.kind || '').toString().toLowerCase(); + const isExecKind = + kind === 'execute' || kind === 'bash' || kind === 'command'; + // CLI sometimes omits kind in tool_call_update payloads; fall back to + // whether we've already tracked this ID as an exec tool. + const wasTrackedExec = activeExecToolCallsRef.current.has(id); + const isExec = isExecKind || wasTrackedExec; + + if (!isExec || !id) { + break; + } + + if (status === 'pending' || status === 'in_progress') { + if (isExecKind) { + activeExecToolCallsRef.current.add(id); + + // Build a helpful hint from rawInput + const rawInput = toolCallData.rawInput; + let cmd = ''; + if (typeof rawInput === 'string') { + cmd = rawInput; + } else if (rawInput && typeof rawInput === 'object') { + const maybe = rawInput as { command?: string }; + cmd = maybe.command || ''; + } + const hint = cmd ? `Running: ${cmd}` : 'Running command...'; + handlers.messageHandling.setWaitingForResponse(hint); + } + } else if (status === 'completed' || status === 'failed') { + activeExecToolCallsRef.current.delete(id); + } + + // If no active exec tool remains, clear the waiting message. + if (activeExecToolCallsRef.current.size === 0) { + handlers.messageHandling.clearWaitingForResponse(); + } + } catch (_error) { + // Best-effort UI hint; ignore errors + } + break; + } + + case 'qwenSessionList': { + const sessions = + (message.data.sessions as Array>) || []; + const append = Boolean(message.data.append); + const nextCursor = message.data.nextCursor as number | undefined; + const hasMore = Boolean(message.data.hasMore); + + handlers.sessionManagement.setQwenSessions( + (prev: Array>) => + append ? [...prev, ...sessions] : sessions, + ); + handlers.sessionManagement.setNextCursor(nextCursor); + handlers.sessionManagement.setHasMore(hasMore); + handlers.sessionManagement.setIsLoading(false); + if ( + handlers.sessionManagement.currentSessionId && + sessions.length > 0 + ) { + const currentSession = sessions.find( + (s: Record) => + (s.id as string) === + handlers.sessionManagement.currentSessionId || + (s.sessionId as string) === + handlers.sessionManagement.currentSessionId, + ); + if (currentSession) { + const title = + (currentSession.title as string) || + (currentSession.name as string) || + 'Past Conversations'; + handlers.sessionManagement.setCurrentSessionTitle(title); + } + } + break; + } + + case 'qwenSessionSwitched': + handlers.sessionManagement.setShowSessionSelector(false); + if (message.data.sessionId) { + handlers.sessionManagement.setCurrentSessionId( + message.data.sessionId as string, + ); + } + if (message.data.session) { + const session = message.data.session as Record; + const title = + (session.title as string) || + (session.name as string) || + 'Past Conversations'; + handlers.sessionManagement.setCurrentSessionTitle(title); + // Update the VS Code webview tab title as well + vscode.postMessage({ type: 'updatePanelTitle', data: { title } }); + } + if (message.data.messages) { + handlers.messageHandling.setMessages(message.data.messages); + } else { + handlers.messageHandling.clearMessages(); + } + + // Clear any waiting message that might be displayed from previous session + handlers.messageHandling.clearWaitingForResponse(); + + // Clear active tool calls tracking + activeExecToolCallsRef.current.clear(); + + // Clear and restore tool calls if provided in session data + handlers.clearToolCalls(); + if (message.data.toolCalls && Array.isArray(message.data.toolCalls)) { + message.data.toolCalls.forEach((toolCall: unknown) => { + if (toolCall && typeof toolCall === 'object') { + handlers.handleToolCallUpdate(toolCall as ToolCallUpdate); + } + }); + } + + // Restore plan entries if provided + if ( + message.data.planEntries && + Array.isArray(message.data.planEntries) + ) { + handlers.setPlanEntries(message.data.planEntries); + } else { + handlers.setPlanEntries([]); + } + lastPlanSnapshotRef.current = null; + break; + + case 'conversationCleared': + handlers.messageHandling.clearMessages(); + handlers.clearToolCalls(); + handlers.sessionManagement.setCurrentSessionId(null); + handlers.sessionManagement.setCurrentSessionTitle( + 'Past Conversations', + ); + // Reset the VS Code tab title to default label + vscode.postMessage({ + type: 'updatePanelTitle', + data: { title: 'Qwen Code' }, + }); + lastPlanSnapshotRef.current = null; + break; + + case 'sessionTitleUpdated': { + const sessionId = message.data?.sessionId as string; + const title = message.data?.title as string; + if (sessionId && title) { + handlers.sessionManagement.setCurrentSessionId(sessionId); + handlers.sessionManagement.setCurrentSessionTitle(title); + // Ask extension host to reflect this title in the tab label + vscode.postMessage({ type: 'updatePanelTitle', data: { title } }); + } + break; + } + + case 'activeEditorChanged': { + const fileName = message.data?.fileName as string | null; + const filePath = message.data?.filePath as string | null; + const selection = message.data?.selection as { + startLine: number; + endLine: number; + } | null; + handlers.fileContext.setActiveFileName(fileName); + handlers.fileContext.setActiveFilePath(filePath); + handlers.fileContext.setActiveSelection(selection); + break; + } + + case 'fileAttached': { + const attachment = message.data as { + id: string; + type: string; + name: string; + value: string; + }; + + handlers.fileContext.addFileReference( + attachment.name, + attachment.value, + ); + + if (inputFieldRef.current) { + const currentText = inputFieldRef.current.textContent || ''; + const newText = currentText + ? `${currentText} @${attachment.name} ` + : `@${attachment.name} `; + inputFieldRef.current.textContent = newText; + setInputText(newText); + + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(inputFieldRef.current); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + } + break; + } + + case 'workspaceFiles': { + const files = message.data?.files as Array<{ + id: string; + label: string; + description: string; + path: string; + }>; + if (files) { + console.log('[WebView] Received workspaceFiles:', files.length); + handlers.fileContext.setWorkspaceFiles(files); + } + break; + } + + case 'saveSessionResponse': { + handlers.sessionManagement.handleSaveSessionResponse(message.data); + break; + } + + case 'cancelStreaming': + // Handle cancel streaming request from webview + handlers.messageHandling.endStreaming(); + handlers.messageHandling.clearWaitingForResponse(); + // Add interrupted message + handlers.messageHandling.addMessage({ + role: 'assistant', + content: 'Interrupted', + timestamp: Date.now(), + }); + break; + + default: + break; + } + }, + [inputFieldRef, setInputText, vscode, setEditMode], + ); + + useEffect(() => { + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [handleMessage]); +}; diff --git a/packages/vscode-ide-companion/src/webview/index.tsx b/packages/vscode-ide-companion/src/webview/index.tsx new file mode 100644 index 00000000..547dc3fc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/index.tsx @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import ReactDOM from 'react-dom/client'; +import { App } from './App.js'; + +// eslint-disable-next-line import/no-internal-modules +import './styles/tailwind.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/App.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/styles.css'; + +const container = document.getElementById('root'); +if (container) { + const root = ReactDOM.createRoot(container); + root.render(); +} diff --git a/packages/vscode-ide-companion/src/webview/styles/App.css b/packages/vscode-ide-companion/src/webview/styles/App.css new file mode 100644 index 00000000..e4ce12ea --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/App.css @@ -0,0 +1,602 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* =========================== + CSS Variables (Root Level) + =========================== */ +:root { + /* Qwen Brand Colors */ + --app-qwen-theme: #615fff; + --app-qwen-clay-button-orange: #4f46e5; + --app-qwen-ivory: #f5f5ff; + --app-qwen-slate: #141420; + --app-qwen-green: #6bcf7f; + + /* Spacing */ + --app-spacing-small: 4px; + --app-spacing-medium: 8px; + --app-spacing-large: 12px; + --app-spacing-xlarge: 16px; + + /* Border Radius */ + --corner-radius-small: 4px; + --corner-radius-medium: 6px; + --corner-radius-large: 8px; + + /* Typography */ + --app-monospace-font-family: var(--vscode-editor-font-family, monospace); + --app-monospace-font-size: var(--vscode-editor-font-size, 12px); + + /* Foreground & Background */ + --app-primary-foreground: var(--vscode-foreground); + --app-primary-background: var(--vscode-sideBar-background); + --app-primary-border-color: var(--vscode-sideBarActivityBarTop-border); + --app-secondary-foreground: var(--vscode-descriptionForeground); + + /* Input Colors */ + --app-input-foreground: var(--vscode-input-foreground); + --app-input-background: var(--vscode-input-background); + --app-input-border: var(--vscode-inlineChatInput-border); + --app-input-active-border: var(--vscode-inputOption-activeBorder); + --app-input-placeholder-foreground: var(--vscode-input-placeholderForeground); + --app-input-secondary-background: var(--vscode-menu-background); + /* Input Highlight (focus ring/border) */ + --app-input-highlight: var(--app-qwen-theme); + + /* Code Highlighting */ + --app-code-background: var( + --vscode-textCodeBlock-background, + rgba(0, 0, 0, 0.05) + ); + --app-link-foreground: var(--vscode-textLink-foreground, #007acc); + --app-link-active-foreground: var( + --vscode-textLink-activeForeground, + #005a9e + ); + + /* List Styles */ + --app-list-hover-background: var(--vscode-list-hoverBackground); + --app-list-active-background: var(--vscode-list-activeSelectionBackground); + --app-list-active-foreground: var(--vscode-list-activeSelectionForeground); + + /* Buttons */ + --app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground); + --app-button-foreground: var(--vscode-button-foreground); + --app-button-background: var(--vscode-button-background); + --app-button-hover-background: var(--vscode-button-hoverBackground); + + /* Border Transparency */ + --app-transparent-inner-border: rgba(255, 255, 255, 0.1); + + /* Header */ + --app-header-background: var(--vscode-sideBar-background); + + /* List Styles*/ + --app-list-padding: 0px; + --app-list-item-padding: 4px 8px; + --app-list-border-color: transparent; + --app-list-border-radius: 4px; + --app-list-gap: 2px; + + /* Menu Colors*/ + --app-menu-background: var(--vscode-menu-background); + --app-menu-border: var(--vscode-menu-border); + --app-menu-foreground: var(--vscode-menu-foreground); + --app-menu-selection-background: var(--vscode-menu-selectionBackground); + --app-menu-selection-foreground: var(--vscode-menu-selectionForeground); + + /* Modal */ + --app-modal-background: rgba(0, 0, 0, 0.75); + + /* Widget */ + --app-widget-border: var(--vscode-editorWidget-border); + --app-widget-shadow: var(--vscode-widget-shadow); +} + +/* Light Theme Overrides */ +.vscode-light { + --app-transparent-inner-border: rgba(0, 0, 0, 0.07); + /* Slightly different brand shade in light theme for better contrast */ + --app-input-highlight: var(--app-qwen-clay-button-orange); +} + +/* Icon SVG styles */ +.icon-svg { + display: block; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--vscode-chat-font-family, var(--vscode-font-family)); + background-color: var(--app-primary-background); + color: var(--app-primary-foreground); + overflow: hidden; + font-size: var(--vscode-chat-font-size, 13px); + padding: 0; +} + +/* Ensure tool call containers keep a consistent left indent even if Tailwind utilities are purged */ +.toolcall-container { + /* Consistent indent for tool call blocks */ + padding-left: 30px; +} + +.toolcall-card { + /* Consistent indent for card-style tool calls */ + padding-left: 30px; +} + +button { + color: var(--app-primary-foreground); + font-family: var(--vscode-chat-font-family); + font-size: var(--vscode-chat-font-size, 13px); +} + +/* =========================== + Main Chat Container + =========================== */ +.chat-container { + display: flex; + flex-direction: column; + height: 100vh; + width: 100%; + background-color: var(--app-primary-background); + color: var(--app-primary-foreground); +} + +/* Message list container: prevent browser scroll anchoring from fighting our manual pin-to-bottom logic */ +.chat-messages > * { + /* Disable overflow anchoring on individual items so the UA doesn't auto-adjust scroll */ + overflow-anchor: none; +} + +/* =========================== + Animations (used by message components) + =========================== */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +@keyframes typingPulse { + 0%, + 60%, + 100% { + transform: scale(0.7); + opacity: 0.6; + } + 30% { + transform: scale(1); + opacity: 1; + } +} + +/* =========================== + Input Form Styles + =========================== */ +.input-form { + display: flex; + background-color: var(--app-primary-background); + border-top: 1px solid var(--app-primary-border-color); +} + +.input-field { + flex: 1; + padding: 10px 12px; + background-color: var(--app-input-background); + color: var(--app-input-foreground); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + font-size: var(--vscode-chat-font-size, 13px); + font-family: var(--vscode-chat-font-family); + outline: none; + line-height: 1.5; +} + +.input-field:focus { + border-color: var(--app-qwen-theme); +} + +.input-field:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.input-field::placeholder { + color: var(--app-input-placeholder-foreground); +} + +.send-button { + padding: 10px 20px; + background-color: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + border: none; + border-radius: var(--corner-radius-small); + font-size: var(--vscode-chat-font-size, 13px); + font-weight: 500; + cursor: pointer; + transition: filter 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.send-button:hover:not(:disabled) { + filter: brightness(1.1); +} + +.send-button:active:not(:disabled) { + filter: brightness(0.9); +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Animation for in-progress status (used by pseudo bullets and spinners) */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.code-block { + font-family: var(--app-monospace-font-family); + font-size: var(--app-monospace-font-size); + background: var(--app-primary-background); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + padding: var(--app-spacing-medium); + overflow-x: auto; + margin: 4px 0 0 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} + +/* =========================== + Diff Display Styles + =========================== */ +.diff-display-container { + margin: 8px 0; + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-medium); + overflow: hidden; +} + +.diff-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--app-input-secondary-background); + border-bottom: 1px solid var(--app-input-border); +} + +.diff-file-path { + font-family: var(--app-monospace-font-family); + font-size: 13px; + color: var(--app-primary-foreground); +} + +.open-diff-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: transparent; + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + color: var(--app-primary-foreground); + cursor: pointer; + font-size: 12px; + transition: background-color 0.15s; +} + +.open-diff-button:hover { + background: var(--app-ghost-button-hover-background); +} + +.open-diff-button svg { + width: 16px; + height: 16px; +} + +.diff-section { + margin: 0; +} + +.diff-label { + padding: 8px 12px; + background: var(--app-primary-background); + border-bottom: 1px solid var(--app-input-border); + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + text-transform: uppercase; +} + +.diff-section .code-block { + border: none; + border-radius: 0; + margin: 0; + max-height: none; /* Remove height limit for diffs */ + overflow-y: visible; +} + +.diff-section .code-content { + display: block; +} + +/* =========================== + Permission Request Card Styles + =========================== */ +.permission-request-card { + background: var(--app-input-background); + border: 1px solid var(--app-qwen-theme); + border-radius: var(--corner-radius-medium); + margin: var(--app-spacing-medium) 0; + margin-bottom: var(--app-spacing-xlarge); + overflow: visible; + animation: fadeIn 0.2s ease-in; +} + +.permission-card-body { + padding: var(--app-spacing-large); + min-height: fit-content; + height: auto; +} + +.permission-header { + display: flex; + align-items: center; + gap: var(--app-spacing-large); + margin-bottom: var(--app-spacing-large); +} + +.permission-icon-wrapper { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(97, 95, 255, 0.1); + border-radius: var(--corner-radius-medium); + flex-shrink: 0; +} + +.permission-icon { + font-size: 20px; +} + +.permission-info { + flex: 1; + min-width: 0; +} + +.permission-title { + font-weight: 600; + color: var(--app-primary-foreground); + margin-bottom: 2px; +} + +.permission-subtitle { + font-size: 12px; + color: var(--app-secondary-foreground); +} + +.permission-command-section { + margin-bottom: var(--app-spacing-large); +} + +.permission-command-label { + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + margin-bottom: var(--app-spacing-small); + text-transform: uppercase; +} + +.permission-command-code { + display: block; + font-family: var(--app-monospace-font-family); + font-size: var(--app-monospace-font-size); + color: var(--app-primary-foreground); + background: var(--app-primary-background); + padding: var(--app-spacing-medium); + border-radius: var(--corner-radius-small); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.permission-locations-section { + margin-bottom: var(--app-spacing-large); +} + +.permission-locations-label { + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + margin-bottom: var(--app-spacing-small); + text-transform: uppercase; +} + +.permission-location-item { + display: flex; + align-items: center; + gap: var(--app-spacing-small); + padding: var(--app-spacing-small) 0; + font-size: 12px; +} + +.permission-location-icon { + flex-shrink: 0; +} + +.permission-location-path { + color: var(--app-primary-foreground); + font-family: var(--app-monospace-font-family); +} + +.permission-location-line { + color: var(--app-secondary-foreground); +} + +.permission-options-section { + margin-top: var(--app-spacing-large); +} + +.permission-options-label { + font-size: 12px; + font-weight: 500; + color: var(--app-primary-foreground); + margin-bottom: var(--app-spacing-medium); +} + +.permission-options-list { + display: flex; + flex-direction: column; + gap: var(--app-spacing-small); +} + +.permission-option { + display: flex; + align-items: center; + gap: var(--app-spacing-medium); + padding: var(--app-spacing-medium) var(--app-spacing-large); + background: var(--app-primary-background); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + cursor: pointer; + transition: all 0.15s ease; +} + +.permission-option:hover { + background: var(--app-list-hover-background); + border-color: var(--app-input-active-border); +} + +.permission-option.selected { + border-color: var(--app-qwen-theme); + background: rgba(97, 95, 255, 0.1); +} + +.permission-radio { + flex-shrink: 0; +} + +.permission-option-content { + display: flex; + align-items: center; + gap: var(--app-spacing-small); + flex: 1; +} + +.permission-option-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + background-color: var(--app-list-hover-background); + border-radius: 4px; + margin-right: 4px; +} + +.permission-option.selected .permission-option-number { + color: var(--app-qwen-ivory); + background-color: var(--app-qwen-theme); +} + +.permission-always-badge { + font-size: 12px; +} + +.permission-no-options { + text-align: center; + padding: var(--app-spacing-large); + color: var(--app-secondary-foreground); +} + +.permission-actions { + margin-top: var(--app-spacing-large); + display: flex; + justify-content: flex-end; +} + +.permission-confirm-button { + padding: var(--app-spacing-medium) var(--app-spacing-xlarge); + background: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + border: none; + border-radius: var(--corner-radius-small); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: filter 0.15s ease; +} + +.permission-confirm-button:hover:not(:disabled) { + filter: brightness(1.1); +} + +.permission-confirm-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.permission-success { + display: flex; + align-items: center; + justify-content: center; + gap: var(--app-spacing-medium); + padding: var(--app-spacing-large); + background: rgba(76, 175, 80, 0.1); + border-radius: var(--corner-radius-small); + margin-top: var(--app-spacing-large); +} + +.permission-success-icon { + color: #4caf50; + font-weight: bold; +} + +.permission-success-text { + color: #4caf50; + font-size: 13px; +} diff --git a/packages/vscode-ide-companion/src/webview/styles/styles.css b/packages/vscode-ide-companion/src/webview/styles/styles.css new file mode 100644 index 00000000..956912cb --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/styles.css @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* Import component styles */ +@import './timeline.css'; +@import '../components/messages/MarkdownRenderer/MarkdownRenderer.css'; + +/* =========================== + CSS Variables + =========================== */ +:root { + /* Colors */ + --app-primary-foreground: var(--vscode-foreground); + --app-secondary-foreground: var(--vscode-descriptionForeground); + --app-primary-border-color: var(--vscode-panel-border); + --app-input-placeholder-foreground: var(--vscode-input-placeholderForeground); + + /* Buttons */ + --app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground); + + /* Border Radius */ + --corner-radius-small: 6px; + + /* Header */ + --app-header-background: var(--vscode-sideBar-background); + + /* List Styles */ + --app-list-padding: 0px; + --app-list-item-padding: 4px 8px; + --app-list-border-color: transparent; + --app-list-border-radius: 4px; + --app-list-hover-background: var(--vscode-list-hoverBackground); + --app-list-active-background: var(--vscode-list-activeSelectionBackground); + --app-list-active-foreground: var(--vscode-list-activeSelectionForeground); + --app-list-gap: 2px; + + /* Menu Styles */ + --app-menu-background: var(--vscode-menu-background); + --app-menu-border: var(--vscode-menu-border); + --app-menu-foreground: var(--vscode-menu-foreground); + --app-menu-selection-background: var(--vscode-menu-selectionBackground); + --app-menu-selection-foreground: var(--vscode-menu-selectionForeground); + + /* Tool Call Styles */ + --app-tool-background: var(--vscode-editor-background); + --app-code-background: var(--vscode-textCodeBlock-background); + + /* Warning/Error Styles */ + --app-warning-background: var( + --vscode-editorWarning-background, + rgba(255, 204, 0, 0.1) + ); + --app-warning-border: var(--vscode-editorWarning-foreground, #ffcc00); + --app-warning-foreground: var(--vscode-editorWarning-foreground, #ffcc00); +} diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css new file mode 100644 index 00000000..46d803d5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* =========================== + Reusable Component Classes + =========================== */ +@layer components { + .btn-ghost { + @apply bg-transparent border border-transparent rounded cursor-pointer outline-none transition-colors duration-200; + color: var(--app-primary-foreground); + font-size: var(--vscode-chat-font-size, 13px); + border-radius: 4px; + } + + .btn-ghost:hover, + .btn-ghost:focus { + background: var(--app-ghost-button-hover-background); + } + + .btn-sm { + @apply p-small; + } + + .btn-md { + @apply py-small px-medium; + } + + .icon-sm { + @apply w-4 h-4; + } + + /* Composer: root container anchored to bottom*/ + .composer-root { + @apply absolute bottom-4 left-4 right-4 flex flex-col z-20; + } + + /* Composer: form wrapper */ + .composer-form { + @apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200; + background: var(--app-input-secondary-background); + border-color: var(--app-input-border); + color: var(--app-input-foreground); + } + .composer-form:focus-within { + /* match existing highlight behavior */ + border-color: var(--app-input-highlight); + box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%); + } + + /* Composer: input editable area */ + .composer-input { + /* Use plain CSS for font-family inheritance; Tailwind has no `font-inherit` utility */ + @apply flex-1 self-stretch py-2.5 px-3.5 outline-none overflow-y-auto relative select-text min-h-[1.5em] max-h-[200px] bg-transparent border-0 rounded-none overflow-x-hidden break-words whitespace-pre-wrap; + font-family: inherit; + font-size: var(--vscode-chat-font-size, 13px); + color: var(--app-input-foreground); + } + /* Show placeholder when truly empty OR when flagged as empty via data attribute. + The data attribute is needed because some browsers insert a
in + contentEditable, which breaks :empty matching. */ + .composer-input:empty:before, + .composer-input[data-empty="true"]::before { + content: attr(data-placeholder); + color: var(--app-input-placeholder-foreground); + pointer-events: none; + position: absolute; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - 28px); + } + .composer-input:focus { + outline: none; + } + .composer-input:disabled, + .composer-input[contenteditable="false"] { + color: #999; + cursor: not-allowed; + } + + /* Composer: actions row (more compact) */ + .composer-actions { + @apply flex items-center gap-1 min-w-0 z-[1]; + padding: 5px; + color: var(--app-secondary-foreground); + border-top: 0.5px solid var(--app-input-border); + } + + /* Text button (icon + label) */ + .btn-text-compact { + @apply inline-flex items-center gap-1 px-1 py-0.5 rounded-[2px] cursor-pointer appearance-none bg-transparent border-0 min-w-0 shrink text-[0.85em] transition-colors duration-150; + color: var(--app-secondary-foreground); + } + .btn-text-compact--primary { + color: var(--app-secondary-foreground); + /* color: var(--app-primary-foreground); */ + } + .btn-text-compact:hover { + background-color: var(--app-ghost-button-hover-background); + } + .btn-text-compact:active:not(:disabled) { + filter: brightness(1.1); + } + .btn-text-compact > svg { + height: 1em; + width: 1em; + flex-shrink: 0; + } + .btn-text-compact > span { + display: inline-block; + min-width: 0; + max-width: 200px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + vertical-align: middle; + } + + @media screen and (max-width: 300px) { + .btn-text-compact > svg { + display: none; + } + } + + /* Icon-only button, compact square (26x26) */ + .btn-icon-compact { + @apply inline-flex items-center justify-center w-[26px] h-[26px] p-0 rounded-small bg-transparent border border-transparent cursor-pointer shrink-0 transition-all duration-150; + color: var(--app-secondary-foreground); + } + .btn-icon-compact:hover { + background-color: var(--app-ghost-button-hover-background); + } + .btn-icon-compact > svg { + @apply w-4 h-4; + } + /* Active/primary state for icon button (e.g., Thinking on) */ + .btn-icon-compact--active { + background-color: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + } + .btn-icon-compact--active > svg { + stroke: var(--app-qwen-ivory); + fill: var(--app-qwen-ivory); + } + + .composer-overlay { + @apply absolute inset-0 rounded-large z-0; + background: var(--app-input-background); + } + + /* Optional: send button variant */ + .btn-send-compact { + @apply btn-icon-compact ml-auto hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed; + background-color: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + } + + /* + * File path styling inside tool call content + * Applies to: .toolcall-content-wrapper .file-link-path + * - Use monospace editor font + * - Slightly smaller size + * - Link color + * - Tighten top alignment and allow aggressive breaking for long paths + */ + .toolcall-content-wrapper .file-link-path { + /* Tailwind utilities where possible */ + @apply text-[0.85em] pt-px break-all min-w-0; + /* Not covered by Tailwind defaults: use CSS vars / properties */ + font-family: var(--app-monospace-font-family); + color: var(--app-link-color); + overflow-wrap: anywhere; + } +} + +/* =========================== + Utilities + =========================== */ +@layer utilities { + /* Multi-line clamp with ellipsis (Chromium-based webview supported) */ + .q-line-clamp-3 { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + } +} diff --git a/packages/vscode-ide-companion/src/webview/styles/timeline.css b/packages/vscode-ide-companion/src/webview/styles/timeline.css new file mode 100644 index 00000000..033e82d2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/timeline.css @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Unified timeline styles for tool calls and messages + */ + +/* ========================================== + ToolCallContainer timeline styles + ========================================== */ +.toolcall-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +/* ToolCallContainer timeline connector */ +.toolcall-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.toolcall-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.toolcall-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* ========================================== + AssistantMessage timeline styles + ========================================== */ +.assistant-message-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +/* AssistantMessage timeline connector */ +.assistant-message-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.assistant-message-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.assistant-message-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* ========================================== + Custom timeline styles for qwen-message message-item elements + ========================================== */ + +/* Default connector style - creates full-height connectors for all AI message items */ +.qwen-message.message-item:not(.user-message-container)::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); + z-index: 0; +} + +/* Single-item AI sequence (both a start and an end): hide the connector entirely */ +.qwen-message.message-item:not(.user-message-container):is( + :first-child, + .user-message-container + + .qwen-message.message-item:not(.user-message-container), + .chat-messages + > :not(.qwen-message.message-item) + + .qwen-message.message-item:not(.user-message-container) + ):is( + :has(+ .user-message-container), + :has(+ :not(.qwen-message.message-item)), + :last-child + )::after { + display: none; +} + +/* Handle the start of each AI message sequence - includes the first AI message in the entire message list and new AI messages interrupted by user messages */ +.qwen-message.message-item:not(.user-message-container):first-child::after, +.user-message-container + .qwen-message.message-item:not(.user-message-container)::after, +/* If the previous sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, or card-style tool calls), also treat as a new group start */ +.chat-messages > :not(.qwen-message.message-item) + + .qwen-message.message-item:not(.user-message-container)::after { + top: 15px; +} + +/* Handle the end of each AI message sequence */ +/* When the next sibling is a user message */ +.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after, +/* Or when the next sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, card-style tool calls, etc.) */ +.qwen-message.message-item:not(.user-message-container):has(+ :not(.qwen-message.message-item))::after, +/* When it's truly the last child element of the parent container */ +.qwen-message.message-item:not(.user-message-container):last-child::after { + /* Note: When setting both top and bottom, the height is (container height - top - bottom). + * Here we expect "15px spacing at the bottom", so bottom should be 15px (not calc(100% - 15px)). */ + top: 0; + bottom: calc(100% - 15px); +} + +.user-message-container:first-child { + margin-top: 0; +} + +.message-item { + padding: 8px 0; + width: 100%; + align-items: flex-start; + padding-left: 30px; + user-select: text; + position: relative; + padding-top: 8px; + padding-bottom: 8px; +} diff --git a/packages/vscode-ide-companion/src/webview/utils/diffStats.ts b/packages/vscode-ide-companion/src/webview/utils/diffStats.ts new file mode 100644 index 00000000..78918821 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/diffStats.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Diff statistics calculation tool + */ + +/** + * Diff statistics + */ +export interface DiffStats { + /** Number of added lines */ + added: number; + /** Number of removed lines */ + removed: number; + /** Number of changed lines (estimated value) */ + changed: number; + /** Total number of changed lines */ + total: number; +} + +/** + * Calculate diff statistics between two texts + * + * Using a simple line comparison algorithm (avoiding heavy-weight diff libraries) + * Algorithm explanation: + * 1. Split text by lines + * 2. Compare set differences of lines + * 3. Estimate changed lines (lines that appear in both added and removed) + * + * @param oldText Old text content + * @param newText New text content + * @returns Diff statistics + * + * @example + * ```typescript + * const stats = calculateDiffStats( + * "line1\nline2\nline3", + * "line1\nline2-modified\nline4" + * ); + * // { added: 2, removed: 2, changed: 1, total: 3 } + * ``` + */ +export function calculateDiffStats( + oldText: string | null | undefined, + newText: string | undefined, +): DiffStats { + // Handle null values + const oldContent = oldText || ''; + const newContent = newText || ''; + + // Split by lines + const oldLines = oldContent.split('\n').filter((line) => line.trim() !== ''); + const newLines = newContent.split('\n').filter((line) => line.trim() !== ''); + + // If one of them is empty, calculate directly + if (oldLines.length === 0) { + return { + added: newLines.length, + removed: 0, + changed: 0, + total: newLines.length, + }; + } + + if (newLines.length === 0) { + return { + added: 0, + removed: oldLines.length, + changed: 0, + total: oldLines.length, + }; + } + + // Use Set for fast lookup + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + + // Calculate added: lines in new but not in old + const addedLines = newLines.filter((line) => !oldSet.has(line)); + + // Calculate removed: lines in old but not in new + const removedLines = oldLines.filter((line) => !newSet.has(line)); + + // Estimate changes: take the minimum value (because changed lines are both deleted and added) + // This is a simplified estimation, actual diff algorithms would be more precise + const estimatedChanged = Math.min(addedLines.length, removedLines.length); + + const added = addedLines.length - estimatedChanged; + const removed = removedLines.length - estimatedChanged; + const changed = estimatedChanged; + + return { + added, + removed, + changed, + total: added + removed + changed, + }; +} + +/** + * Format diff statistics as human-readable text + * + * @param stats Diff statistics + * @returns Formatted text, e.g. "+5 -3 ~2" + * + * @example + * ```typescript + * formatDiffStats({ added: 5, removed: 3, changed: 2, total: 10 }); + * // "+5 -3 ~2" + * ``` + */ +export function formatDiffStats(stats: DiffStats): string { + const parts: string[] = []; + + if (stats.added > 0) { + parts.push(`+${stats.added}`); + } + + if (stats.removed > 0) { + parts.push(`-${stats.removed}`); + } + + if (stats.changed > 0) { + parts.push(`~${stats.changed}`); + } + + return parts.join(' ') || 'No changes'; +} + +/** + * Format detailed diff statistics + * + * @param stats Diff statistics + * @returns Detailed description text + * + * @example + * ```typescript + * formatDiffStatsDetailed({ added: 5, removed: 3, changed: 2, total: 10 }); + * // "+5 lines, -3 lines, ~2 lines" + * ``` + */ +export function formatDiffStatsDetailed(stats: DiffStats): string { + const parts: string[] = []; + + if (stats.added > 0) { + parts.push(`+${stats.added} ${stats.added === 1 ? 'line' : 'lines'}`); + } + + if (stats.removed > 0) { + parts.push(`-${stats.removed} ${stats.removed === 1 ? 'line' : 'lines'}`); + } + + if (stats.changed > 0) { + parts.push(`~${stats.changed} ${stats.changed === 1 ? 'line' : 'lines'}`); + } + + return parts.join(', ') || 'No changes'; +} diff --git a/packages/vscode-ide-companion/src/webview/utils/diffUtils.ts b/packages/vscode-ide-companion/src/webview/utils/diffUtils.ts new file mode 100644 index 00000000..dac37cf3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/diffUtils.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared utilities for handling diff operations in the webview + */ + +import type { VSCodeAPI } from '../hooks/useVSCode.js'; + +/** + * Handle opening a diff view for a file + * @param vscode Webview API instance + * @param path File path + * @param oldText Original content (left side) + * @param newText New content (right side) + */ +export const handleOpenDiff = ( + vscode: VSCodeAPI, + path: string | undefined, + oldText: string | null | undefined, + newText: string | undefined, +): void => { + if (path) { + vscode.postMessage({ + type: 'openDiff', + data: { path, oldText: oldText || '', newText: newText || '' }, + }); + } +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/resourceUrl.ts b/packages/vscode-ide-companion/src/webview/utils/resourceUrl.ts new file mode 100644 index 00000000..d55d4e14 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/resourceUrl.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Extend Window interface to include __EXTENSION_URI__ +declare global { + interface Window { + __EXTENSION_URI__?: string; + } +} + +/** + * Get the extension URI from the body data attribute or window global + * @returns Extension URI or undefined if not found + */ +function getExtensionUri(): string | undefined { + // First try to get from window (for backwards compatibility) + if (window.__EXTENSION_URI__) { + return window.__EXTENSION_URI__; + } + + // Then try to get from body data attribute (CSP-compliant method) + const bodyUri = document.body?.getAttribute('data-extension-uri'); + if (bodyUri) { + // Cache it in window for future use + window.__EXTENSION_URI__ = bodyUri; + return bodyUri; + } + + return undefined; +} + +/** + * Validate if URL is a secure VS Code webview resource URL + * Prevent XSS attacks + * + * @param url - URL to validate + * @returns Whether it is a secure URL + */ +function isValidWebviewUrl(url: string): boolean { + try { + // Valid protocols for VS Code webview resource URLs + const allowedProtocols = [ + 'vscode-webview-resource:', + 'https-vscode-webview-resource:', + 'vscode-file:', + 'https:', + ]; + + // Check if it starts with a valid protocol + return allowedProtocols.some((protocol) => url.startsWith(protocol)); + } catch { + return false; + } +} + +/** + * Generate a resource URL for webview access + * Similar to the pattern used in other VSCode extensions + * + * @param relativePath - Relative path from extension root (e.g., 'assets/icon.png') + * @returns Full webview-accessible URL (empty string if validation fails) + * + * @example + * ```tsx + * + * ``` + */ +export function generateResourceUrl(relativePath: string): string { + const extensionUri = getExtensionUri(); + + if (!extensionUri) { + console.warn('[resourceUrl] Extension URI not found in window or body'); + return ''; + } + + // Validate if extensionUri is a secure URL + if (!isValidWebviewUrl(extensionUri)) { + console.error( + '[resourceUrl] Invalid extension URI - possible security risk:', + extensionUri, + ); + return ''; + } + + // Remove leading slash if present + const cleanPath = relativePath.startsWith('/') + ? relativePath.slice(1) + : relativePath; + + // Ensure extension URI has trailing slash + const baseUri = extensionUri.endsWith('/') + ? extensionUri + : `${extensionUri}/`; + + const fullUrl = `${baseUri}${cleanPath}`; + + // Validate if the final generated URL is secure + if (!isValidWebviewUrl(fullUrl)) { + console.error('[resourceUrl] Generated URL failed validation:', fullUrl); + return ''; + } + + return fullUrl; +} + +/** + * Shorthand for generating icon URLs + * @param iconPath - Path relative to assets directory + */ +export function generateIconUrl(iconPath: string): string { + return generateResourceUrl(`assets/${iconPath}`); +} diff --git a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts new file mode 100644 index 00000000..e11f4bce --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface SessionGroup { + label: string; + sessions: Array>; +} + +/** + * Group sessions by date + * + * @param sessions - Array of session objects + * @returns Array of grouped sessions + */ +export const groupSessionsByDate = ( + sessions: Array>, +): SessionGroup[] => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const groups: { + [key: string]: Array>; + } = { + Today: [], + Yesterday: [], + 'This Week': [], + Older: [], + }; + + sessions.forEach((session) => { + const timestamp = + (session.lastUpdated as string) || (session.startTime as string) || ''; + if (!timestamp) { + groups['Older'].push(session); + return; + } + + const sessionDate = new Date(timestamp); + const sessionDay = new Date( + sessionDate.getFullYear(), + sessionDate.getMonth(), + sessionDate.getDate(), + ); + + if (sessionDay.getTime() === today.getTime()) { + groups['Today'].push(session); + } else if (sessionDay.getTime() === yesterday.getTime()) { + groups['Yesterday'].push(session); + } else if (sessionDay.getTime() > today.getTime() - 7 * 86400000) { + groups['This Week'].push(session); + } else { + groups['Older'].push(session); + } + }); + + return Object.entries(groups) + .filter(([, sessions]) => sessions.length > 0) + .map(([label, sessions]) => ({ label, sessions })); +}; + +/** + * Time ago formatter + * + * @param timestamp - ISO timestamp string + * @returns Formatted time string + */ +export const getTimeAgo = (timestamp: string): string => { + if (!timestamp) { + return ''; + } + const now = new Date().getTime(); + const then = new Date(timestamp).getTime(); + const diffMs = now - then; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) { + return 'now'; + } + if (diffMins < 60) { + return `${diffMins}m`; + } + if (diffHours < 24) { + return `${diffHours}h`; + } + if (diffDays === 1) { + return 'Yesterday'; + } + if (diffDays < 7) { + return `${diffDays}d`; + } + return new Date(timestamp).toLocaleDateString(); +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/tempFileManager.ts b/packages/vscode-ide-companion/src/webview/utils/tempFileManager.ts new file mode 100644 index 00000000..8ab17e30 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/tempFileManager.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Temporary file manager for creating and opening temporary files in webview + */ + +/** + * Creates a temporary file with the given content and opens it in VS Code + * @param content The content to write to the temporary file + * @param fileName Optional file name (without extension) + * @param fileExtension Optional file extension (defaults to .txt) + */ +export async function createAndOpenTempFile( + postMessage: (message: { + type: string; + data: Record; + }) => void, + content: string, + fileName: string = 'temp', + fileExtension: string = '.txt', +): Promise { + // Send message to VS Code extension to create and open temp file + postMessage({ + type: 'createAndOpenTempFile', + data: { + content, + fileName, + fileExtension, + }, + }); +} diff --git a/packages/vscode-ide-companion/src/webview/utils/webviewUtils.ts b/packages/vscode-ide-companion/src/webview/utils/webviewUtils.ts new file mode 100644 index 00000000..ed1b3135 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/webviewUtils.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extract filename from full path + * @param fsPath Full path of the file + * @returns Filename (without path) + */ +export function getFileName(fsPath: string): string { + // Use path.basename logic: find the part after the last path separator + const lastSlash = Math.max(fsPath.lastIndexOf('/'), fsPath.lastIndexOf('\\')); + return lastSlash >= 0 ? fsPath.substring(lastSlash + 1) : fsPath; +} + +/** + * HTML escape function to prevent XSS attacks + * Convert special characters to HTML entities + * @param text Text to escape + * @returns Escaped text + */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js new file mode 100644 index 00000000..956f785c --- /dev/null +++ b/packages/vscode-ide-companion/tailwind.config.js @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-env node */ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/webview/**/**/*.{js,jsx,ts,tsx}'], + theme: { + extend: { + keyframes: { + // CompletionMenu mount animation: fade in + slight upward slide + 'completion-menu-enter': { + '0%': { opacity: '0', transform: 'translateY(4px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + // Pulse animation for in-progress tool calls + 'pulse-slow': { + '0%, 100%': { opacity: '1' }, + '50%': { opacity: '0.5' }, + }, + // PermissionDrawer enter animation: slide up from bottom + 'slide-up': { + '0%': { transform: 'translateY(100%)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + }, + animation: { + 'completion-menu-enter': 'completion-menu-enter 150ms ease-out both', + 'pulse-slow': 'pulse-slow 1.5s ease-in-out infinite', + 'slide-up': 'slide-up 200ms ease-out both', + }, + colors: { + qwen: { + orange: '#615fff', + 'clay-orange': '#4f46e5', + ivory: '#f5f5ff', + slate: '#141420', + green: '#6bcf7f', + // Status colors used by toolcall components + success: '#74c991', + error: '#c74e39', + warning: '#e1c08d', + loading: 'var(--app-secondary-foreground)', + }, + }, + borderRadius: { + small: '4px', + medium: '6px', + large: '8px', + }, + spacing: { + small: '4px', + medium: '8px', + large: '12px', + xlarge: '16px', + }, + }, + }, + plugins: [], +}; diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json index 02a9b53f..538ec461 100644 --- a/packages/vscode-ide-companion/tsconfig.json +++ b/packages/vscode-ide-companion/tsconfig.json @@ -4,6 +4,8 @@ "moduleResolution": "NodeNext", "target": "ES2022", "lib": ["ES2022", "dom"], + "jsx": "react-jsx", + "jsxImportSource": "react", "sourceMap": true, "strict": true /* enable all strict type-checking options */ /* Additional Checks */ diff --git a/packages/vscode-ide-companion/vitest.config.ts b/packages/vscode-ide-companion/vitest.config.ts index 60f018c5..50c8ea3c 100644 --- a/packages/vscode-ide-companion/vitest.config.ts +++ b/packages/vscode-ide-companion/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'clover'], diff --git a/vitest.config.ts b/vitest.config.ts index 20ec6b90..88cded8b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'packages/cli', 'packages/core', 'packages/vscode-ide-companion', + 'packages/sdk-typescript', 'integration-tests', 'scripts', ],