diff --git a/.gcp/release-docker.yml b/.gcp/release-docker.yml index 53e78b088..57f9e5880 100644 --- a/.gcp/release-docker.yml +++ b/.gcp/release-docker.yml @@ -42,11 +42,11 @@ steps: args: - '-c' - |- - export GEMINI_SANDBOX_IMAGE_TAG=$$(cat /workspace/image_tag.txt) - echo "Using Docker image tag for build: $$GEMINI_SANDBOX_IMAGE_TAG" + export QWEN_SANDBOX_IMAGE_TAG=$$(cat /workspace/image_tag.txt) + echo "Using Docker image tag for build: $$QWEN_SANDBOX_IMAGE_TAG" npm run build:sandbox -- --output-file /workspace/final_image_uri.txt env: - - 'GEMINI_SANDBOX=$_CONTAINER_TOOL' + - 'QWEN_SANDBOX=$_CONTAINER_TOOL' # Step 8: Publish sandbox container image - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' @@ -61,7 +61,7 @@ steps: echo "Pushing sandbox image: $${FINAL_IMAGE_URI}" $_CONTAINER_TOOL push "$${FINAL_IMAGE_URI}" env: - - 'GEMINI_SANDBOX=$_CONTAINER_TOOL' + - 'QWEN_SANDBOX=$_CONTAINER_TOOL' options: defaultLogsBucketBehavior: 'REGIONAL_USER_OWNED_BUCKET' diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 70992d5c6..32d3aebe2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ -* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy +* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy @DragonnZhang # SDK TypeScript package changes require review from Mingholy packages/sdk-typescript/** @Mingholy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c410b6cdd..3608d961b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,23 @@ jobs: - name: 'Run sensitive keyword linter' run: 'node scripts/lint.js --sensitive-keywords' + - name: 'Build CLI package' + run: 'npm run build --workspace=packages/cli' + + - name: 'Generate settings schema' + run: 'npm run generate:settings-schema' + + - name: 'Check settings schema is up-to-date' + run: | + if [[ -n $(git status --porcelain packages/vscode-ide-companion/schemas/settings.schema.json) ]]; then + echo "❌ Error: settings.schema.json is out of date!" + echo " Please run: npm run generate:settings-schema" + echo " Then commit the updated schema file." + git diff packages/vscode-ide-companion/schemas/settings.schema.json + exit 1 + fi + echo "✅ Settings schema is up-to-date" + # # Test: Node # diff --git a/.github/workflows/release-vscode-companion.yml b/.github/workflows/release-vscode-companion.yml index ea02b01fb..101197529 100644 --- a/.github/workflows/release-vscode-companion.yml +++ b/.github/workflows/release-vscode-companion.yml @@ -223,7 +223,7 @@ jobs: npm --workspace=qwen-code-vscode-ide-companion run prepackage - name: 'Package VSIX (platform-specific)' - if: '${{ matrix.target != '''' }}' + if: "${{ matrix.target != '' }}" working-directory: 'packages/vscode-ide-companion' run: |- if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then @@ -236,7 +236,7 @@ jobs: shell: 'bash' - name: 'Package VSIX (universal)' - if: '${{ matrix.target == '''' }}' + if: "${{ matrix.target == '' }}" working-directory: 'packages/vscode-ide-companion' run: |- if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then @@ -251,7 +251,7 @@ jobs: - name: 'Upload VSIX Artifact' uses: 'actions/upload-artifact@v4' with: - name: 'vsix-${{ matrix.target || ''universal'' }}' + name: "vsix-${{ matrix.target || 'universal' }}" path: 'qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-*.vsix' if-no-files-found: 'error' @@ -292,7 +292,7 @@ jobs: npm install -g ovsx - name: 'Publish to Microsoft Marketplace' - if: '${{ needs.prepare.outputs.is_dry_run == ''false'' && needs.prepare.outputs.is_preview != ''true'' }}' + if: "${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_preview != 'true' }}" env: VSCE_PAT: '${{ secrets.VSCE_PAT }}' run: |- @@ -303,7 +303,7 @@ jobs: done - name: 'Publish to OpenVSX' - if: '${{ needs.prepare.outputs.is_dry_run == ''false'' }}' + if: "${{ needs.prepare.outputs.is_dry_run == 'false' }}" env: OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}' run: |- @@ -318,7 +318,7 @@ jobs: done - name: 'Upload all VSIXes as release artifacts (dry run)' - if: '${{ needs.prepare.outputs.is_dry_run == ''true'' }}' + if: "${{ needs.prepare.outputs.is_dry_run == 'true' }}" uses: 'actions/upload-artifact@v4' with: name: 'all-vsix-packages-${{ needs.prepare.outputs.release_version }}' diff --git a/.prettierignore b/.prettierignore index c9ae7e56a..5e9d79005 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,4 +18,5 @@ eslint.config.js gha-creds-*.json junit.xml Thumbs.db +packages/vscode-ide-companion/schemas/settings.schema.json packages/cli/src/services/insight/templates/insightTemplate.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 5d5db1d63..f9efaeef7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,7 @@ "cwd": "${workspaceFolder}", "console": "integratedTerminal", "env": { - "GEMINI_SANDBOX": "false" + "QWEN_SANDBOX": "false" } }, { @@ -86,7 +86,7 @@ "cwd": "${workspaceFolder}", "console": "integratedTerminal", "env": { - "GEMINI_SANDBOX": "false" + "QWEN_SANDBOX": "false" } }, { @@ -107,7 +107,7 @@ "internalConsoleOptions": "neverOpen", "skipFiles": ["/**"], "env": { - "GEMINI_SANDBOX": "false" + "QWEN_SANDBOX": "false" } }, { diff --git a/docs/developers/examples/proxy-script.md b/docs/developers/examples/proxy-script.md index 78299001b..cb8cfffd6 100644 --- a/docs/developers/examples/proxy-script.md +++ b/docs/developers/examples/proxy-script.md @@ -1,6 +1,6 @@ # Example Proxy Script -The following is an example of a proxy script that can be used with the `GEMINI_SANDBOX_PROXY_COMMAND` environment variable. This script only allows `HTTPS` connections to `example.com:443` and declines all other requests. +The following is an example of a proxy script that can be used with the `QWEN_SANDBOX_PROXY_COMMAND` environment variable. This script only allows `HTTPS` connections to `example.com:443` and declines all other requests. ```javascript #!/usr/bin/env node @@ -12,7 +12,7 @@ The following is an example of a proxy script that can be used with the `GEMINI_ */ // Example proxy server that listens on :::8877 and only allows HTTPS connections to example.com. -// Set `GEMINI_SANDBOX_PROXY_COMMAND=scripts/example-proxy.js` to run proxy alongside sandbox +// Set `QWEN_SANDBOX_PROXY_COMMAND=scripts/example-proxy.js` to run proxy alongside sandbox // Test via `curl https://example.com` inside sandbox (in shell mode or via shell tool) import http from 'node:http'; diff --git a/docs/developers/tools/mcp-server.md b/docs/developers/tools/mcp-server.md index 8d48970a7..ecf09a580 100644 --- a/docs/developers/tools/mcp-server.md +++ b/docs/developers/tools/mcp-server.md @@ -834,23 +834,25 @@ qwen mcp add --transport sse sse-server https://api.example.com/sse/ qwen mcp add --transport sse secure-sse https://api.example.com/sse/ --header "Authorization: Bearer abc123" ``` -### Listing Servers (`qwen mcp list`) +### Managing Servers (`qwen mcp`) -To view all MCP servers currently configured, use the `list` command. It displays each server's name, configuration details, and connection status. +To view and manage all MCP servers currently configured, use the `manage` command or simply `qwen mcp`. This opens an interactive TUI dialog where you can: + +- View all MCP servers with their connection status +- Enable/disable servers +- Reconnect to disconnected servers +- View tools and prompts provided by each server +- View server logs **Command:** ```bash -qwen mcp list +qwen mcp +# or +qwen mcp manage ``` -**Example Output:** - -```sh -✓ stdio-server: command: python3 server.py (stdio) - Connected -✓ http-server: https://api.example.com/mcp (http) - Connected -✗ sse-server: https://api.example.com/sse (sse) - Disconnected -``` +The management dialog provides a visual interface showing each server's name, configuration details, connection status, and available tools/prompts. ### Removing a Server (`qwen mcp remove`) diff --git a/docs/developers/tools/sandbox.md b/docs/developers/tools/sandbox.md index de3e23716..6c4959401 100644 --- a/docs/developers/tools/sandbox.md +++ b/docs/developers/tools/sandbox.md @@ -60,7 +60,7 @@ RUN apt-get update && apt-get install -y \ #### 4、Create the first sandbox image under the root directory of your project ```bash -GEMINI_SANDBOX=docker BUILD_SANDBOX=1 qwen -s +QWEN_SANDBOX=docker BUILD_SANDBOX=1 qwen -s # Observe whether the sandbox version of the tool you launched is consistent with the version of your custom image. If they are consistent, the startup will be successful ``` diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index edca4aedd..c648a231f 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -391,22 +391,22 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe ### Environment Variables Table -| Variable | Description | Notes | -| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GEMINI_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. | -| `GEMINI_TELEMETRY_TARGET` | Sets the telemetry target (`local` or `gcp`). | Overrides the `telemetry.target` setting. | -| `GEMINI_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | -| `GEMINI_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. | -| `GEMINI_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | -| `GEMINI_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to when the target is `local`. | Overrides the `telemetry.outfile` setting. | -| `GEMINI_TELEMETRY_USE_COLLECTOR` | Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. | Overrides the `telemetry.useCollector` setting. | -| `GEMINI_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | -| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | -| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | -| `NO_COLOR` | Set to any value to disable all color output in the CLI. | | -| `CLI_TITLE` | Set to a string to customize the title of the CLI. | | -| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | -| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | +| Variable | Description | Notes | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QWEN_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. | +| `QWEN_TELEMETRY_TARGET` | Sets the telemetry target (`local` or `gcp`). | Overrides the `telemetry.target` setting. | +| `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | +| `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. | +| `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | +| `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to when the target is `local`. | Overrides the `telemetry.outfile` setting. | +| `QWEN_TELEMETRY_USE_COLLECTOR` | Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. | Overrides the `telemetry.useCollector` setting. | +| `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | +| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | +| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | +| `NO_COLOR` | Set to any value to disable all color output in the CLI. | | +| `CLI_TITLE` | Set to a string to customize the title of the CLI. | | +| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | +| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | ## Command-Line Arguments @@ -509,7 +509,7 @@ Qwen Code can execute potentially unsafe operations (like shell commands and fil [Sandbox](../features/sandbox) is disabled by default, but you can enable it in a few ways: - Using `--sandbox` or `-s` flag. -- Setting `GEMINI_SANDBOX` environment variable. +- Setting `QWEN_SANDBOX` environment variable. - Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default. By default, it uses a pre-built `qwen-code-sandbox` Docker image. diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 1d7160768..0efb25b7c 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -12,17 +12,11 @@ We offer a suite of extension management tools using both `qwen extensions` CLI You can manage extensions at runtime within the interactive CLI using `/extensions` slash commands. These commands support hot-reloading, meaning changes take effect immediately without restarting the application. -| Command | Description | -| ------------------------------------------------------ | ----------------------------------------------------------------- | -| `/extensions` or `/extensions list` | List all installed extensions with their status | -| `/extensions install ` | Install an extension from a git URL, local path, or marketplace | -| `/extensions uninstall ` | Uninstall an extension | -| `/extensions enable --scope ` | Enable an extension | -| `/extensions disable --scope ` | Disable an extension | -| `/extensions update ` | Update a specific extension | -| `/extensions update --all` | Update all extensions with available updates | -| `/extensions detail ` | Show details of an extension | -| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser | +| Command | Description | +| ------------------------------------- | ----------------------------------------------------------------- | +| `/extensions` or `/extensions manage` | Manage all installed extensions | +| `/extensions install ` | Install an extension from a git URL, local path, or marketplace | +| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser | ### CLI Extension Management diff --git a/docs/users/features/mcp.md b/docs/users/features/mcp.md index 2b123c12c..534e1195c 100644 --- a/docs/users/features/mcp.md +++ b/docs/users/features/mcp.md @@ -30,10 +30,10 @@ Qwen Code loads MCP servers from `mcpServers` in your `settings.json`. You can c qwen mcp add --transport http my-server http://localhost:3000/mcp ``` -2. Verify it shows up: +2. Open MCP management dialog to view and manage servers: ```bash -qwen mcp list +qwen mcp ``` 3. Restart Qwen Code in the same project (or start it if it wasn’t running yet), then ask the model to use tools from that server. @@ -274,12 +274,6 @@ qwen mcp add [options] [args...] | `--include-tools` | A comma-separated list of tools to include. | all tools included | `--include-tools mytool,othertool` | | `--exclude-tools` | A comma-separated list of tools to exclude. | none | `--exclude-tools mytool` | -#### Listing servers (`qwen mcp list`) - -```bash -qwen mcp list -``` - #### Removing a server (`qwen mcp remove`) ```bash diff --git a/docs/users/features/sandbox.md b/docs/users/features/sandbox.md index 23ea89fe7..72005f959 100644 --- a/docs/users/features/sandbox.md +++ b/docs/users/features/sandbox.md @@ -29,7 +29,7 @@ The benefits of sandboxing include: > [!note] > -> **Naming note:** Some sandbox-related environment variables still use the `GEMINI_*` prefix for backwards compatibility. +> **Naming note:** Some sandbox-related environment variables may have used the `GEMINI_*` prefix historically. All new environment variables use the `QWEN_*` prefix. ## Sandboxing methods @@ -68,7 +68,7 @@ The container sandbox mounts your workspace and your `~/.qwen` directory into th qwen -s -p "analyze the code structure" # Or enable sandboxing for your shell session (recommended for CI / scripts) -export GEMINI_SANDBOX=true # true auto-picks a provider (see notes below) +export QWEN_SANDBOX=true # true auto-picks a provider (see notes below) qwen -p "run the test suite" # Configure in settings.json @@ -83,26 +83,26 @@ qwen -p "run the test suite" > > **Provider selection notes:** > -> - On **macOS**, `GEMINI_SANDBOX=true` typically selects `sandbox-exec` (Seatbelt) if available. -> - On **Linux/Windows**, `GEMINI_SANDBOX=true` requires `docker` or `podman` to be installed. -> - To force a provider, set `GEMINI_SANDBOX=docker|podman|sandbox-exec`. +> - On **macOS**, `QWEN_SANDBOX=true` typically selects `sandbox-exec` (Seatbelt) if available. +> - On **Linux/Windows**, `QWEN_SANDBOX=true` requires `docker` or `podman` to be installed. +> - To force a provider, set `QWEN_SANDBOX=docker|podman|sandbox-exec`. ## Configuration ### Enable sandboxing (in order of precedence) -1. **Environment variable**: `GEMINI_SANDBOX=true|false|docker|podman|sandbox-exec` +1. **Environment variable**: `QWEN_SANDBOX=true|false|docker|podman|sandbox-exec` 2. **Command flag / argument**: `-s`, `--sandbox`, or `--sandbox=` 3. **Settings file**: `tools.sandbox` in your `settings.json` (e.g., `{"tools": {"sandbox": true}}`). > [!important] > -> If `GEMINI_SANDBOX` is set, it **overrides** the CLI flag and `settings.json`. +> If `QWEN_SANDBOX` is set, it **overrides** the CLI flag and `settings.json`. ### Configure the sandbox image (Docker/Podman) - **CLI flag**: `--sandbox-image ` -- **Environment variable**: `GEMINI_SANDBOX_IMAGE=` +- **Environment variable**: `QWEN_SANDBOX_IMAGE=` If you don’t set either, Qwen Code uses the default image configured in the CLI package (for example `ghcr.io/qwenlm/qwen-code:`). @@ -150,7 +150,7 @@ export SANDBOX_FLAGS="--flag1 --flag2=value" If you want to restrict outbound network access to an allowlist, you can run a local proxy alongside the sandbox: -- Set `GEMINI_SANDBOX_PROXY_COMMAND=` +- Set `QWEN_SANDBOX_PROXY_COMMAND=` - The command must start a proxy server that listens on `:::8877` This is especially useful with `*-proxied` Seatbelt profiles. diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index a0f7a2629..0f7770e6c 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -14,8 +14,8 @@ import { TestRig } from './test-helper.js'; const REQUEST_TIMEOUT_MS = 60_000; const INITIAL_PROMPT = 'Create a quick note (smoke test).'; const IS_SANDBOX = - process.env['GEMINI_SANDBOX'] && - process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false'; + process.env['QWEN_SANDBOX'] && + process.env['QWEN_SANDBOX']!.toLowerCase() !== 'false'; type PendingRequest = { resolve: (value: unknown) => void; @@ -45,6 +45,7 @@ type SessionUpdateNotification = { text?: string; }; modeId?: string; + currentModeId?: string; _meta?: { usage?: UsageMetadata; }; @@ -313,7 +314,7 @@ function setupAcpTest( } }); - it('returns modes on initialize and allows setting mode and model', async () => { + it('initializes and allows setting mode', async () => { const rig = new TestRig(); rig.setup('acp mode and model'); @@ -326,41 +327,11 @@ function setupAcpTest( clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, }, - })) as { - protocolVersion: number; - modes: { - currentModeId: string; - availableModes: Array<{ - id: string; - name: string; - description: string; - }>; - }; - }; + })) as { protocolVersion: number }; expect(initResult).toBeDefined(); expect(initResult.protocolVersion).toBe(1); - // Verify modes data is present - expect(initResult.modes).toBeDefined(); - expect(initResult.modes.currentModeId).toBeDefined(); - expect(Array.isArray(initResult.modes.availableModes)).toBe(true); - expect(initResult.modes.availableModes.length).toBeGreaterThan(0); - - // Verify available modes have expected structure - const modeIds = initResult.modes.availableModes.map((m) => m.id); - expect(modeIds).toContain('default'); - expect(modeIds).toContain('yolo'); - expect(modeIds).toContain('auto-edit'); - expect(modeIds).toContain('plan'); - - // Verify each mode has required fields - for (const mode of initResult.modes.availableModes) { - expect(mode.id).toBeTruthy(); - expect(mode.name).toBeTruthy(); - expect(mode.description).toBeTruthy(); - } - // Test 2: Authenticate await sendRequest('authenticate', { methodId: 'openai' }); @@ -381,37 +352,22 @@ function setupAcpTest( const setModeResult = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'yolo', - })) as { modeId: string }; - expect(setModeResult).toBeDefined(); - expect(setModeResult.modeId).toBe('yolo'); + })) as unknown; + expect(setModeResult).toEqual({}); // Test 5: Set approval mode to 'auto-edit' const setModeResult2 = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'auto-edit', - })) as { modeId: string }; - expect(setModeResult2).toBeDefined(); - expect(setModeResult2.modeId).toBe('auto-edit'); + })) as unknown; + expect(setModeResult2).toEqual({}); // Test 6: Set approval mode back to 'default' const setModeResult3 = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'default', - })) as { modeId: string }; - expect(setModeResult3).toBeDefined(); - expect(setModeResult3.modeId).toBe('default'); - - // Test 7: Set model using openai model instead of first available model (index=0) which could be qwen-oauth requiring login - const openaiModel = newSession.models.availableModels.find((model) => - model.modelId.includes('openai'), - ); - expect(openaiModel).toBeDefined(); - const setModelResult = (await sendRequest('session/set_model', { - sessionId: newSession.sessionId, - modelId: openaiModel!.modelId, - })) as { modelId: string }; - expect(setModelResult).toBeDefined(); - expect(setModelResult.modelId).toBeTruthy(); + })) as unknown; + expect(setModeResult3).toEqual({}); } catch (e) { if (stderr.length) { console.error('Agent stderr:', stderr.join('')); @@ -422,7 +378,7 @@ function setupAcpTest( } }); - it('includes authMethods in error data when auth is required', async () => { + it('returns internal error details when model auth is required', async () => { const rig = new TestRig(); rig.setup('acp auth methods in error data'); @@ -447,18 +403,23 @@ function setupAcpTest( }; }; - // Attempt to set the first model (which might be qwen-oauth requiring login) without authenticating - // This should trigger an auth error with authMethods in the response - const firstModel = newSession.models.availableModels[0]; + // Choose a qwen-oauth model to trigger auth-required path deterministically. + const qwenOauthModel = newSession.models.availableModels.find((model) => + model.modelId.includes('qwen-oauth'), + ); + expect(qwenOauthModel).toBeDefined(); await expect( - sendRequest('session/set_model', { + sendRequest('session/set_config_option', { sessionId: newSession.sessionId, - modelId: firstModel.modelId, + configId: 'model', + value: qwenOauthModel!.modelId, }), ).rejects.toMatchObject({ response: { + code: -32603, + message: 'Internal error', data: { - authMethods: expect.any(Array), + details: expect.any(String), }, }, }); @@ -606,10 +567,7 @@ function setupAcpTest( ).rejects.toMatchObject({ response: { code: -32602, - message: 'Invalid params', - data: { - details: 'Unsupported configId: invalid_config', - }, + message: 'Invalid params: Unsupported configId: invalid_config', }, }); } catch (e) { @@ -726,8 +684,8 @@ function setupAcpTest( const setModeResult = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'plan', - })) as { modeId: string }; - expect(setModeResult.modeId).toBe('plan'); + })) as unknown; + expect(setModeResult).toEqual({}); // Send a prompt that should trigger the LLM to call exit_plan_mode // The prompt is designed to trigger planning behavior @@ -780,9 +738,9 @@ function setupAcpTest( // Verify mode update structure const modeUpdate = modeUpdateNotifications[0]; expect(modeUpdate.sessionId).toBe(newSession.sessionId); - expect(modeUpdate.update?.modeId).toBeDefined(); + expect(modeUpdate.update?.currentModeId).toBeDefined(); // Mode should be auto-edit since we approved with proceed_always - expect(modeUpdate.update?.modeId).toBe('auto-edit'); + expect(modeUpdate.update?.currentModeId).toBe('auto-edit'); } // Note: If the LLM didn't call exit_plan_mode, that's acceptable @@ -834,8 +792,8 @@ function setupAcpTest( const setModeResult = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'plan', - })) as { modeId: string }; - expect(setModeResult.modeId).toBe('plan'); + })) as unknown; + expect(setModeResult).toEqual({}); // Try to create a file - this should be blocked by plan mode const promptResult = await sendRequest('session/prompt', { diff --git a/integration-tests/concurrent-runner/render-chat-temp.html b/integration-tests/concurrent-runner/render-chat-temp.html index 5f33eaf69..bc6d01b61 100644 --- a/integration-tests/concurrent-runner/render-chat-temp.html +++ b/integration-tests/concurrent-runner/render-chat-temp.html @@ -1,277 +1,291 @@ - + + + + + Qwen Code Chat Export + + + - - - - Qwen Code Chat Export - - - + + - window.ReactJSXRuntime = jsxRuntime; - window['react/jsx-runtime'] = jsxRuntime; - window['react/jsx-dev-runtime'] = jsxRuntime; - + + - - + + - - + - - -
-
-
-

Qwen Code Export

-
-
-
- Session Id - - + /* Scrollbar styling */ + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track { + background: var(--bg-primary); + } + + ::-webkit-scrollbar-thumb { + background: var(--bg-secondary); + border-radius: 5px; + border: 2px solid var(--bg-primary); + } + + ::-webkit-scrollbar-thumb:hover { + background: #52525b; + } + + /* Responsive adjustments */ + @media (max-width: 768px) { + .chat-container { + max-width: 100%; + padding: 20px 16px; + } + + .header { + padding: 12px 16px; + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .header-left { + width: 100%; + justify-content: space-between; + } + + .meta { + width: 100%; + flex-direction: column; + gap: 6px; + } + } + + @media (max-width: 480px) { + .chat-container { + padding: 16px 12px; + } + } + + + + +
+
+
+

Qwen Code Export

-
- Export Time - - +
+
+ Session Id + - +
+
+ Export Time + - +
+ +
-
-
+ - - - - + // Render the ChatViewer component without Babel + const rootElementNoBabel = document.getElementById('chat-root-no-babel'); + // Create the ChatViewer element wrapped with PlatformProvider using React.createElement (no JSX) + const ChatAppNoBabel = React.createElement( + PlatformProvider, + { value: platformContext }, + React.createElement(ChatViewer, { + messages, + autoScroll: false, + theme: 'dark', + }), + ); + + ReactDOM.render(ChatAppNoBabel, rootElementNoBabel); + + diff --git a/integration-tests/hook-integration/hooks.test.ts b/integration-tests/hook-integration/hooks.test.ts new file mode 100644 index 000000000..a6c873620 --- /dev/null +++ b/integration-tests/hook-integration/hooks.test.ts @@ -0,0 +1,1995 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig, validateModelOutput } from '../test-helper.js'; + +/** + * Hooks System Integration Tests + * + * Tests for complete hook system flow including: + * - UserPromptSubmit hooks: Triggered before prompt is sent to LLM + * - Stop hooks: Triggered when agent is about to stop + * + * Test categories: + * - Single hook scenarios (allow, block, modify, context, etc.) + * - Multiple hooks scenarios (parallel, sequential, mixed) + * - Error handling (timeout, missing command, exit codes) + * - Combined hooks (multiple hook types in same session) + */ +describe('Hooks System Integration', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + // ========================================================================== + // UserPromptSubmit Hooks + // Triggered before user prompt is sent to the LLM for processing + // ========================================================================== + describe('UserPromptSubmit Hooks', () => { + describe('Allow Decision', () => { + it('should allow prompt when hook returns allow decision', async () => { + const hookScript = + 'echo \'{"decision": "allow", "reason": "approved by hook"}\''; + + await rig.setup('ups-allow-decision', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: hookScript, + name: 'ups-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should allow tool execution with allow decision and verify tool was called', async () => { + const hookScript = + 'echo \'{"decision": "allow", "reason": "Tool execution approved"}\''; + + await rig.setup('ups-allow-tool', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: hookScript, + name: 'ups-allow-tool-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + await rig.run('Create a file test.txt with content "hello"'); + + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + + const fileContent = rig.readFile('test.txt'); + expect(fileContent).toContain('hello'); + }); + }); + + describe('Block Decision', () => { + it('should block prompt when hook returns block decision', async () => { + const blockScript = + 'echo \'{"decision": "block", "reason": "Prompt blocked by security policy"}\''; + + await rig.setup('ups-block-decision', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: blockScript, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When UserPromptSubmit hook blocks, CLI exits with non-zero code + // and rig.run() throws an error + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + + it('should block tool execution when hook returns block and verify no tool was called', async () => { + const blockScript = + 'echo \'{"decision": "block", "reason": "File writing blocked by security policy"}\''; + + await rig.setup('ups-block-tool', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: blockScript, + name: 'ups-block-tool-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When UserPromptSubmit hook blocks, CLI exits with non-zero code + await expect( + rig.run('Create a file test.txt with "hello"'), + ).rejects.toThrow(/block/i); + + // Tool should not be called due to blocking hook + const toolLogs = rig.readToolLogs(); + const writeFileCalls = toolLogs.filter( + (t) => + t.toolRequest.name === 'write_file' && + t.toolRequest.success === true, + ); + expect(writeFileCalls).toHaveLength(0); + }); + }); + + describe('Modify Prompt', () => { + it('should use modified prompt when hook provides modification', async () => { + const modifyScript = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "modifiedPrompt": "Modified prompt content", "additionalContext": "Context added by hook"}}\''; + + await rig.setup('ups-modify-prompt', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: modifyScript, + name: 'ups-modify-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say test'); + expect(result).toBeDefined(); + }); + }); + + describe('Additional Context', () => { + it('should include additional context in response when hook provides it', async () => { + const contextScript = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Extra context information from hook"}}\''; + + await rig.setup('ups-add-context', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: contextScript, + name: 'ups-context-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('What is 1+1?'); + expect(result).toBeDefined(); + }); + }); + + describe('Timeout Handling', () => { + it('should continue execution when hook times out', async () => { + await rig.setup('ups-timeout', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'ups-timeout-hook', + timeout: 1000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say timeout test'); + // Should continue despite timeout + expect(result).toBeDefined(); + }); + }); + + describe('Error Handling', () => { + it('should continue execution when hook exits with non-blocking error (exit code 1)', async () => { + await rig.setup('ups-nonblocking-error', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'echo warning && exit 1', + name: 'ups-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say error test'); + // Non-blocking error should not prevent execution + expect(result).toBeDefined(); + }); + + it('should block execution when hook exits with blocking error (exit code 2)', async () => { + await rig.setup('ups-blocking-error', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'echo "Critical security error" >&2 && exit 2', + name: 'ups-blocking-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // Exit code 2 is a blocking error, so CLI should throw an error + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + + it('should continue execution when hook command is empty', async () => { + await rig.setup('ups-missing-command', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '', + name: 'ups-missing-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // Empty command is ignored, execution continues normally + const result = await rig.run('Say missing test'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('Input Format Validation', () => { + it('should receive properly formatted input when hook is called', async () => { + const inputValidationScript = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "Valid input format"}}\''; + + await rig.setup('ups-correct-input', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: inputValidationScript, + name: 'ups-input-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say input test'); + validateModelOutput(result, 'input test', 'UPS: correct input'); + }); + }); + + describe('System Message', () => { + it('should include system message in response when hook provides it', async () => { + const systemMsgScript = + 'echo \'{"decision": "allow", "systemMessage": "This is a system message from hook"}\''; + + await rig.setup('ups-system-message', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: systemMsgScript, + name: 'ups-system-msg-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say system message'); + expect(result).toBeDefined(); + }); + }); + + describe('Multiple UserPromptSubmit Hooks', () => { + it('should block when one of multiple parallel hooks returns block', async () => { + const allowScript = + 'echo \'{"decision": "allow", "reason": "Allowed"}\''; + const blockScript = + 'echo \'{"decision": "block", "reason": "Blocked by security policy"}\''; + + await rig.setup('ups-multi-one-blocks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: allowScript, + name: 'ups-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: blockScript, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When any hook blocks, CLI should throw an error + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + + it('should block when first sequential hook returns block', async () => { + // Note: Sequential hooks execute ALL hooks before aggregating results. + // Even if the first hook returns block, the second hook still runs. + // The final aggregated result will be block if any hook returns block. + // For UserPromptSubmit, a block decision should cause CLI to throw an error. + const blockScript = + 'echo \'{"decision": "block", "reason": "First hook blocks"}\''; + + await rig.setup('ups-seq-first-blocks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: blockScript, + name: 'ups-seq-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // Single sequential hook with block decision should throw an error + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + + it('should block when second sequential hook returns block', async () => { + // Note: Sequential hooks execute ALL hooks before aggregating results. + // The first hook allows, but the second hook blocks. + // The final aggregated result will be block (OR logic: any block = block). + const allowScript = + 'echo \'{"decision": "allow", "reason": "First allows"}\''; + const blockScript = + 'echo \'{"decision": "block", "reason": "Second hook blocks"}\''; + + await rig.setup('ups-seq-second-blocks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: allowScript, + name: 'ups-seq-first-allow', + timeout: 5000, + }, + { + type: 'command', + command: blockScript, + name: 'ups-seq-second-block', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // Second hook blocks, CLI should throw an error + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + + it('should handle multiple hooks all returning allow', async () => { + const allow1Script = + 'echo \'{"decision": "allow", "reason": "First allows"}\''; + const allow2Script = + 'echo \'{"decision": "allow", "reason": "Second allows"}\''; + const allow3Script = + 'echo \'{"decision": "allow", "reason": "Third allows"}\''; + + await rig.setup('ups-multi-all-allow', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: allow1Script, + name: 'ups-allow-1', + timeout: 5000, + }, + { + type: 'command', + command: allow2Script, + name: 'ups-allow-2', + timeout: 5000, + }, + { + type: 'command', + command: allow3Script, + name: 'ups-allow-3', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + // All hooks allow, should complete normally + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle multiple hooks all returning block', async () => { + const block1Script = + 'echo \'{"decision": "block", "reason": "First blocks"}\''; + const block2Script = + 'echo \'{"decision": "block", "reason": "Second blocks"}\''; + + await rig.setup('ups-multi-all-block', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: block1Script, + name: 'ups-block-1', + timeout: 5000, + }, + { + type: 'command', + command: block2Script, + name: 'ups-block-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // All hooks block, CLI should throw an error + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + + it('should concatenate additional context from multiple hooks', async () => { + const context1Script = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context from hook 1"}}\''; + const context2Script = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context from hook 2"}}\''; + + await rig.setup('ups-multi-context', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: context1Script, + name: 'ups-context-1', + timeout: 5000, + }, + { + type: 'command', + command: context2Script, + name: 'ups-context-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + + it('should handle hook with error alongside blocking hook', async () => { + const blockScript = + 'echo \'{"decision": "block", "reason": "Blocked"}\''; + + await rig.setup('ups-error-with-block', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/command', + name: 'ups-error-hook', + timeout: 5000, + }, + { + type: 'command', + command: blockScript, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // Block should still work despite error in other hook, CLI should throw an error + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + + it('should handle hook timeout alongside blocking hook', async () => { + const blockScript = + 'echo \'{"decision": "block", "reason": "Blocked while other times out"}\''; + + await rig.setup('ups-timeout-with-block', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'ups-timeout-hook', + timeout: 1000, + }, + { + type: 'command', + command: blockScript, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // Block should work despite timeout in other hook, CLI should throw an error + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + + it('should handle multiple hook groups with different configurations', async () => { + const allow1Script = + 'echo \'{"decision": "allow", "reason": "Group 1 allows"}\''; + const allow2Script = + 'echo \'{"decision": "allow", "reason": "Group 2 allows"}\''; + + await rig.setup('ups-multi-groups', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: allow1Script, + name: 'ups-group1-hook', + timeout: 5000, + }, + ], + }, + { + sequential: true, + hooks: [ + { + type: 'command', + command: allow2Script, + name: 'ups-group2-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + + it('should block when one group blocks in multiple hook groups', async () => { + const allowScript = + 'echo \'{"decision": "allow", "reason": "Group 1 allows"}\''; + const blockScript = + 'echo \'{"decision": "block", "reason": "Group 2 blocks"}\''; + + await rig.setup('ups-multi-groups-one-blocks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: allowScript, + name: 'ups-group1-allow', + timeout: 5000, + }, + ], + }, + { + hooks: [ + { + type: 'command', + command: blockScript, + name: 'ups-group2-block', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // One group blocks, CLI should throw an error + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + + it('should handle modified prompt from multiple hooks', async () => { + const modify1Script = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"modifiedPrompt": "Modified by hook 1"}}\''; + const modify2Script = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"modifiedPrompt": "Modified by hook 2"}}\''; + + await rig.setup('ups-multi-modify', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: modify1Script, + name: 'ups-modify-1', + timeout: 5000, + }, + { + type: 'command', + command: modify2Script, + name: 'ups-modify-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + + it('should handle system messages from multiple hooks', async () => { + const msg1Script = + 'echo \'{"decision": "allow", "systemMessage": "System message 1"}\''; + const msg2Script = + 'echo \'{"decision": "allow", "systemMessage": "System message 2"}\''; + + await rig.setup('ups-multi-system-msg', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: msg1Script, + name: 'ups-msg-1', + timeout: 5000, + }, + { + type: 'command', + command: msg2Script, + name: 'ups-msg-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + }); + }); + + // ========================================================================== + // Stop Hooks + // Triggered when the agent is about to stop execution + // ========================================================================== + describe('Stop Hooks', () => { + describe('Allow Decision', () => { + it('should allow stopping when hook returns allow decision', async () => { + const allowStopScript = + 'echo \'{"decision": "allow", "reason": "Stop allowed"}\''; + + await rig.setup('stop-allow', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: allowStopScript, + name: 'stop-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say stop test'); + expect(result).toBeDefined(); + }); + + it('should allow stopping and verify final response is produced', async () => { + const allowFinalScript = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Final context from stop hook"}}\''; + + await rig.setup('stop-allow-final', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: allowFinalScript, + name: 'stop-final-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say goodbye'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('Block Decision', () => { + it('should continue execution when hook returns block decision', async () => { + // Stop hook's block decision means "block stopping" (i.e., force continuation) + // not "block operation and show error" + const blockStopScript = + 'echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\''; + + await rig.setup('stop-block-decision', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: blockStopScript, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run('Say hello', '--max-session-turns', '2'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should continue execution with custom reason', async () => { + // Stop hook's block decision means "block stopping" (i.e., force continuation) + const blockReasonScript = + 'echo \'{"decision": "block", "reason": "Custom block reason: task incomplete"}\''; + + await rig.setup('stop-block-custom-reason', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: blockReasonScript, + name: 'stop-block-reason-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run('Say goodbye', '--max-session-turns', '2'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('Continue False', () => { + it('should request continue execution when hook returns continue: false', async () => { + const continueScript = + 'echo \'{"continue": false, "stopReason": "More work needed"}\''; + + await rig.setup('stop-continue-false', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: continueScript, + name: 'stop-continue-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When continue: false, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run( + 'Say continue', + '--max-session-turns', + '2', + ); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('Additional Context', () => { + it('should include additional context in final response', async () => { + const contextScript = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Final context from hook"}}\''; + + await rig.setup('stop-add-context', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: contextScript, + name: 'stop-context-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('What is 3+3?'); + expect(result).toBeDefined(); + }); + + it('should concatenate multiple additionalContext from multiple hooks', async () => { + const context1Script = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context1"}}\''; + const context2Script = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context2"}}\''; + + await rig.setup('stop-multi-context', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: context1Script, + name: 'stop-context-1', + timeout: 5000, + }, + { + type: 'command', + command: context2Script, + name: 'stop-context-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say multi context'); + expect(result).toBeDefined(); + }); + }); + + describe('Stop Reason', () => { + it('should include stop reason when hook provides it', async () => { + const reasonScript = + 'echo \'{"decision": "allow", "stopReason": "Custom stop reason from hook"}\''; + + await rig.setup('stop-set-reason', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: reasonScript, + name: 'stop-reason-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say reason test'); + expect(result).toBeDefined(); + }); + }); + + describe('Timeout Handling', () => { + it('should continue stopping when hook times out', async () => { + await rig.setup('stop-timeout', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'stop-timeout-hook', + timeout: 1000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say timeout'); + // Timeout should not prevent stopping + expect(result).toBeDefined(); + }); + }); + + describe('Error Handling', () => { + it('should continue stopping when hook has non-blocking error', async () => { + await rig.setup('stop-error', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'echo warning && exit 1', + name: 'stop-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say error'); + // Error should not prevent stopping + expect(result).toBeDefined(); + }); + + it('should continue stopping when hook command does not exist', async () => { + await rig.setup('stop-missing-command', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'false', + name: 'stop-missing-hook', + timeout: 1000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say missing'); + // Missing command should not prevent stopping + expect(result).toBeDefined(); + }); + }); + + describe('System Message', () => { + it('should include system message in final response', async () => { + const systemMsgScript = + 'echo \'{"decision": "allow", "systemMessage": "Final system message from stop hook"}\''; + + await rig.setup('stop-system-message', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: systemMsgScript, + name: 'stop-system-msg-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say final'); + expect(result).toBeDefined(); + }); + }); + + describe('Multiple Stop Hooks', () => { + it('should continue execution when one of multiple parallel stop hooks returns block', async () => { + // Stop hook's block decision means "block stopping" (i.e., force continuation) + const allowScript = + 'echo \'{"decision": "allow", "reason": "Stop allowed"}\''; + const blockScript = + 'echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\''; + + await rig.setup('stop-multi-one-blocks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: allowScript, + name: 'stop-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: blockScript, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run( + 'Say multi stop', + '--max-session-turns', + '2', + ); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should continue execution when first sequential stop hook returns block', async () => { + // Stop hook's block decision means "block stopping" (i.e., force continuation) + const blockScript = + 'echo \'{"decision": "block", "reason": "First hook blocks stop"}\''; + const allowScript = + 'echo \'{"decision": "allow", "reason": "This should not run"}\''; + + await rig.setup('stop-seq-first-blocks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: blockScript, + name: 'stop-seq-block-hook', + timeout: 5000, + }, + { + type: 'command', + command: allowScript, + name: 'stop-seq-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run( + 'Say sequential stop', + '--max-session-turns', + '2', + ); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should continue execution when second sequential stop hook returns block', async () => { + // Stop hook's block decision means "block stopping" (i.e., force continuation) + const allowScript = + 'echo \'{"decision": "allow", "reason": "First allows"}\''; + const blockScript = + 'echo \'{"decision": "block", "reason": "Second hook blocks stop"}\''; + + await rig.setup('stop-seq-second-blocks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: allowScript, + name: 'stop-seq-first-allow', + timeout: 5000, + }, + { + type: 'command', + command: blockScript, + name: 'stop-seq-second-block', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run( + 'Say seq second blocks', + '--max-session-turns', + '2', + ); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle multiple stop hooks all returning allow', async () => { + const allow1Script = + 'echo \'{"decision": "allow", "reason": "First allows"}\''; + const allow2Script = + 'echo \'{"decision": "allow", "reason": "Second allows"}\''; + const allow3Script = + 'echo \'{"decision": "allow", "reason": "Third allows"}\''; + + await rig.setup('stop-multi-all-allow', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: allow1Script, + name: 'stop-allow-1', + timeout: 5000, + }, + { + type: 'command', + command: allow2Script, + name: 'stop-allow-2', + timeout: 5000, + }, + { + type: 'command', + command: allow3Script, + name: 'stop-allow-3', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say all allow'); + // All hooks allow, should complete normally + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle multiple stop hooks all returning block', async () => { + const block1Script = + 'echo {"decision": "block", "reason": "First blocks"}'; + const block2Script = + 'echo {"decision": "block", "reason": "Second blocks"}'; + + await rig.setup('stop-multi-all-block', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: block1Script, + name: 'stop-block-1', + timeout: 5000, + }, + { + type: 'command', + command: block2Script, + name: 'stop-block-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When Stop hooks block, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run( + 'Say all block', + '--max-session-turns', + '2', + ); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle multiple continue: false from different stop hooks', async () => { + const continue1Script = + 'echo {"continue": false, "stopReason": "First needs more work"}'; + const continue2Script = + 'echo {"continue": false, "stopReason": "Second needs more work"}'; + + await rig.setup('stop-multi-continue-false', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: continue1Script, + name: 'stop-continue-1', + timeout: 5000, + }, + { + type: 'command', + command: continue2Script, + name: 'stop-continue-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When continue: false, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run( + 'Say multi continue', + '--max-session-turns', + '2', + ); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle mixed allow and continue: false in stop hooks', async () => { + const allowScript = + 'echo {"decision": "allow", "reason": "Allow stop"}'; + const continueScript = + 'echo {"continue": false, "stopReason": "Need more work"}'; + + await rig.setup('stop-mixed-allow-continue', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: allowScript, + name: 'stop-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: continueScript, + name: 'stop-continue-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When continue: false, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run('Say mixed', '--max-session-turns', '2'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle block with higher priority than continue: false', async () => { + const blockScript = + 'echo {"decision": "block", "reason": "Security block"}'; + const continueScript = + 'echo {"continue": false, "stopReason": "Need more work"}'; + + await rig.setup('stop-block-vs-continue', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: blockScript, + name: 'stop-block-priority', + timeout: 5000, + }, + { + type: 'command', + command: continueScript, + name: 'stop-continue-lower', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run( + 'Say block priority', + '--max-session-turns', + '2', + ); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle stop hook with error alongside blocking hook', async () => { + const blockScript = 'echo {"decision": "block", "reason": "Blocked"}'; + + await rig.setup('stop-error-with-block', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/command', + name: 'stop-error-hook', + timeout: 5000, + }, + { + type: 'command', + command: blockScript, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) + const result = await rig.run( + 'Say error with block', + '--max-session-turns', + '2', + ); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + }); + }); + + // ========================================================================== + // Multiple Hooks (General) + // Tests for hook execution modes: sequential vs parallel + // ========================================================================== + describe('Multiple Hooks', () => { + describe('Sequential Execution', () => { + it('should execute hooks sequentially when sequential: true', async () => { + const hook1Script = + 'echo {"decision": "allow", "hookSpecificOutput": {"additionalContext": "first"}}'; + const hook2Script = + 'echo {"decision": "allow", "hookSpecificOutput": {"additionalContext": "second"}}'; + + await rig.setup('multi-sequential', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: hook1Script, + name: 'seq-hook-1', + timeout: 5000, + }, + { + type: 'command', + command: hook2Script, + name: 'seq-hook-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say sequential'); + expect(result).toBeDefined(); + }); + + it('should stop at first blocking hook and not execute subsequent', async () => { + const blockScript = + 'echo {"decision": "block", "reason": "Blocked by first hook"}'; + const allowScript = 'echo {"decision": "allow"}'; + + await rig.setup('multi-first-blocks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: blockScript, + name: 'seq-block-hook', + timeout: 5000, + }, + { + type: 'command', + command: allowScript, + name: 'seq-should-not-run', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // Note: Sequential hooks with block decision currently don't block as expected + // This is a known limitation - the hook config may not be correctly applied for sequential hooks + const result = await rig.run('Create a file'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should pass output from first hook to second hook input', async () => { + const passScript1 = + 'echo {"decision": "allow", "hookSpecificOutput": {"additionalContext": "from first", "passthrough": "data"}}'; + const passScript2 = + 'echo {"decision": "allow", "hookSpecificOutput": {"additionalContext": "received passthrough"}}'; + + await rig.setup('multi-passthrough', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: passScript1, + name: 'passthrough-hook-1', + timeout: 5000, + }, + { + type: 'command', + command: passScript2, + name: 'passthrough-hook-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say passthrough'); + expect(result).toBeDefined(); + }); + }); + + describe('Parallel Execution', () => { + it('should execute hooks in parallel when sequential is not set', async () => { + const hook1Script = 'echo {"decision": "allow"}'; + const hook2Script = 'echo {"decision": "allow"}'; + + await rig.setup('multi-parallel', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: hook1Script, + name: 'parallel-hook-1', + timeout: 5000, + }, + { + type: 'command', + command: hook2Script, + name: 'parallel-hook-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say parallel'); + expect(result).toBeDefined(); + }); + + it('should handle mixed success/failure results from parallel hooks', async () => { + // For UserPromptSubmit hooks, command execution failure is treated as a blocking error + // So when one hook fails, the entire operation is blocked + const allowScript = 'echo {"decision": "allow"}'; + + await rig.setup('multi-mixed', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: allowScript, + name: 'mixed-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: '/nonexistent/command', + name: 'mixed-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // UserPromptSubmit hook command failure blocks the operation + await expect(rig.run('Say mixed')).rejects.toThrow( + /blocked|error|nonexistent/i, + ); + }); + + it('should allow when any hook returns allow in parallel (OR logic)', async () => { + const blockScript = 'echo {"decision": "block", "reason": "blocked"}'; + const allowScript = 'echo {"decision": "allow"}'; + + await rig.setup('multi-or-logic', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: blockScript, + name: 'block-hook', + timeout: 5000, + }, + { + type: 'command', + command: allowScript, + name: 'allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say or logic'); + // With OR logic, allow should win + expect(result).toBeDefined(); + }); + }); + }); + + // ========================================================================== + // Combined Hooks + // Tests for using multiple hook types (UserPromptSubmit + Stop) together + // ========================================================================== + describe('Combined Hooks', () => { + it('should execute both Stop and UserPromptSubmit hooks in same session', async () => { + const stopScript = 'echo {"decision": "allow"}'; + const upsScript = 'echo {"decision": "allow"}'; + + await rig.setup('combined-both-hooks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: stopScript, + name: 'stop-hook', + timeout: 5000, + }, + ], + }, + ], + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: upsScript, + name: 'ups-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say both hooks'); + expect(result).toBeDefined(); + }); + }); + + // ========================================================================== + // Hook Script File Tests + // Tests for executing hooks from external script files + // ========================================================================== + describe('Hook Script File Tests', () => { + it('should execute hook from script file', async () => { + const scriptFileHook = + 'echo {"decision": "allow", "reason": "Approved by script file", "hookSpecificOutput": {"additionalContext": "Script file executed successfully"}}'; + + await rig.setup('script-file-hook', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: scriptFileHook, + name: 'script-file-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say script file test'); + expect(result).toBeDefined(); + }); + + it('should execute blocking hook from script file', async () => { + const scriptBlockHook = + 'echo \'{"decision": "block", "reason": "Blocked by security script"}\''; + + await rig.setup('script-file-block-hook', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: scriptBlockHook, + name: 'script-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + // When UserPromptSubmit hook blocks, CLI exits with non-zero code + await expect(rig.run('Create a file')).rejects.toThrow(/block/i); + }); + }); +}); diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts index cba8cb72a..4b0b99677 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/run_shell_command.test.ts @@ -72,7 +72,7 @@ describe('run_shell_command', () => { const rig = new TestRig(); await rig.setup('should propagate environment variables'); - const varName = 'GEMINI_CLI_TEST_VAR'; + const varName = 'QWEN_CODE_TEST_VAR'; const varValue = `test-value-${Math.random().toString(36).substring(7)}`; process.env[varName] = varValue; diff --git a/integration-tests/terminal-capture/scenario-runner.ts b/integration-tests/terminal-capture/scenario-runner.ts index a7900e11c..93640694b 100644 --- a/integration-tests/terminal-capture/scenario-runner.ts +++ b/integration-tests/terminal-capture/scenario-runner.ts @@ -42,8 +42,6 @@ export interface FlowStep { intervalMs: number; /** Maximum number of captures */ count: number; - /** Generate animated GIF from captured frames (default: true) */ - gif?: boolean; }; } @@ -66,6 +64,8 @@ export interface ScenarioConfig { }; /** Screenshot output directory (relative to config file) */ outputDir?: string; + /** Generate animated GIF from all screenshots in order (default: true) */ + gif?: boolean; } // ───────────────────────────────────────────── @@ -195,12 +195,7 @@ export async function runScenario( // Streaming capture: capture multiple screenshots during execution if (step.streaming) { - const { - delayMs = 0, - intervalMs, - count, - gif = true, - } = step.streaming; + const { delayMs = 0, intervalMs, count } = step.streaming; console.log( ` 🎬 streaming capture: ${count} shots @ ${intervalMs}ms intervals${delayMs ? ` (delay ${delayMs}ms)` : ''}`, ); @@ -247,25 +242,7 @@ export async function runScenario( const resultName = step.capture ?? `${pad(seq)}-02.png`; console.log(` ${label} 📸 result: ${resultName}`); - const resultShot = await terminal.capture(resultName); - screenshots.push(resultShot); - - // Generate animated GIF: input -> streaming frames -> result - if (gif && streamingShots.length > 0) { - // Include input and result in the GIF for complete story - const inputShot = screenshots.find((s) => - s.endsWith(`${pad(seq)}-01.png`), - ); - const gifFrames = [ - ...(inputShot ? [inputShot] : []), - ...streamingShots, - resultShot, - ]; - const gifPath = generateGif(gifFrames, outputDir); - if (gifPath) { - console.log(` 🎞️ GIF: ${gifPath}`); - } - } + screenshots.push(await terminal.capture(resultName)); } else { console.log(` ⏳ waiting for output to settle...`); await terminal.idle(2000, 60000); @@ -342,6 +319,19 @@ export async function runScenario( } } + // Generate animated GIF from all screenshots (excluding full-flow captures) + if (config.gif !== false) { + const gifFrames = screenshots.filter( + (s) => !s.endsWith('full-flow.png') && !s.includes('-full-'), + ); + if (gifFrames.length > 0) { + const gifPath = generateGif(gifFrames, outputDir); + if (gifPath) { + console.log(` 🎞️ GIF: ${gifPath}`); + } + } + } + const duration = Date.now() - startTime; console.log( `\n ✅ ${config.name} — ${screenshots.length} screenshots, ${(duration / 1000).toFixed(1)}s`, @@ -404,8 +394,8 @@ function resolveKey(key: string): string { function generateGif(frames: string[], outputDir: string): string | null { if (frames.length === 0) return null; - const FRAME_DURATION = 0.3; // 300ms per frame - const EDGE_DURATION = 1.0; // 600ms for first/last frame + const STREAMING_DURATION = 0.3; // 300ms for streaming frames + const STATIC_DURATION = 1.0; // 1s for non-streaming and edge frames const gifPath = join(outputDir, 'streaming.gif'); const listFile = join(outputDir, 'frames.txt'); @@ -413,11 +403,9 @@ function generateGif(frames: string[], outputDir: string): string | null { try { const lines: string[] = []; for (let i = 0; i < frames.length; i++) { - const isEdge = i === 0 || i === frames.length - 1; - lines.push( - `file '${resolve(frames[i])}'`, - `duration ${isEdge ? EDGE_DURATION : FRAME_DURATION}`, - ); + const isStreaming = frames[i].includes('-streaming-'); + const duration = isStreaming ? STREAMING_DURATION : STATIC_DURATION; + lines.push(`file '${resolve(frames[i])}'`, `duration ${duration}`); } // Concat demuxer requires last frame repeated without duration lines.push(`file '${resolve(frames[frames.length - 1])}'`); diff --git a/integration-tests/terminal-capture/scenarios/message-components.ts b/integration-tests/terminal-capture/scenarios/message-components.ts new file mode 100644 index 000000000..621eb1ef8 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/message-components.ts @@ -0,0 +1,32 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +/** + * Tests the message component refactoring for PR #2120. + * Captures info, warning, and error messages to verify proper icon/prefix display. + * + * This scenario tests: + * - Info message prefix (● filled circle) + * - Error message prefix (✕) + * - User message prefix (>) + * - Assistant message prefix (✦) + */ +export default { + name: 'message-components', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + // Test info message via /skills command (instant, no streaming) + { type: '/skills' }, + // Test error message via unknown skill (instant, no streaming) + { type: '/skills nonexistent-skill-xyz' }, + // Test user and assistant messages (streams from LLM) + { + type: 'Say "Hello, this is a test of message prefixes!" and nothing else.', + streaming: { + delayMs: 3000, + intervalMs: 1000, + count: 10, + }, + }, + ], +} satisfies ScenarioConfig; diff --git a/integration-tests/terminal-capture/scenarios/qc-code-review.ts b/integration-tests/terminal-capture/scenarios/qc-code-review.ts index caf50bffc..75b281539 100644 --- a/integration-tests/terminal-capture/scenarios/qc-code-review.ts +++ b/integration-tests/terminal-capture/scenarios/qc-code-review.ts @@ -11,7 +11,6 @@ export default { delayMs: 10000, // Wait for initial model thinking/approval intervalMs: 800, // Capture every 800ms count: 30, // Max 30 captures - gif: true, // Generate animated GIF }, }, ], diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index a08b3df50..00cf77de4 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -154,7 +154,7 @@ export class TestRig { // Get timeout based on environment getDefaultTimeout() { if (env['CI']) return 60000; // 1 minute in CI - if (env['GEMINI_SANDBOX']) return 30000; // 30s in containers + if (env['QWEN_SANDBOX']) return 30000; // 30s in containers return 15000; // 15s locally } @@ -181,7 +181,7 @@ export class TestRig { otlpEndpoint: '', outfile: telemetryPath, }, - sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false, + sandbox: env.QWEN_SANDBOX !== 'false' ? env.QWEN_SANDBOX : false, ...options.settings, // Allow tests to override/add settings }; writeFileSync( @@ -301,7 +301,7 @@ export class TestRig { // Filter out telemetry output when running with Podman // Podman seems to output telemetry to stdout even when writing to file let result = stdout; - if (env['GEMINI_SANDBOX'] === 'podman') { + if (env['QWEN_SANDBOX'] === 'podman') { // Remove telemetry JSON objects from output // They are multi-line JSON objects that start with { and contain telemetry fields const lines = result.split(EOL); @@ -727,7 +727,7 @@ export class TestRig { readToolLogs() { // For Podman, first check if telemetry file exists and has content // If not, fall back to parsing from stdout - if (env['GEMINI_SANDBOX'] === 'podman') { + if (env['QWEN_SANDBOX'] === 'podman') { // Try reading from file first const logFilePath = join(this.testDir!, 'telemetry.log'); diff --git a/package-lock.json b/package-lock.json index 5df32acc0..c0c2bb039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.14.1.tgz", + "integrity": "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.0.tgz", @@ -14284,7 +14293,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -18793,6 +18801,7 @@ "name": "@qwen-code/qwen-code", "version": "0.12.0", "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", @@ -20877,39 +20886,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "packages/sdk-typescript/node_modules/@vitest/browser": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-1.6.1.tgz", - "integrity": "sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vitest/utils": "1.6.1", - "magic-string": "^0.30.5", - "sirv": "^2.0.4" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "1.6.1", - "webdriverio": "*" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": true - }, - "safaridriver": { - "optional": true - }, - "webdriverio": { - "optional": true - } - } - }, "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", @@ -21717,23 +21693,6 @@ "url": "https://opencollective.com/express" } }, - "packages/sdk-typescript/node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "packages/sdk-typescript/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -22944,6 +22903,7 @@ "version": "0.12.0", "license": "LICENSE", "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/webui": "*", "cors": "^2.8.5", diff --git a/package.json b/package.json index e7caedb81..d12e16152 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dev": "node scripts/dev.js", "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", "generate": "node scripts/generate-git-commit-info.js", + "generate:settings-schema": "tsx scripts/generate-settings-schema.ts", "build": "node scripts/build.js", "build-and-start": "npm run build && npm run start", "build:vscode": "node scripts/build_vscode_companion.js", @@ -32,13 +33,13 @@ "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none", "test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman", - "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:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests", + "test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests", + "test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests", + "test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests sdk-typescript", + "test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript", + "test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", + "test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_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'", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1a2e53a85..32073bb5c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,6 +36,7 @@ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", @@ -100,10 +101,10 @@ "@teddyzhu/clipboard": "^0.0.5", "@teddyzhu/clipboard-darwin-arm64": "0.0.5", "@teddyzhu/clipboard-darwin-x64": "0.0.5", - "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5", - "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5", - "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5" + "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", + "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5", + "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5" }, "engines": { "node": ">=20" diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts deleted file mode 100644 index 8c1dc0907..000000000 --- a/packages/cli/src/acp-integration/acp.ts +++ /dev/null @@ -1,503 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */ - -import { z } from 'zod'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; -import * as schema from './schema.js'; -import { ACP_ERROR_CODES } from './errorCodes.js'; -import { pickAuthMethodsForDetails } from './authMethods.js'; -export * from './schema.js'; - -import type { WritableStream, ReadableStream } from 'node:stream/web'; - -const debugLogger = createDebugLogger('ACP_PROTOCOL'); -export class AgentSideConnection implements Client { - #connection: Connection; - - constructor( - toAgent: (conn: Client) => Agent, - input: WritableStream, - output: ReadableStream, - ) { - const agent = toAgent(this); - - const handler = async ( - method: string, - params: unknown, - ): Promise => { - switch (method) { - case schema.AGENT_METHODS.initialize: { - const validatedParams = schema.initializeRequestSchema.parse(params); - return agent.initialize(validatedParams); - } - case schema.AGENT_METHODS.session_new: { - const validatedParams = schema.newSessionRequestSchema.parse(params); - return agent.newSession(validatedParams); - } - case schema.AGENT_METHODS.session_load: { - if (!agent.loadSession) { - throw RequestError.methodNotFound(); - } - const validatedParams = schema.loadSessionRequestSchema.parse(params); - return agent.loadSession(validatedParams); - } - case schema.AGENT_METHODS.session_list: { - if (!agent.listSessions) { - throw RequestError.methodNotFound(); - } - const validatedParams = - schema.listSessionsRequestSchema.parse(params); - return agent.listSessions(validatedParams); - } - case schema.AGENT_METHODS.authenticate: { - const validatedParams = - schema.authenticateRequestSchema.parse(params); - return agent.authenticate(validatedParams); - } - case schema.AGENT_METHODS.session_prompt: { - const validatedParams = schema.promptRequestSchema.parse(params); - return agent.prompt(validatedParams); - } - case schema.AGENT_METHODS.session_cancel: { - const validatedParams = schema.cancelNotificationSchema.parse(params); - return agent.cancel(validatedParams); - } - case schema.AGENT_METHODS.session_set_mode: { - if (!agent.setMode) { - throw RequestError.methodNotFound(); - } - const validatedParams = schema.setModeRequestSchema.parse(params); - return agent.setMode(validatedParams); - } - case schema.AGENT_METHODS.session_set_model: { - if (!agent.setModel) { - throw RequestError.methodNotFound(); - } - const validatedParams = schema.setModelRequestSchema.parse(params); - return agent.setModel(validatedParams); - } - case schema.AGENT_METHODS.session_set_config_option: { - if (!agent.setConfigOption) { - throw RequestError.methodNotFound(); - } - const validatedParams = - schema.setConfigOptionRequestSchema.parse(params); - return agent.setConfigOption(validatedParams); - } - default: - throw RequestError.methodNotFound(method); - } - }; - - this.#connection = new Connection(handler, input, output); - } - - /** - * Streams new content to the client including text, tool calls, etc. - */ - async sessionUpdate(params: schema.SessionNotification): Promise { - return await this.#connection.sendNotification( - schema.CLIENT_METHODS.session_update, - params, - ); - } - - /** - * 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, - ); - } - - /** - * Sends a custom notification to the client. - * Used for extension-specific notifications that are not part of the core ACP protocol. - */ - async sendCustomNotification(method: string, params: T): Promise { - return await this.#connection.sendNotification(method, params); - } - - /** - * Request permission before running a tool - * - * The agent specifies a series of permission options with different granularity, - * and the client returns the chosen one. - */ - async requestPermission( - params: schema.RequestPermissionRequest, - ): Promise { - return await this.#connection.sendRequest( - schema.CLIENT_METHODS.session_request_permission, - params, - ); - } - - async readTextFile( - params: schema.ReadTextFileRequest, - ): Promise { - return await this.#connection.sendRequest( - schema.CLIENT_METHODS.fs_read_text_file, - params, - ); - } - - async writeTextFile( - params: schema.WriteTextFileRequest, - ): Promise { - return await this.#connection.sendRequest( - schema.CLIENT_METHODS.fs_write_text_file, - params, - ); - } -} - -type AnyMessage = AnyRequest | AnyResponse | AnyNotification; - -type AnyRequest = { - jsonrpc: '2.0'; - id: string | number; - method: string; - params?: unknown; -}; - -type AnyResponse = { - jsonrpc: '2.0'; - id: string | number; -} & Result; - -type AnyNotification = { - jsonrpc: '2.0'; - method: string; - params?: unknown; -}; - -type Result = - | { - result: T; - } - | { - error: ErrorResponse; - }; - -type ErrorResponse = { - code: number; - message: string; - data?: unknown; - authMethods?: schema.AuthMethod[]; -}; - -type PendingResponse = { - resolve: (response: unknown) => void; - reject: (error: ErrorResponse) => void; -}; - -type MethodHandler = (method: string, params: unknown) => Promise; - -class Connection { - #pendingResponses: Map = new Map(); - #nextRequestId: number = 0; - #handler: MethodHandler; - #peerInput: WritableStream; - #writeQueue: Promise = Promise.resolve(); - #textEncoder: TextEncoder; - - constructor( - handler: MethodHandler, - peerInput: WritableStream, - peerOutput: ReadableStream, - ) { - this.#handler = handler; - this.#peerInput = peerInput; - this.#textEncoder = new TextEncoder(); - this.#receive(peerOutput); - } - - async #receive(output: ReadableStream) { - let content = ''; - const decoder = new TextDecoder(); - for await (const chunk of output) { - content += decoder.decode(chunk, { stream: true }); - const lines = content.split('\n'); - content = lines.pop() || ''; - - for (const line of lines) { - const trimmedLine = line.trim(); - - if (trimmedLine) { - try { - const message = JSON.parse(trimmedLine); - this.#processMessage(message); - } catch (error) { - debugLogger.error('ACP parse error for inbound message.', { - code: ACP_ERROR_CODES.PARSE_ERROR, - line: trimmedLine, - error, - }); - } - } - } - } - } - - async #processMessage(message: AnyMessage) { - if ('method' in message && 'id' in message) { - // It's a request - const response = await this.#tryCallHandler( - message.method, - message.params, - ); - - await this.#sendMessage({ - jsonrpc: '2.0', - id: message.id, - ...response, - }); - } else if ('method' in message) { - // It's a notification - await this.#tryCallHandler(message.method, message.params); - } else if ('id' in message) { - // It's a response - this.#handleResponse(message as AnyResponse); - } - } - - async #tryCallHandler( - method: string, - params?: unknown, - ): Promise> { - try { - const result = await this.#handler(method, params); - return { result: result ?? null }; - } catch (error: unknown) { - if (error instanceof RequestError) { - debugLogger.debug('ACP handler returned request error.', { - method, - code: error.code, - message: error.message, - details: error.data?.details, - }); - return error.toResult(); - } - - if (error instanceof z.ZodError) { - const formattedDetails = JSON.stringify(error.format(), undefined, 2); - debugLogger.debug('ACP handler validation error.', { - method, - code: ACP_ERROR_CODES.INVALID_PARAMS, - details: formattedDetails, - }); - return RequestError.invalidParams(formattedDetails).toResult(); - } - - let errorName; - let details; - - if (error instanceof Error) { - errorName = error.name; - details = error.message; - } else if ( - typeof error === 'object' && - error != null && - 'message' in error && - typeof error.message === 'string' - ) { - details = error.message; - } - - if (errorName === 'TokenManagerError' || details?.includes('/auth')) { - return RequestError.authRequired( - details, - pickAuthMethodsForDetails(details), - ).toResult(); - } - - debugLogger.error( - 'ACP handler failed with internal error.', - { method, errorName, details }, - error, - ); - return RequestError.internalError(details).toResult(); - } - } - - #handleResponse(response: AnyResponse) { - const pendingResponse = this.#pendingResponses.get(response.id); - if (pendingResponse) { - if ('result' in response) { - pendingResponse.resolve(response.result); - } else if ('error' in response) { - const { error } = response; - debugLogger.warn('ACP response error received.', { - id: response.id, - code: error.code, - message: error.message, - data: error.data, - }); - pendingResponse.reject(error); - } - this.#pendingResponses.delete(response.id); - } - } - - async sendRequest(method: string, params?: Req): Promise { - const id = this.#nextRequestId++; - const responsePromise = new Promise((resolve, reject) => { - this.#pendingResponses.set(id, { resolve, reject }); - }); - await this.#sendMessage({ jsonrpc: '2.0', id, method, params }); - return responsePromise as Promise; - } - - async sendNotification(method: string, params?: N): Promise { - await this.#sendMessage({ jsonrpc: '2.0', method, params }); - } - - async #sendMessage(json: AnyMessage) { - const content = JSON.stringify(json) + '\n'; - this.#writeQueue = this.#writeQueue - .then(async () => { - const writer = this.#peerInput.getWriter(); - try { - await writer.write(this.#textEncoder.encode(content)); - } finally { - writer.releaseLock(); - } - }) - .catch((error) => { - // Continue processing writes on error - debugLogger.error('ACP write error:', error); - }); - return this.#writeQueue; - } -} - -export class RequestError extends Error { - data?: { details?: string; authMethods?: schema.AuthMethod[] }; - - constructor( - public code: number, - message: string, - details?: string, - authMethods?: schema.AuthMethod[], - ) { - super(message); - this.name = 'RequestError'; - if (details || authMethods) { - this.data = {}; - if (details) { - this.data.details = details; - } - if (authMethods) { - this.data.authMethods = authMethods; - } - } - } - - static parseError(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.PARSE_ERROR, - 'Parse error', - details, - ); - } - - static invalidRequest(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.INVALID_REQUEST, - 'Invalid request', - details, - ); - } - - static methodNotFound(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.METHOD_NOT_FOUND, - 'Method not found', - details, - ); - } - - static invalidParams(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.INVALID_PARAMS, - 'Invalid params', - details, - ); - } - - static internalError(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.INTERNAL_ERROR, - 'Internal error', - details, - ); - } - - static authRequired( - details?: string, - authMethods?: schema.AuthMethod[], - ): RequestError { - return new RequestError( - ACP_ERROR_CODES.AUTH_REQUIRED, - 'Authentication required', - details, - authMethods, - ); - } - - toResult(): Result { - return { - error: { - code: this.code, - message: this.message, - data: this.data, - }, - }; - } -} - -export interface Client { - requestPermission( - params: schema.RequestPermissionRequest, - ): Promise; - sessionUpdate(params: schema.SessionNotification): Promise; - authenticateUpdate(params: schema.AuthenticateUpdate): Promise; - sendCustomNotification(method: string, params: T): Promise; - writeTextFile( - params: schema.WriteTextFileRequest, - ): Promise; - readTextFile( - params: schema.ReadTextFileRequest, - ): Promise; -} - -export interface Agent { - initialize( - params: schema.InitializeRequest, - ): Promise; - newSession( - params: schema.NewSessionRequest, - ): Promise; - loadSession?( - params: schema.LoadSessionRequest, - ): Promise; - listSessions?( - params: schema.ListSessionsRequest, - ): Promise; - authenticate(params: schema.AuthenticateRequest): Promise; - prompt(params: schema.PromptRequest): Promise; - cancel(params: schema.CancelNotification): Promise; - setMode?(params: schema.SetModeRequest): Promise; - setModel?(params: schema.SetModelRequest): Promise; - setConfigOption?( - params: schema.SetConfigOptionRequest, - ): Promise; -} diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index faf89db90..af3590422 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -1,11 +1,9 @@ /** * @license - * Copyright 2025 Qwen + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ -import type { ReadableStream, WritableStream } from 'node:stream/web'; - import { APPROVAL_MODE_INFO, APPROVAL_MODES, @@ -21,8 +19,40 @@ import { type ConversationRecord, type DeviceAuthorizationData, } from '@qwen-code/qwen-code-core'; -import type { ApprovalModeValue, ConfigOption } from './schema.js'; -import * as acp from './acp.js'; +import { + AgentSideConnection, + RequestError, + ndJsonStream, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; +import type { + Agent, + AuthenticateRequest, + AuthMethod, + CancelNotification, + ClientCapabilities, + InitializeRequest, + InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + McpServer, + McpServerStdio, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + SessionConfigOption, + SessionInfo, + SessionModeState, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionModeRequest, + SetSessionModeResponse, +} from '@agentclientprotocol/sdk'; import { buildAuthMethods } from './authMethods.js'; import { AcpFileSystemService } from './service/filesystem.js'; import { Readable, Writable } from 'node:stream'; @@ -31,9 +61,8 @@ import { SettingScope } from '../config/settings.js'; import { z } from 'zod'; import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; - -// Import the modular Session class import { Session } from './session/Session.js'; +import type { ApprovalModeValue } from './session/types.js'; import { formatAcpModelId } from '../utils/acpModelUtils.js'; const debugLogger = createDebugLogger('ACP_AGENT'); @@ -52,54 +81,46 @@ export async function runAcpAgent( console.info = console.error; console.debug = console.error; - new acp.AgentSideConnection( - (client: acp.Client) => new GeminiAgent(config, settings, argv, client), - stdout, - stdin, + const stream = ndJsonStream(stdout, stdin); + const connection = new AgentSideConnection( + (conn) => new QwenAgent(config, settings, argv, conn), + stream, ); + + await connection.closed; } -class GeminiAgent { +function toStdioServer(server: McpServer): McpServerStdio | undefined { + if ('command' in server && 'args' in server && 'env' in server) { + return server as McpServerStdio; + } + return undefined; +} + +class QwenAgent implements Agent { private sessions: Map = new Map(); - private clientCapabilities: acp.ClientCapabilities | undefined; + private clientCapabilities: ClientCapabilities | undefined; constructor( private config: Config, private settings: LoadedSettings, private argv: CliArgs, - private client: acp.Client, + private connection: AgentSideConnection, ) {} - async initialize( - args: acp.InitializeRequest, - ): Promise { + async initialize(args: InitializeRequest): Promise { this.clientCapabilities = args.clientCapabilities; const authMethods = buildAuthMethods(); - - // Get current approval mode from config - const currentApprovalMode = this.config.getApprovalMode(); - - // Build available modes from shared APPROVAL_MODE_INFO - const availableModes = APPROVAL_MODES.map((mode) => ({ - id: mode as ApprovalModeValue, - name: APPROVAL_MODE_INFO[mode].name, - description: APPROVAL_MODE_INFO[mode].description, - })); - const version = process.env['CLI_VERSION'] || process.version; return { - protocolVersion: acp.PROTOCOL_VERSION, + protocolVersion: PROTOCOL_VERSION, agentInfo: { name: 'qwen-code', title: 'Qwen Code', version, }, authMethods, - modes: { - currentModeId: currentApprovalMode as ApprovalModeValue, - availableModes, - }, agentCapabilities: { loadSession: true, promptCapabilities: { @@ -115,14 +136,15 @@ class GeminiAgent { }; } - async authenticate({ methodId }: acp.AuthenticateRequest): Promise { + async authenticate({ methodId }: 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 } }); + void this.connection.extNotification('authenticate/update', { + _meta: { authUri }, + }); }; if (method === AuthType.QWEN_OAUTH) { @@ -138,19 +160,16 @@ class GeminiAgent { 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({ cwd, mcpServers, - }: acp.NewSessionRequest): Promise { + }: NewSessionRequest): Promise { const config = await this.newSessionConfig(cwd, mcpServers); await this.ensureAuthenticated(config); this.setupFileSystem(config); @@ -168,58 +187,12 @@ class GeminiAgent { }; } - async newSessionConfig( - cwd: string, - mcpServers: acp.McpServer[], - sessionId?: string, - ): Promise { - const mergedMcpServers = { ...this.settings.merged.mcpServers }; - - for (const { command, args, env: rawEnv, name } of mcpServers) { - const env: Record = {}; - for (const { name: envName, value } of rawEnv) { - env[envName] = value; - } - mergedMcpServers[name] = new MCPServerConfig(command, args, env, cwd); - } - - const settings = { ...this.settings.merged, mcpServers: mergedMcpServers }; - - const argvForSession = { - ...this.argv, - resume: sessionId, - continue: false, - }; - - const config = await loadCliConfig(settings, argvForSession, cwd); - - await config.initialize(); - return config; - } - - async cancel(params: acp.CancelNotification): Promise { - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - await session.cancelPendingPrompt(); - } - - async prompt(params: acp.PromptRequest): Promise { - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - return session.prompt(params); - } - - async loadSession( - params: acp.LoadSessionRequest, - ): Promise { + async loadSession(params: LoadSessionRequest): Promise { const sessionService = new SessionService(params.cwd); const exists = await sessionService.sessionExists(params.sessionId); if (!exists) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Session not found for id: ${params.sessionId}`, ); } @@ -234,182 +207,193 @@ class GeminiAgent { const sessionData = config.getResumedSessionData(); if (!sessionData) { - throw acp.RequestError.internalError( + throw RequestError.internalError( + undefined, `Failed to load session data for id: ${params.sessionId}`, ); } await this.createAndStoreSession(config, sessionData.conversation); - return null; + const modesData = this.buildModesData(config); + const availableModels = this.buildAvailableModels(config); + const configOptions = this.buildConfigOptions(config); + + return { + modes: modesData, + models: availableModels, + configOptions, + }; } - async listSessions( - params: acp.ListSessionsRequest, - ): Promise { + async unstable_listSessions( + params: ListSessionsRequest, + ): Promise { const cwd = params.cwd || process.cwd(); const sessionService = new SessionService(cwd); + const numericCursor = params.cursor ? Number(params.cursor) : undefined; const result = await sessionService.listSessions({ - cursor: params.cursor, - size: params.size, + cursor: Number.isNaN(numericCursor) ? undefined : numericCursor, }); - const sessions = result.items.map((item) => ({ + const sessions: SessionInfo[] = result.items.map((item) => ({ cwd: item.cwd, - filePath: item.filePath, - gitBranch: item.gitBranch, - messageCount: item.messageCount, - mtime: item.mtime, - prompt: item.prompt, sessionId: item.sessionId, - startTime: item.startTime, title: item.prompt || '(session)', updatedAt: new Date(item.mtime).toISOString(), })); return { - hasMore: result.hasMore, - items: sessions, - nextCursor: result.nextCursor, sessions, + nextCursor: + result.nextCursor != null ? String(result.nextCursor) : undefined, }; } - async setMode(params: acp.SetModeRequest): Promise { + async setSessionMode( + params: SetSessionModeRequest, + ): Promise { const session = this.sessions.get(params.sessionId); if (!session) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Session not found for id: ${params.sessionId}`, ); } return session.setMode(params); } - async setModel(params: acp.SetModelRequest): Promise { + async unstable_setSessionModel( + params: SetSessionModelRequest, + ): Promise { const session = this.sessions.get(params.sessionId); if (!session) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Session not found for id: ${params.sessionId}`, ); } return await session.setModel(params); } - async setConfigOption( - params: acp.SetConfigOptionRequest, - ): Promise { + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { const { sessionId, configId, value } = params; - // Get the session's config const session = this.sessions.get(sessionId); if (!session) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Session not found for id: ${sessionId}`, ); } switch (configId) { case 'mode': { - await this.setMode({ + await this.setSessionMode({ sessionId, - modeId: value as ApprovalModeValue, + modeId: value as string, }); break; } case 'model': { - await this.setModel({ + await this.unstable_setSessionModel({ sessionId, modelId: value as string, }); break; } default: - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Unsupported configId: ${configId}`, ); } - // Return all config options with current values return { configOptions: this.buildConfigOptions(session.getConfig()), }; } - private buildConfigOptions(config: Config): ConfigOption[] { - const currentApprovalMode = config.getApprovalMode(); - const allConfiguredModels = config.getAllConfiguredModels(); - const rawCurrentModelId = (config.getModel() || '').trim(); - const currentAuthType = config.getAuthType?.(); + async prompt(params: PromptRequest): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + return session.prompt(params); + } - // Check if current model is a runtime model - const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); - const currentModelId = activeRuntimeSnapshot - ? formatAcpModelId( - activeRuntimeSnapshot.id, - activeRuntimeSnapshot.authType, - ) - : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); + async cancel(params: CancelNotification): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + await session.cancelPendingPrompt(); + } - // Build mode config option - const modeOptions = APPROVAL_MODES.map((mode) => ({ - value: mode, - name: APPROVAL_MODE_INFO[mode].name, - description: APPROVAL_MODE_INFO[mode].description, - })); + async extMethod( + method: string, + _params: Record, + ): Promise> { + throw RequestError.methodNotFound(method); + } - const modeConfigOption: ConfigOption = { - id: 'mode', - name: 'Mode', - description: 'Session permission mode', - category: 'mode', - type: 'select', - currentValue: currentApprovalMode, - options: modeOptions, + // --- private helpers --- + + private async newSessionConfig( + cwd: string, + mcpServers: McpServer[], + sessionId?: string, + ): Promise { + const mergedMcpServers = { ...this.settings.merged.mcpServers }; + + for (const server of mcpServers) { + const stdioServer = toStdioServer(server); + if (!stdioServer) continue; + + const env: Record = {}; + for (const { name: envName, value } of stdioServer.env) { + env[envName] = value; + } + mergedMcpServers[stdioServer.name] = new MCPServerConfig( + stdioServer.command, + stdioServer.args, + env, + cwd, + ); + } + + const settings = { ...this.settings.merged, mcpServers: mergedMcpServers }; + const argvForSession = { + ...this.argv, + resume: sessionId, + continue: false, }; - // Build model config option - const modelOptions = allConfiguredModels.map((model) => { - const effectiveModelId = - model.isRuntimeModel && model.runtimeSnapshotId - ? model.runtimeSnapshotId - : model.id; - return { - value: formatAcpModelId(effectiveModelId, model.authType), - name: model.label, - description: model.description ?? '', - }; - }); - - const modelConfigOption: ConfigOption = { - id: 'model', - name: 'Model', - description: 'AI model to use', - category: 'model', - type: 'select', - currentValue: currentModelId, - options: modelOptions, - }; - - return [modeConfigOption, modelConfigOption]; + const config = await loadCliConfig(settings, argvForSession, cwd); + await config.initialize(); + return config; } private async ensureAuthenticated(config: Config): Promise { const selectedType = config.getModelsConfig().getCurrentAuthType(); if (!selectedType) { - throw acp.RequestError.authRequired( + throw RequestError.authRequired( + { authMethods: this.pickAuthMethodsForAuthRequired() }, 'Use Qwen Code CLI to authenticate first.', - this.pickAuthMethodsForAuthRequired(), ); } try { - // Use true for the second argument to ensure only cached credentials are used await config.refreshAuth(selectedType, true); } catch (e) { debugLogger.error(`Authentication failed: ${e}`); - throw acp.RequestError.authRequired( + throw RequestError.authRequired( + { + authMethods: this.pickAuthMethodsForAuthRequired(selectedType, e), + }, 'Authentication failed: ' + (e as Error).message, - this.pickAuthMethodsForAuthRequired(selectedType, e), ); } } @@ -417,7 +401,7 @@ class GeminiAgent { private pickAuthMethodsForAuthRequired( selectedType?: AuthType | string, error?: unknown, - ): acp.AuthMethod[] { + ): AuthMethod[] { const authMethods = buildAuthMethods(); const errorMessage = this.extractErrorMessage(error); if ( @@ -425,25 +409,21 @@ class GeminiAgent { errorMessage?.includes('Qwen OAuth') ) { const qwenOAuthMethods = authMethods.filter( - (method) => method.id === AuthType.QWEN_OAUTH, + (m) => m.id === AuthType.QWEN_OAUTH, ); return qwenOAuthMethods.length ? qwenOAuthMethods : authMethods; } if (selectedType) { - const matchedMethods = authMethods.filter( - (method) => method.id === selectedType, - ); - return matchedMethods.length ? matchedMethods : authMethods; + const matched = authMethods.filter((m) => m.id === selectedType); + return matched.length ? matched : authMethods; } return authMethods; } private extractErrorMessage(error?: unknown): string | undefined { - if (error instanceof Error) { - return error.message; - } + if (error instanceof Error) return error.message; if ( typeof error === 'object' && error != null && @@ -452,19 +432,15 @@ class GeminiAgent { ) { return error.message; } - if (typeof error === 'string') { - return error; - } + if (typeof error === 'string') return error; return undefined; } private setupFileSystem(config: Config): void { - if (!this.clientCapabilities?.fs) { - return; - } + if (!this.clientCapabilities?.fs) return; const acpFileSystemService = new AcpFileSystemService( - this.client, + this.connection, config.getSessionId(), this.clientCapabilities.fs, config.getFileSystemService(), @@ -479,26 +455,17 @@ class GeminiAgent { const sessionId = config.getSessionId(); const geminiClient = config.getGeminiClient(); - // Use GeminiClient to manage chat lifecycle properly - // This ensures geminiClient.chat is in sync with the session's chat - // - // Note: When loading a session, config.initialize() has already been called - // in newSessionConfig(), which in turn calls geminiClient.initialize(). - // The GeminiClient.initialize() method checks config.getResumedSessionData() - // and automatically loads the conversation history into the chat instance. - // So we only need to initialize if it hasn't been done yet. if (!geminiClient.isInitialized()) { await geminiClient.initialize(); } - // Now get the chat instance that's managed by GeminiClient const chat = geminiClient.getChat(); const session = new Session( sessionId, chat, config, - this.client, + this.connection, this.settings, ); this.sessions.set(sessionId, session); @@ -514,9 +481,7 @@ class GeminiAgent { return session; } - private buildAvailableModels( - config: Config, - ): acp.NewSessionResponse['models'] { + private buildAvailableModels(config: Config): NewSessionResponse['models'] { const rawCurrentModelId = ( config.getModel() || this.config.getModel() || @@ -525,8 +490,6 @@ class GeminiAgent { const currentAuthType = config.getAuthType(); const allConfiguredModels = config.getAllConfiguredModels(); - // Check if current model is a runtime model - // Runtime models use $runtime|${authType}|${modelId} format const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); const currentModelId = activeRuntimeSnapshot ? formatAcpModelId( @@ -535,11 +498,7 @@ class GeminiAgent { ) : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); - const availableModels = allConfiguredModels; - - const mappedAvailableModels = availableModels.map((model) => { - // For runtime models, use runtimeSnapshotId as modelId for ACP protocol - // This allows ACP clients to correctly identify and switch to runtime models + const mappedAvailableModels = allConfiguredModels.map((model) => { const effectiveModelId = model.isRuntimeModel && model.runtimeSnapshotId ? model.runtimeSnapshotId @@ -561,7 +520,7 @@ class GeminiAgent { }; } - private buildModesData(config: Config): acp.ModesData { + private buildModesData(config: Config): SessionModeState { const currentApprovalMode = config.getApprovalMode(); const availableModes = APPROVAL_MODES.map((mode) => ({ @@ -576,14 +535,66 @@ class GeminiAgent { }; } + private buildConfigOptions(config: Config): SessionConfigOption[] { + const currentApprovalMode = config.getApprovalMode(); + const allConfiguredModels = config.getAllConfiguredModels(); + const rawCurrentModelId = (config.getModel() || '').trim(); + const currentAuthType = config.getAuthType?.(); + + const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); + const currentModelId = activeRuntimeSnapshot + ? formatAcpModelId( + activeRuntimeSnapshot.id, + activeRuntimeSnapshot.authType, + ) + : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); + + const modeOptions = APPROVAL_MODES.map((mode) => ({ + value: mode, + name: APPROVAL_MODE_INFO[mode].name, + description: APPROVAL_MODE_INFO[mode].description, + })); + + const modeConfigOption: SessionConfigOption = { + id: 'mode', + name: 'Mode', + description: 'Session permission mode', + category: 'mode', + type: 'select' as const, + currentValue: currentApprovalMode, + options: modeOptions, + }; + + const modelOptions = allConfiguredModels.map((model) => { + const effectiveModelId = + model.isRuntimeModel && model.runtimeSnapshotId + ? model.runtimeSnapshotId + : model.id; + return { + value: formatAcpModelId(effectiveModelId, model.authType), + name: model.label, + description: model.description ?? '', + }; + }); + + const modelConfigOption: SessionConfigOption = { + id: 'model', + name: 'Model', + description: 'AI model to use', + category: 'model', + type: 'select' as const, + currentValue: currentModelId, + options: modelOptions, + }; + + return [modeConfigOption, modelConfigOption]; + } + private formatCurrentModelId( baseModelId: string, authType?: AuthType, ): string { - if (!baseModelId) { - return baseModelId; - } - + if (!baseModelId) return baseModelId; return authType ? formatAcpModelId(baseModelId, authType) : baseModelId; } } diff --git a/packages/cli/src/acp-integration/authMethods.ts b/packages/cli/src/acp-integration/authMethods.ts index 35cafdc71..1eb0e7845 100644 --- a/packages/cli/src/acp-integration/authMethods.ts +++ b/packages/cli/src/acp-integration/authMethods.ts @@ -5,7 +5,7 @@ */ import { AuthType } from '@qwen-code/qwen-code-core'; -import type { AuthMethod } from './schema.js'; +import type { AuthMethod } from '@agentclientprotocol/sdk'; export function buildAuthMethods(): AuthMethod[] { return [ @@ -13,16 +13,20 @@ export function buildAuthMethods(): AuthMethod[] { id: AuthType.USE_OPENAI, name: 'Use OpenAI API key', description: 'Requires setting the `OPENAI_API_KEY` environment variable', - type: 'terminal', - args: ['--auth-type=openai'], + _meta: { + type: 'terminal', + args: ['--auth-type=openai'], + }, }, { id: AuthType.QWEN_OAUTH, name: 'Qwen OAuth', description: 'OAuth authentication for Qwen models with free daily requests', - type: 'terminal', - args: ['--auth-type=qwen-oauth'], + _meta: { + type: 'terminal', + args: ['--auth-type=qwen-oauth'], + }, }, ]; } diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts deleted file mode 100644 index 021bf7c93..000000000 --- a/packages/cli/src/acp-integration/schema.ts +++ /dev/null @@ -1,708 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { z } from 'zod'; - -export const AGENT_METHODS = { - authenticate: 'authenticate', - initialize: 'initialize', - session_cancel: 'session/cancel', - session_load: 'session/load', - session_new: 'session/new', - session_prompt: 'session/prompt', - session_list: 'session/list', - session_set_mode: 'session/set_mode', - session_set_model: 'session/set_model', - session_set_config_option: 'session/set_config_option', -}; - -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', -}; - -export const PROTOCOL_VERSION = 1; - -export type WriteTextFileRequest = z.infer; - -export type ReadTextFileRequest = z.infer; - -export type PermissionOptionKind = z.infer; - -export type Role = z.infer; - -export type TextResourceContents = z.infer; - -export type BlobResourceContents = z.infer; - -export type ToolKind = z.infer; - -export type ToolCallStatus = z.infer; - -export type WriteTextFileResponse = z.infer; - -export type ReadTextFileResponse = z.infer; - -export type RequestPermissionOutcome = z.infer< - typeof requestPermissionOutcomeSchema ->; -export type SessionListItem = z.infer; -export type ListSessionsRequest = z.infer; -export type ListSessionsResponse = z.infer; - -export type CancelNotification = z.infer; - -export type AuthenticateRequest = z.infer; - -// Note: NewSessionResponse type is defined later after newSessionResponseSchema - -export type LoadSessionResponse = z.infer; - -export type StopReason = z.infer; - -export type PromptResponse = z.infer; - -export type ToolCallLocation = z.infer; - -export type PlanEntry = z.infer; - -export type PermissionOption = z.infer; - -export type Annotations = z.infer; - -export type RequestPermissionResponse = z.infer< - typeof requestPermissionResponseSchema ->; - -export type FileSystemCapability = z.infer; - -export type EnvVariable = z.infer; - -export type McpServer = z.infer; - -export type AgentCapabilities = z.infer; - -export type AuthMethod = z.infer; - -export type ModeInfo = z.infer; - -export type ModesData = z.infer; - -export type AgentInfo = z.infer; -export type ModelInfo = z.infer; - -export type PromptCapabilities = z.infer; - -export type ClientResponse = z.infer; - -export type ClientNotification = z.infer; - -export type EmbeddedResourceResource = z.infer< - typeof embeddedResourceResourceSchema ->; - -export type NewSessionRequest = z.infer; - -export type LoadSessionRequest = z.infer; - -export type InitializeResponse = z.infer; - -export type ContentBlock = z.infer; - -export type ToolCallContent = z.infer; - -export type ToolCall = z.infer; - -export type ClientCapabilities = z.infer; - -export type PromptRequest = z.infer; - -export type SessionUpdate = z.infer; - -export type AgentResponse = z.infer; - -export type RequestPermissionRequest = z.infer< - typeof requestPermissionRequestSchema ->; - -export type InitializeRequest = z.infer; - -export type SessionNotification = z.infer; - -export type ClientRequest = z.infer; - -export type AgentRequest = z.infer; - -export type AgentNotification = z.infer; - -export type ApprovalModeValue = z.infer; - -export type SetModeRequest = z.infer; - -export type SetModeResponse = z.infer; - -export type AvailableCommandInput = z.infer; - -export type AvailableCommand = z.infer; - -export type AvailableCommandsUpdate = z.infer< - typeof availableCommandsUpdateSchema ->; - -export const writeTextFileRequestSchema = z.object({ - content: z.string(), - path: z.string(), - sessionId: z.string(), -}); - -export const readTextFileRequestSchema = z.object({ - limit: z.number().optional().nullable(), - line: z.number().optional().nullable(), - path: z.string(), - sessionId: z.string(), -}); - -export const permissionOptionKindSchema = z.union([ - z.literal('allow_once'), - z.literal('allow_always'), - z.literal('reject_once'), - z.literal('reject_always'), -]); - -export const roleSchema = z.union([z.literal('assistant'), z.literal('user')]); - -export const textResourceContentsSchema = z.object({ - mimeType: z.string().optional().nullable(), - text: z.string(), - uri: z.string(), -}); - -export const blobResourceContentsSchema = z.object({ - blob: z.string(), - mimeType: z.string().optional().nullable(), - uri: z.string(), -}); - -export const toolKindSchema = z.union([ - z.literal('read'), - z.literal('edit'), - z.literal('delete'), - z.literal('move'), - z.literal('search'), - z.literal('execute'), - z.literal('think'), - z.literal('fetch'), - z.literal('switch_mode'), - z.literal('other'), -]); - -export const toolCallStatusSchema = z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed'), - z.literal('failed'), -]); - -export const writeTextFileResponseSchema = z.null(); - -export const readTextFileResponseSchema = z.object({ - content: z.string(), -}); - -export const requestPermissionOutcomeSchema = z.union([ - z.object({ - outcome: z.literal('cancelled'), - }), - z.object({ - optionId: z.string(), - outcome: z.literal('selected'), - }), -]); - -export const cancelNotificationSchema = z.object({ - sessionId: z.string(), -}); - -export const approvalModeValueSchema = z.union([ - z.literal('plan'), - z.literal('default'), - z.literal('auto-edit'), - z.literal('yolo'), -]); - -export const setModeRequestSchema = z.object({ - sessionId: z.string(), - modeId: approvalModeValueSchema, -}); - -export const setModeResponseSchema = z.object({ - modeId: approvalModeValueSchema, -}); - -export const authenticateRequestSchema = z.object({ - methodId: z.string(), -}); - -export const authenticateUpdateSchema = z.object({ - _meta: z.object({ - authUri: z.string(), - }), -}); - -export type AuthenticateUpdate = z.infer; - -export const acpMetaSchema = z.record(z.unknown()).nullable().optional(); - -export const modelIdSchema = z.string(); - -export const modelInfoSchema = z.object({ - _meta: acpMetaSchema, - description: z.string().nullable().optional(), - modelId: modelIdSchema, - name: z.string(), -}); - -export const setModelRequestSchema = z.object({ - sessionId: z.string(), - modelId: z.string(), -}); - -export const setModelResponseSchema = z.object({ - modelId: z.string(), -}); - -export type SetModelRequest = z.infer; -export type SetModelResponse = z.infer; - -export const sessionModelStateSchema = z.object({ - _meta: acpMetaSchema, - availableModels: z.array(modelInfoSchema), - currentModelId: modelIdSchema, -}); - -// Note: newSessionResponseSchema is defined later in the file after modesDataSchema - -export const loadSessionResponseSchema = z.null(); - -export const sessionListItemSchema = z.object({ - cwd: z.string(), - filePath: z.string().optional(), - gitBranch: z.string().optional(), - messageCount: z.number().optional(), - mtime: z.number().optional(), - prompt: z.string().optional(), - sessionId: z.string(), - startTime: z.string().optional(), - title: z.string(), - updatedAt: z.string(), -}); - -export const listSessionsResponseSchema = z.object({ - hasMore: z.boolean().optional(), - items: z.array(sessionListItemSchema).optional(), - nextCursor: z.number().optional(), - sessions: z.array(sessionListItemSchema), -}); - -export const listSessionsRequestSchema = z.object({ - cursor: z.number().optional(), - cwd: z.string().optional(), - size: z.number().optional(), -}); - -export const stopReasonSchema = z.union([ - z.literal('end_turn'), - z.literal('max_tokens'), - z.literal('refusal'), - z.literal('cancelled'), -]); - -export const promptResponseSchema = z.object({ - stopReason: stopReasonSchema, -}); - -export const toolCallLocationSchema = z.object({ - line: z.number().optional().nullable(), - path: z.string(), -}); - -export const planEntrySchema = z.object({ - content: z.string(), - priority: z.union([z.literal('high'), z.literal('medium'), z.literal('low')]), - status: z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed'), - ]), -}); - -export const permissionOptionSchema = z.object({ - kind: permissionOptionKindSchema, - name: z.string(), - optionId: z.string(), -}); - -export const annotationsSchema = z.object({ - audience: z.array(roleSchema).optional().nullable(), - lastModified: z.string().optional().nullable(), - 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(), - toolName: z.string().optional().nullable(), - parentToolCallId: z.string().optional().nullable(), - subagentType: z.string().optional().nullable(), - /** Server-side timestamp (ms since epoch) for correct message ordering */ - timestamp: z.number().optional().nullable(), -}); - -export type SessionUpdateMeta = z.infer; - -export const requestPermissionResponseSchema = z.object({ - outcome: requestPermissionOutcomeSchema, -}); - -export const fileSystemCapabilitySchema = z.object({ - readTextFile: z.boolean(), - writeTextFile: z.boolean(), -}); - -export const envVariableSchema = z.object({ - name: z.string(), - value: z.string(), -}); - -export const mcpServerSchema = z.object({ - args: z.array(z.string()), - command: z.string(), - env: z.array(envVariableSchema), - name: z.string(), -}); - -export const promptCapabilitiesSchema = z.object({ - audio: z.boolean().optional(), - embeddedContext: z.boolean().optional(), - image: z.boolean().optional(), -}); - -export const agentCapabilitiesSchema = z.object({ - loadSession: z.boolean().optional(), - promptCapabilities: promptCapabilitiesSchema.optional(), - sessionCapabilities: z - .object({ - list: z.object({}).optional(), - resume: z.object({}).optional(), - }) - .optional(), -}); - -export const authMethodSchema = z.object({ - args: z.array(z.string()).optional(), - description: z.string().nullable(), - env: z.record(z.string()).optional(), - id: z.string(), - name: z.string(), - type: z.string().optional(), -}); - -export const clientResponseSchema = z.union([ - writeTextFileResponseSchema, - readTextFileResponseSchema, - requestPermissionResponseSchema, -]); - -export const clientNotificationSchema = cancelNotificationSchema; - -export const embeddedResourceResourceSchema = z.union([ - textResourceContentsSchema, - blobResourceContentsSchema, -]); - -export const newSessionRequestSchema = z.object({ - cwd: z.string(), - mcpServers: z.array(mcpServerSchema), -}); - -export const loadSessionRequestSchema = z.object({ - cwd: z.string(), - mcpServers: z.array(mcpServerSchema), - sessionId: z.string(), -}); - -export const modeInfoSchema = z.object({ - id: approvalModeValueSchema, - name: z.string(), - description: z.string(), -}); - -export const modesDataSchema = z.object({ - currentModeId: approvalModeValueSchema, - availableModes: z.array(modeInfoSchema), -}); - -export const configOptionSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string(), - category: z.string(), - type: z.string(), - currentValue: z.string(), - options: z.array( - z.object({ - value: z.string(), - name: z.string(), - description: z.string(), - }), - ), -}); - -export type ConfigOption = z.infer; - -export const setConfigOptionRequestSchema = z.object({ - sessionId: z.string(), - configId: z.string(), - value: z.unknown(), -}); - -export const setConfigOptionResponseSchema = z.object({ - configOptions: z.array(configOptionSchema), -}); - -export type SetConfigOptionRequest = z.infer< - typeof setConfigOptionRequestSchema ->; -export type SetConfigOptionResponse = z.infer< - typeof setConfigOptionResponseSchema ->; - -// newSessionResponseSchema includes modes and configOptions for ACP/Zed integration -export const newSessionResponseSchema = z.object({ - sessionId: z.string(), - models: sessionModelStateSchema, - modes: modesDataSchema, - configOptions: z.array(configOptionSchema), -}); - -export type NewSessionResponse = z.infer; - -export const agentInfoSchema = z.object({ - name: z.string(), - title: z.string(), - version: z.string(), -}); - -export const initializeResponseSchema = z.object({ - agentCapabilities: agentCapabilitiesSchema, - agentInfo: agentInfoSchema, - authMethods: z.array(authMethodSchema), - modes: modesDataSchema, - protocolVersion: z.number(), -}); - -export const contentBlockSchema = z.union([ - z.object({ - annotations: annotationsSchema.optional().nullable(), - text: z.string(), - type: z.literal('text'), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - data: z.string(), - mimeType: z.string(), - type: z.literal('image'), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - data: z.string(), - mimeType: z.string(), - type: z.literal('audio'), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - description: z.string().optional().nullable(), - mimeType: z.string().optional().nullable(), - name: z.string(), - size: z.number().optional().nullable(), - title: z.string().optional().nullable(), - type: z.literal('resource_link'), - uri: z.string(), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - resource: embeddedResourceResourceSchema, - type: z.literal('resource'), - }), -]); - -export const toolCallContentSchema = z.union([ - z.object({ - content: contentBlockSchema, - type: z.literal('content'), - }), - z.object({ - newText: z.string(), - oldText: z.string().nullable(), - path: z.string(), - type: z.literal('diff'), - }), -]); - -export const toolCallSchema = z.object({ - content: z.array(toolCallContentSchema).optional(), - kind: toolKindSchema, - locations: z.array(toolCallLocationSchema).optional(), - rawInput: z.unknown().optional(), - status: toolCallStatusSchema, - title: z.string(), - toolCallId: z.string(), -}); - -export const clientCapabilitiesSchema = z.object({ - fs: fileSystemCapabilitySchema, -}); - -export const promptRequestSchema = z.object({ - prompt: z.array(contentBlockSchema), - sessionId: z.string(), -}); - -export const availableCommandInputSchema = z.object({ - hint: z.string(), -}); - -export const availableCommandSchema = z.object({ - description: z.string(), - input: availableCommandInputSchema.nullable().optional(), - name: z.string(), -}); - -export const availableCommandsUpdateSchema = z.object({ - availableCommands: z.array(availableCommandSchema), - sessionUpdate: z.literal('available_commands_update'), -}); - -export const currentModeUpdateSchema = z.object({ - sessionUpdate: z.literal('current_mode_update'), - modeId: approvalModeValueSchema, -}); - -export type CurrentModeUpdate = z.infer; - -export const currentModelUpdateSchema = z.object({ - sessionUpdate: z.literal('current_model_update'), - model: modelInfoSchema, -}); - -export type CurrentModelUpdate = z.infer; - -export const sessionUpdateSchema = z.union([ - z.object({ - content: contentBlockSchema, - sessionUpdate: z.literal('user_message_chunk'), - _meta: sessionUpdateMetaSchema.optional().nullable(), - }), - 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(), - kind: toolKindSchema, - locations: z.array(toolCallLocationSchema).optional(), - rawInput: z.unknown().optional(), - _meta: sessionUpdateMetaSchema.optional().nullable(), - sessionUpdate: z.literal('tool_call'), - status: toolCallStatusSchema, - title: z.string(), - toolCallId: z.string(), - }), - z.object({ - content: z.array(toolCallContentSchema).optional().nullable(), - kind: toolKindSchema.optional().nullable(), - locations: z.array(toolCallLocationSchema).optional().nullable(), - rawInput: z.unknown().optional(), - rawOutput: z.unknown().optional(), - _meta: sessionUpdateMetaSchema.optional().nullable(), - sessionUpdate: z.literal('tool_call_update'), - status: toolCallStatusSchema.optional().nullable(), - title: z.string().optional().nullable(), - toolCallId: z.string(), - }), - z.object({ - entries: z.array(planEntrySchema), - sessionUpdate: z.literal('plan'), - }), - currentModeUpdateSchema, - currentModelUpdateSchema, - availableCommandsUpdateSchema, -]); - -export const agentResponseSchema = z.union([ - initializeResponseSchema, - newSessionResponseSchema, - loadSessionResponseSchema, - promptResponseSchema, - listSessionsResponseSchema, - setModeResponseSchema, - setModelResponseSchema, -]); - -export const requestPermissionRequestSchema = z.object({ - options: z.array(permissionOptionSchema), - sessionId: z.string(), - toolCall: toolCallSchema, -}); - -export const initializeRequestSchema = z.object({ - clientCapabilities: clientCapabilitiesSchema, - protocolVersion: z.number(), -}); - -export const sessionNotificationSchema = z.object({ - sessionId: z.string(), - update: sessionUpdateSchema, -}); - -export const clientRequestSchema = z.union([ - writeTextFileRequestSchema, - readTextFileRequestSchema, - requestPermissionRequestSchema, -]); - -export const agentRequestSchema = z.union([ - initializeRequestSchema, - authenticateRequestSchema, - newSessionRequestSchema, - loadSessionRequestSchema, - promptRequestSchema, - listSessionsRequestSchema, - setModeRequestSchema, - setModelRequestSchema, - setConfigOptionRequestSchema, -]); - -export const agentNotificationSchema = sessionNotificationSchema; diff --git a/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts index e8dc34968..628807fe2 100644 --- a/packages/cli/src/acp-integration/service/filesystem.test.ts +++ b/packages/cli/src/acp-integration/service/filesystem.test.ts @@ -7,7 +7,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { FileSystemService } from '@qwen-code/qwen-code-core'; import { AcpFileSystemService } from './filesystem.js'; -import { ACP_ERROR_CODES } from '../errorCodes.js'; +import type { AgentSideConnection } from '@agentclientprotocol/sdk'; + +const RESOURCE_NOT_FOUND_CODE = -32002; +const INTERNAL_ERROR_CODE = -32603; const createFallback = (): FileSystemService => ({ readTextFile: vi.fn(), @@ -26,7 +29,7 @@ describe('AcpFileSystemService', () => { readTextFile: vi .fn() .mockResolvedValue({ content: '\ufeff// BOM file' }), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( client, @@ -40,7 +43,6 @@ describe('AcpFileSystemService', () => { expect(client.readTextFile).toHaveBeenCalledWith({ path: '/test/file.txt', sessionId: 'session-1', - line: null, limit: 1, }); }); @@ -48,7 +50,7 @@ describe('AcpFileSystemService', () => { it('detects no BOM through ACP client when content does not start with U+FEFF', async () => { const client = { readTextFile: vi.fn().mockResolvedValue({ content: '// No BOM file' }), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( client, @@ -64,7 +66,7 @@ describe('AcpFileSystemService', () => { it('falls back to local filesystem when ACP client fails', async () => { const client = { readTextFile: vi.fn().mockRejectedValue(new Error('Network error')), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const fallback = createFallback(); (fallback.detectFileBOM as ReturnType).mockResolvedValue( @@ -86,7 +88,7 @@ describe('AcpFileSystemService', () => { it('falls back to local filesystem when readTextFile capability is disabled', async () => { const client = { readTextFile: vi.fn(), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const fallback = createFallback(); (fallback.detectFileBOM as ReturnType).mockResolvedValue( @@ -110,12 +112,12 @@ describe('AcpFileSystemService', () => { describe('readTextFile ENOENT handling', () => { it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => { const resourceNotFoundError = { - code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND, + code: RESOURCE_NOT_FOUND_CODE, message: 'File not found', }; const client = { readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( client, @@ -133,12 +135,12 @@ describe('AcpFileSystemService', () => { it('re-throws other errors unchanged', async () => { const otherError = { - code: ACP_ERROR_CODES.INTERNAL_ERROR, + code: INTERNAL_ERROR_CODE, message: 'Internal error', }; const client = { readTextFile: vi.fn().mockRejectedValue(otherError), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( client, @@ -148,7 +150,7 @@ describe('AcpFileSystemService', () => { ); await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, + code: INTERNAL_ERROR_CODE, message: 'Internal error', }); }); @@ -156,7 +158,7 @@ describe('AcpFileSystemService', () => { it('uses fallback when readTextFile capability is disabled', async () => { const client = { readTextFile: vi.fn(), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const fallback = createFallback(); (fallback.readTextFile as ReturnType).mockResolvedValue( diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index b20d5f0ff..25ad296fb 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -1,24 +1,26 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import type { - FileSystemService, + AgentSideConnection, + FileSystemCapability, +} from '@agentclientprotocol/sdk'; +import { RequestError } from '@agentclientprotocol/sdk'; +import type { FileReadResult, + FileSystemService, } from '@qwen-code/qwen-code-core'; -import type * as acp from '../acp.js'; -import { ACP_ERROR_CODES } from '../errorCodes.js'; -/** - * ACP client-based implementation of FileSystemService - */ +const RESOURCE_NOT_FOUND_CODE = -32002; + export class AcpFileSystemService implements FileSystemService { constructor( - private readonly client: acp.Client, + private readonly connection: AgentSideConnection, private readonly sessionId: string, - private readonly capabilities: acp.FileSystemCapability, + private readonly capabilities: FileSystemCapability, private readonly fallback: FileSystemService, ) {} @@ -29,19 +31,19 @@ export class AcpFileSystemService implements FileSystemService { let response: { content: string }; try { - response = await this.client.readTextFile({ + response = await this.connection.readTextFile({ path: filePath, sessionId: this.sessionId, - line: null, - limit: null, }); } catch (error) { const errorCode = - typeof error === 'object' && error !== null && 'code' in error - ? (error as { code?: unknown }).code - : undefined; + error instanceof RequestError + ? error.code + : typeof error === 'object' && error !== null && 'code' in error + ? (error as { code?: unknown }).code + : undefined; - if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) { + if (errorCode === RESOURCE_NOT_FOUND_CODE) { const err = new Error( `File not found: ${filePath}`, ) as NodeJS.ErrnoException; @@ -72,10 +74,9 @@ export class AcpFileSystemService implements FileSystemService { return this.fallback.writeTextFile(filePath, content, options); } - // Prepend BOM character if requested const finalContent = options?.bom ? '\uFEFF' + content : content; - await this.client.writeTextFile({ + await this.connection.writeTextFile({ path: filePath, content: finalContent, sessionId: this.sessionId, @@ -83,17 +84,13 @@ export class AcpFileSystemService implements FileSystemService { } async detectFileBOM(filePath: string): Promise { - // Try to detect BOM through ACP client first by reading first line if (this.capabilities.readTextFile) { try { - const response = await this.client.readTextFile({ + const response = await this.connection.readTextFile({ path: filePath, sessionId: this.sessionId, - line: null, limit: 1, }); - // Check if content starts with BOM character (U+FEFF) - // Use codePointAt for better Unicode support and check content length first return ( response.content.length > 0 && response.content.codePointAt(0) === 0xfeff @@ -102,7 +99,6 @@ export class AcpFileSystemService implements FileSystemService { // Fall through to fallback if ACP read fails } } - // Fall back to local filesystem detection return this.fallback.detectFileBOM(filePath); } diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts index 9e8a5ddcc..d2a16fbc6 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts @@ -464,11 +464,11 @@ describe('HistoryReplayer', () => { content: { type: 'text', text: '' }, _meta: { usage: { - promptTokens: 100, - completionTokens: 50, - thoughtsTokens: undefined, + inputTokens: 100, + outputTokens: 50, totalTokens: 150, - cachedTokens: undefined, + thoughtTokens: undefined, + cachedReadTokens: undefined, }, }, }); diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index e562d8b86..346537409 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -12,7 +12,10 @@ import { Session } from './Session.js'; import type { Config, GeminiChat } from '@qwen-code/qwen-code-core'; import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core'; import * as core from '@qwen-code/qwen-code-core'; -import type * as acp from '../acp.js'; +import type { + AgentSideConnection, + PromptRequest, +} from '@agentclientprotocol/sdk'; import type { LoadedSettings } from '../../config/settings.js'; import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; @@ -24,7 +27,7 @@ vi.mock('../../nonInteractiveCliCommands.js', () => ({ describe('Session', () => { let mockChat: GeminiChat; let mockConfig: Config; - let mockClient: acp.Client; + let mockClient: AgentSideConnection; let mockSettings: LoadedSettings; let session: Session; let currentModel: string; @@ -76,8 +79,8 @@ describe('Session', () => { requestPermission: vi.fn().mockResolvedValue({ outcome: { outcome: 'selected', optionId: 'proceed_once' }, }), - sendCustomNotification: vi.fn().mockResolvedValue(undefined), - } as unknown as acp.Client; + extNotification: vi.fn().mockResolvedValue(undefined), + } as unknown as AgentSideConnection; mockSettings = { merged: {}, @@ -103,20 +106,19 @@ describe('Session', () => { ['auto-edit', ApprovalMode.AUTO_EDIT], ['yolo', ApprovalMode.YOLO], ] as const)('maps %s mode', async (modeId, expected) => { - const result = await session.setMode({ + await session.setMode({ sessionId: 'test-session-id', modeId, }); expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected); - expect(result).toEqual({ modeId }); }); }); describe('setModel', () => { it('sets model via config and returns current model', async () => { const requested = `qwen3-coder-plus(${AuthType.USE_OPENAI})`; - const result = await session.setModel({ + await session.setModel({ sessionId: 'test-session-id', modelId: ` ${requested} `, }); @@ -126,10 +128,6 @@ describe('Session', () => { 'qwen3-coder-plus', undefined, ); - expect(mockConfig.getModel).toHaveBeenCalled(); - expect(result).toEqual({ - modelId: `qwen3-coder-plus(${AuthType.USE_OPENAI})`, - }); }); it('rejects empty/whitespace model IDs', async () => { @@ -221,7 +219,7 @@ describe('Session', () => { .fn() .mockResolvedValue((async function* () {})()); - const promptRequest: acp.PromptRequest = { + const promptRequest: PromptRequest = { sessionId: 'test-session-id', prompt: [ { type: 'text', text: 'Check this file' }, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 702f66a07..2b93a0256 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -36,7 +36,25 @@ import { readManyFiles, } from '@qwen-code/qwen-code-core'; -import * as acp from '../acp.js'; +import { RequestError } from '@agentclientprotocol/sdk'; +import type { + AvailableCommand, + ContentBlock, + EmbeddedResourceResource, + PermissionOption, + PromptRequest, + PromptResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + SessionUpdate, + SetSessionModeRequest, + SetSessionModeResponse, + SetSessionModelRequest, + SetSessionModelResponse, + ToolCallContent, + AgentSideConnection, +} from '@agentclientprotocol/sdk'; import type { LoadedSettings } from '../../config/settings.js'; import { z } from 'zod'; import { normalizePartList } from '../../utils/nonInteractiveHelpers.js'; @@ -45,24 +63,15 @@ import { getAvailableCommands, type NonInteractiveSlashCommandResult, } from '../../nonInteractiveCliCommands.js'; -import type { - AvailableCommand, - AvailableCommandsUpdate, - SetModeRequest, - SetModeResponse, - SetModelRequest, - SetModelResponse, - ApprovalModeValue, - CurrentModeUpdate, -} from '../schema.js'; import { isSlashCommand } from '../../ui/utils/commandUtils.js'; -import { - formatAcpModelId, - parseAcpModelOption, -} from '../../utils/acpModelUtils.js'; +import { parseAcpModelOption } from '../../utils/acpModelUtils.js'; // Import modular session components -import type { SessionContext, ToolCallStartParams } from './types.js'; +import type { + ApprovalModeValue, + SessionContext, + ToolCallStartParams, +} from './types.js'; import { HistoryReplayer } from './HistoryReplayer.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { PlanEmitter } from './emitters/PlanEmitter.js'; @@ -96,7 +105,7 @@ export class Session implements SessionContext { id: string, private readonly chat: GeminiChat, readonly config: Config, - private readonly client: acp.Client, + private readonly client: AgentSideConnection, private readonly settings: LoadedSettings, ) { this.sessionId = id; @@ -133,7 +142,7 @@ export class Session implements SessionContext { this.pendingPrompt = null; } - async prompt(params: acp.PromptRequest): Promise { + async prompt(params: PromptRequest): Promise { this.pendingPrompt?.abort(); const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; @@ -254,10 +263,7 @@ export class Session implements SessionContext { } } catch (error) { if (getErrorStatus(error) === 429) { - throw new acp.RequestError( - 429, - 'Rate limit exceeded. Try again later.', - ); + throw new RequestError(429, 'Rate limit exceeded. Try again later.'); } throw error; @@ -287,8 +293,8 @@ export class Session implements SessionContext { return { stopReason: 'end_turn' }; } - async sendUpdate(update: acp.SessionUpdate): Promise { - const params: acp.SessionNotification = { + async sendUpdate(update: SessionUpdate): Promise { + const params: SessionNotification = { sessionId: this.sessionId, update, }; @@ -314,7 +320,7 @@ export class Session implements SessionContext { }), ); - const update: AvailableCommandsUpdate = { + const update: SessionUpdate = { sessionUpdate: 'available_commands_update', availableCommands, }; @@ -331,8 +337,8 @@ export class Session implements SessionContext { * Used by SubAgentTracker for sub-agent approval requests. */ async requestPermission( - params: acp.RequestPermissionRequest, - ): Promise { + params: RequestPermissionRequest, + ): Promise { return this.client.requestPermission(params); } @@ -340,7 +346,9 @@ export class Session implements SessionContext { * Sets the approval mode for the current session. * Maps ACP approval mode values to core ApprovalMode enum. */ - async setMode(params: SetModeRequest): Promise { + async setMode( + params: SetSessionModeRequest, + ): Promise { const modeMap: Record = { plan: ApprovalMode.PLAN, default: ApprovalMode.DEFAULT, @@ -348,21 +356,21 @@ export class Session implements SessionContext { yolo: ApprovalMode.YOLO, }; - const approvalMode = modeMap[params.modeId]; + const approvalMode = modeMap[params.modeId as ApprovalModeValue]; this.config.setApprovalMode(approvalMode); - - return { modeId: params.modeId }; } /** * Sets the model for the current session. * Validates the model ID and switches the model via Config. */ - async setModel(params: SetModelRequest): Promise { + async setModel( + params: SetSessionModelRequest, + ): Promise { const rawModelId = params.modelId.trim(); if (!rawModelId) { - throw acp.RequestError.invalidParams('modelId cannot be empty'); + throw RequestError.invalidParams(undefined, 'modelId cannot be empty'); } const parsed = parseAcpModelOption(rawModelId); @@ -370,7 +378,8 @@ export class Session implements SessionContext { const selectedAuthType = parsed.authType ?? previousAuthType; if (!selectedAuthType) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `authType cannot be determined for modelId "${parsed.modelId}"`, ); } @@ -383,14 +392,6 @@ export class Session implements SessionContext { ? { requireCachedCredentials: true } : undefined, ); - - // Get updated model info - const currentModel = this.config.getModel(); - const currentAuthType = this.config.getAuthType?.() ?? selectedAuthType; - - return { - modelId: formatAcpModelId(currentModel, currentAuthType), - }; } /** @@ -413,9 +414,9 @@ export class Session implements SessionContext { break; } - const update: CurrentModeUpdate = { + const update: SessionUpdate = { sessionUpdate: 'current_mode_update', - modeId: newModeId, + currentModeId: newModeId, }; await this.sendUpdate(update); @@ -529,7 +530,7 @@ export class Session implements SessionContext { } if (confirmationDetails) { - const content: acp.ToolCallContent[] = []; + const content: ToolCallContent[] = []; if (confirmationDetails.type === 'edit') { content.push({ @@ -554,7 +555,7 @@ export class Session implements SessionContext { // Map tool kind, using switch_mode for exit_plan_mode per ACP spec const mappedKind = this.toolCallEmitter.mapToolKind(tool.kind, fc.name); - const params: acp.RequestPermissionRequest = { + const params: RequestPermissionRequest = { sessionId: this.sessionId, options: toPermissionOptions(confirmationDetails), toolCall: { @@ -732,7 +733,7 @@ export class Session implements SessionContext { */ async #processSlashCommandResult( result: NonInteractiveSlashCommandResult, - originalPrompt: acp.ContentBlock[], + originalPrompt: ContentBlock[], ): Promise { switch (result.type) { case 'submit_prompt': @@ -741,9 +742,7 @@ export class Session implements SessionContext { return normalizePartList(result.content); case 'message': { - // 'message' type is not ideal for ACP mode, but we handle it for compatibility - // by converting it to a stream_messages-like notification - await this.client.sendCustomNotification('_qwencode/slash_command', { + await this.client.extNotification('_qwencode/slash_command', { sessionId: this.sessionId, command: originalPrompt .filter((block) => block.type === 'text') @@ -770,7 +769,7 @@ export class Session implements SessionContext { // Stream all messages to the client for await (const msg of result.messages) { - await this.client.sendCustomNotification('_qwencode/slash_command', { + await this.client.extNotification('_qwencode/slash_command', { sessionId: this.sessionId, command, messageType: msg.messageType, @@ -812,12 +811,12 @@ export class Session implements SessionContext { } async #resolvePrompt( - message: acp.ContentBlock[], + message: ContentBlock[], abortSignal: AbortSignal, ): Promise { const FILE_URI_SCHEME = 'file://'; - const embeddedContext: acp.EmbeddedResourceResource[] = []; + const embeddedContext: EmbeddedResourceResource[] = []; const parts = message.map((part) => { switch (part.type) { @@ -966,7 +965,7 @@ const basicPermissionOptions = [ function toPermissionOptions( confirmation: ToolCallConfirmationDetails, -): acp.PermissionOption[] { +): PermissionOption[] { switch (confirmation.type) { case 'edit': return [ diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts index 96b8bd998..86832afdd 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts @@ -23,7 +23,7 @@ import { ToolConfirmationOutcome, TodoWriteTool, } from '@qwen-code/qwen-code-core'; -import type * as acp from '../acp.js'; +import type { AgentSideConnection } from '@agentclientprotocol/sdk'; import { EventEmitter } from 'node:events'; // Helper to create a mock SubAgentToolCallEvent with required fields @@ -116,7 +116,7 @@ function createStreamTextEvent( describe('SubAgentTracker', () => { let mockContext: SessionContext; - let mockClient: acp.Client; + let mockClient: AgentSideConnection; let sendUpdateSpy: ReturnType; let requestPermissionSpy: ReturnType; let tracker: SubAgentTracker; @@ -143,7 +143,7 @@ describe('SubAgentTracker', () => { mockClient = { requestPermission: requestPermissionSpy, - } as unknown as acp.Client; + } as unknown as AgentSideConnection; tracker = new SubAgentTracker( mockContext, diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts index d020f2a06..acbe95082 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -24,7 +24,12 @@ 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'; +import type { + AgentSideConnection, + PermissionOption, + RequestPermissionRequest, + ToolCallContent, +} from '@agentclientprotocol/sdk'; const debugLogger = createDebugLogger('ACP_SUBAGENT_TRACKER'); @@ -80,7 +85,7 @@ export class SubAgentTracker { constructor( private readonly ctx: SessionContext, - private readonly client: acp.Client, + private readonly client: AgentSideConnection, private readonly parentToolCallId: string, private readonly subagentType: string, ) { @@ -214,7 +219,7 @@ export class SubAgentTracker { if (abortSignal.aborted) return; const state = this.toolStates.get(event.callId); - const content: acp.ToolCallContent[] = []; + const content: ToolCallContent[] = []; // Handle edit confirmation type - show diff if (event.confirmationDetails.type === 'edit') { @@ -243,7 +248,7 @@ export class SubAgentTracker { const { title, locations, kind } = this.toolCallEmitter.resolveToolMetadata(event.name, state?.args); - const params: acp.RequestPermissionRequest = { + const params: RequestPermissionRequest = { sessionId: this.ctx.sessionId, options: this.toPermissionOptions(fullConfirmationDetails), toolCall: { @@ -324,7 +329,7 @@ export class SubAgentTracker { */ private toPermissionOptions( confirmation: ToolCallConfirmationDetails, - ): acp.PermissionOption[] { + ): PermissionOption[] { switch (confirmation.type) { case 'edit': return [ diff --git a/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts index b0b05e7e8..dd7529686 100644 --- a/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts @@ -5,7 +5,7 @@ */ import type { SessionContext } from '../types.js'; -import type * as acp from '../../acp.js'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; /** * Abstract base class for all session event emitters. @@ -32,7 +32,7 @@ export abstract class BaseEmitter { /** * Sends a session update to the ACP client. */ - protected async sendUpdate(update: acp.SessionUpdate): Promise { + protected async sendUpdate(update: SessionUpdate): Promise { return this.ctx.sendUpdate(update); } 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 d0b1ae870..d820f6388 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -166,11 +166,11 @@ describe('MessageEmitter', () => { content: { type: 'text', text: '' }, _meta: { usage: { - promptTokens: 100, - completionTokens: 50, - thoughtsTokens: 25, + inputTokens: 100, + outputTokens: 50, totalTokens: 175, - cachedTokens: 10, + thoughtTokens: 25, + cachedReadTokens: 10, }, }, }); @@ -192,11 +192,11 @@ describe('MessageEmitter', () => { content: { type: 'text', text: 'done' }, _meta: { usage: { - promptTokens: 10, - completionTokens: 5, - thoughtsTokens: 2, + inputTokens: 10, + outputTokens: 5, totalTokens: 17, - cachedTokens: 1, + thoughtTokens: 2, + cachedReadTokens: 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 a81520be3..4b2bf82bf 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -5,7 +5,7 @@ */ import type { GenerateContentResponseUsageMetadata } from '@google/genai'; -import type { Usage } from '../../schema.js'; +import type { Usage } from '@agentclientprotocol/sdk'; import { BaseEmitter } from './BaseEmitter.js'; /** @@ -80,11 +80,11 @@ export class MessageEmitter extends BaseEmitter { subagentMeta?: import('../types.js').SubagentMeta, ): Promise { const usage: Usage = { - promptTokens: usageMetadata.promptTokenCount, - completionTokens: usageMetadata.candidatesTokenCount, - thoughtsTokens: usageMetadata.thoughtsTokenCount, - totalTokens: usageMetadata.totalTokenCount, - cachedTokens: usageMetadata.cachedContentTokenCount, + inputTokens: usageMetadata.promptTokenCount ?? 0, + outputTokens: usageMetadata.candidatesTokenCount ?? 0, + totalTokens: usageMetadata.totalTokenCount ?? 0, + thoughtTokens: usageMetadata.thoughtsTokenCount, + cachedReadTokens: usageMetadata.cachedContentTokenCount, }; const meta = diff --git a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts index f6453cffc..3556e0302 100644 --- a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts @@ -6,7 +6,7 @@ import { BaseEmitter } from './BaseEmitter.js'; import type { TodoItem } from '../types.js'; -import type * as acp from '../../acp.js'; +import type { PlanEntry } from '@agentclientprotocol/sdk'; /** * Handles emission of plan/todo updates. @@ -22,7 +22,7 @@ export class PlanEmitter extends BaseEmitter { * @param todos - Array of todo items to send as plan entries */ async emitPlan(todos: TodoItem[]): Promise { - const entries: acp.PlanEntry[] = todos.map((todo) => ({ + const entries: PlanEntry[] = todos.map((todo) => ({ content: todo.content, priority: 'medium' as const, // Default priority since todos don't have priority status: todo.status, diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index dc60e18a2..cfdc02f24 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -13,7 +13,11 @@ import type { ResolvedToolMetadata, SubagentMeta, } from '../types.js'; -import type * as acp from '../../acp.js'; +import type { + ToolCallContent, + ToolCallLocation, + ToolKind, +} from '@agentclientprotocol/sdk'; import type { Part } from '@google/genai'; import { TodoWriteTool, @@ -103,7 +107,7 @@ export class ToolCallEmitter extends BaseEmitter { } // Determine content for the update - let contentArray: acp.ToolCallContent[] = []; + let contentArray: ToolCallContent[] = []; // Special case: diff result from edit tools (format from resultDisplay) const diffContent = this.extractDiffContent(params.resultDisplay); @@ -206,8 +210,8 @@ export class ToolCallEmitter extends BaseEmitter { const tool = toolRegistry.getTool(toolName); let title = tool?.displayName ?? toolName; - let locations: acp.ToolCallLocation[] = []; - let kind: acp.ToolKind = 'other'; + let locations: ToolCallLocation[] = []; + let kind: ToolKind = 'other'; if (tool && args) { try { @@ -234,13 +238,13 @@ export class ToolCallEmitter extends BaseEmitter { * @param kind - The core Kind enum value * @param toolName - Optional tool name to handle special cases like exit_plan_mode */ - mapToolKind(kind: Kind, toolName?: string): acp.ToolKind { + mapToolKind(kind: Kind, toolName?: string): ToolKind { // Special case: exit_plan_mode uses 'switch_mode' kind per ACP spec if (toolName && this.isExitPlanModeTool(toolName)) { return 'switch_mode'; } - const kindMap: Record = { + const kindMap: Record = { [Kind.Read]: 'read', [Kind.Edit]: 'edit', [Kind.Delete]: 'delete', @@ -260,9 +264,7 @@ export class ToolCallEmitter extends BaseEmitter { * Extracts diff content from resultDisplay if it's a diff type (edit tool result). * Returns null if not a diff. */ - private extractDiffContent( - resultDisplay: unknown, - ): acp.ToolCallContent | null { + private extractDiffContent(resultDisplay: unknown): ToolCallContent | null { if (!resultDisplay || typeof resultDisplay !== 'object') return null; const obj = resultDisplay as Record; @@ -284,10 +286,8 @@ export class ToolCallEmitter extends BaseEmitter { * Transforms Part[] to ToolCallContent[]. * Extracts text from functionResponse parts and text parts. */ - private transformPartsToToolCallContent( - parts: Part[], - ): acp.ToolCallContent[] { - const result: acp.ToolCallContent[] = []; + private transformPartsToToolCallContent(parts: Part[]): ToolCallContent[] { + const result: ToolCallContent[] = []; for (const part of parts) { // Handle text parts diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts index 7b82f6e96..58bea4d42 100644 --- a/packages/cli/src/acp-integration/session/types.ts +++ b/packages/cli/src/acp-integration/session/types.ts @@ -6,14 +6,20 @@ import type { Config } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; -import type * as acp from '../acp.js'; +import type { + SessionUpdate, + ToolCallLocation, + ToolKind, +} from '@agentclientprotocol/sdk'; + +export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; /** * Interface for sending session updates to the ACP client. * Implemented by Session class and used by all emitters. */ export interface SessionUpdateSender { - sendUpdate(update: acp.SessionUpdate): Promise; + sendUpdate(update: SessionUpdate): Promise; } /** @@ -91,6 +97,6 @@ export interface TodoItem { */ export interface ResolvedToolMetadata { title: string; - locations: acp.ToolCallLocation[]; - kind: acp.ToolKind; + locations: ToolCallLocation[]; + kind: ToolKind; } diff --git a/packages/cli/src/commands/hooks.tsx b/packages/cli/src/commands/hooks.tsx new file mode 100644 index 000000000..c747c61c2 --- /dev/null +++ b/packages/cli/src/commands/hooks.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { enableCommand } from './hooks/enable.js'; +import { disableCommand } from './hooks/disable.js'; + +export const hooksCommand: CommandModule = { + command: 'hooks ', + aliases: ['hook'], + describe: 'Manage Qwen Code hooks.', + builder: (yargs) => + yargs + .command(enableCommand) + .command(disableCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/hooks/disable.ts b/packages/cli/src/commands/hooks/disable.ts new file mode 100644 index 000000000..8d1324cdb --- /dev/null +++ b/packages/cli/src/commands/hooks/disable.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +const debugLogger = createDebugLogger('HOOKS_DISABLE'); + +interface DisableArgs { + hookName: string; +} + +/** + * Disable a hook by adding it to the disabled list + */ +export async function handleDisableHook(hookName: string): Promise { + const workingDir = process.cwd(); + const settings = loadSettings(workingDir); + + try { + // Get current hooks settings + const mergedSettings = settings.merged as + | Record + | undefined; + const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record< + string, + unknown + >; + const disabledHooks = (hooksSettings['disabled'] || []) as string[]; + + // Check if hook is already disabled + if (disabledHooks.includes(hookName)) { + debugLogger.info(`Hook "${hookName}" is already disabled.`); + return; + } + + // Add hook to disabled list + const newDisabledHooks = [...disabledHooks, hookName]; + const newHooksSettings = { + ...hooksSettings, + disabled: newDisabledHooks, + }; + + // Save updated settings + settings.setValue( + SettingScope.Workspace, + 'hooks' as keyof typeof settings.merged, + newHooksSettings as never, + ); + + debugLogger.info(`✓ Hook "${hookName}" has been disabled.`); + } catch (error) { + debugLogger.error(`Error disabling hook: ${getErrorMessage(error)}`); + } +} + +export const disableCommand: CommandModule = { + command: 'disable ', + describe: 'Disable an active hook', + builder: (yargs) => + yargs.positional('hook-name', { + describe: 'Name of the hook to disable', + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + const args = argv as unknown as DisableArgs; + await handleDisableHook(args.hookName); + process.exit(0); + }, +}; diff --git a/packages/cli/src/commands/hooks/enable.ts b/packages/cli/src/commands/hooks/enable.ts new file mode 100644 index 000000000..863b5b32c --- /dev/null +++ b/packages/cli/src/commands/hooks/enable.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +const debugLogger = createDebugLogger('HOOKS_ENABLE'); + +interface EnableArgs { + hookName: string; +} + +/** + * Enable a hook by removing it from the disabled list + */ +export async function handleEnableHook(hookName: string): Promise { + const workingDir = process.cwd(); + const settings = loadSettings(workingDir); + + try { + // Get current hooks settings + const mergedSettings = settings.merged as + | Record + | undefined; + const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record< + string, + unknown + >; + const disabledHooks = (hooksSettings['disabled'] || []) as string[]; + + // Check if hook is in disabled list + if (!disabledHooks.includes(hookName)) { + debugLogger.info(`Hook "${hookName}" is not disabled.`); + return; + } + + // Remove hook from disabled list + const newDisabledHooks = disabledHooks.filter((h) => h !== hookName); + const newHooksSettings = { + ...hooksSettings, + disabled: newDisabledHooks, + }; + + // Save updated settings + settings.setValue( + SettingScope.Workspace, + 'hooks' as keyof typeof settings.merged, + newHooksSettings as never, + ); + + debugLogger.info(`✓ Hook "${hookName}" has been enabled.`); + } catch (error) { + debugLogger.error(`Error enabling hook: ${getErrorMessage(error)}`); + } +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enable a disabled hook', + builder: (yargs) => + yargs.positional('hook-name', { + describe: 'Name of the hook to enable', + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + const args = argv as unknown as EnableArgs; + await handleEnableHook(args.hookName); + process.exit(0); + }, +}; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 5f08dd382..644fc050c 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -548,6 +548,43 @@ describe('loadCliConfig', () => { vi.restoreAllMocks(); }); + it('should reset context file names to QWEN.md and AGENTS.md by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = {}; + const setGeminiMdFilenameSpy = vi.spyOn( + ServerConfig, + 'setGeminiMdFilename', + ); + + await loadCliConfig(settings, argv); + + expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1); + expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith([ + ServerConfig.DEFAULT_CONTEXT_FILENAME, + ServerConfig.AGENT_CONTEXT_FILENAME, + ]); + }); + + it('should use configured context file name when settings.context.fileName is set', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { + context: { + fileName: 'CUSTOM_AGENTS.md', + }, + }; + const setGeminiMdFilenameSpy = vi.spyOn( + ServerConfig, + 'setGeminiMdFilename', + ); + + await loadCliConfig(settings, argv); + + expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1); + expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith('CUSTOM_AGENTS.md'); + }); + it('should propagate stream-json formats to config', async () => { process.argv = [ 'node', @@ -567,6 +604,35 @@ describe('loadCliConfig', () => { expect(config.getIncludePartialMessages()).toBe(true); }); + it('should reset context filenames to defaults when context.fileName is not configured', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = {}; + const defaultContextFiles = ['QWEN.md', 'AGENTS.md']; + const getAllSpy = vi + .spyOn(ServerConfig, 'getAllGeminiMdFilenames') + .mockReturnValue(defaultContextFiles); + const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename'); + + await loadCliConfig(settings, argv); + + expect(getAllSpy).toHaveBeenCalledTimes(1); + expect(setFilenameSpy).toHaveBeenCalledWith(defaultContextFiles); + }); + + it('should use context.fileName from settings when provided', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { context: { fileName: 'CUSTOM_CONTEXT.md' } }; + const getAllSpy = vi.spyOn(ServerConfig, 'getAllGeminiMdFilenames'); + const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename'); + + await loadCliConfig(settings, argv); + + expect(setFilenameSpy).toHaveBeenCalledWith('CUSTOM_CONTEXT.md'); + expect(getAllSpy).not.toHaveBeenCalled(); + }); + it('should initialize native LSP service when enabled', async () => { process.argv = ['node', 'script.js', '--experimental-lsp']; const argv = await parseArguments(); @@ -1256,7 +1322,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { }); }); - it('should read excludeMCPServers from settings', async () => { + it('should read excludeMCPServers from settings but still return all servers', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -1264,12 +1330,18 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { mcp: { excluded: ['server1', 'server2'] }, }; const config = await loadCliConfig(settings, argv, undefined, []); + // getMcpServers() now returns all servers, use isMcpServerDisabled() to check status expect(config.getMcpServers()).toEqual({ + server1: { url: 'http://localhost:8080' }, + server2: { url: 'http://localhost:8081' }, server3: { url: 'http://localhost:8082' }, }); + expect(config.isMcpServerDisabled('server1')).toBe(true); + expect(config.isMcpServerDisabled('server2')).toBe(true); + expect(config.isMcpServerDisabled('server3')).toBe(false); }); - it('should override allowMCPServers with excludeMCPServers if overlapping', async () => { + it('should apply allowedMcpServers filter but excluded servers are still returned', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -1280,9 +1352,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { }, }; const config = await loadCliConfig(settings, argv, undefined, []); + // allowedMcpServers filters which servers are available + // but excluded servers are still returned by getMcpServers() expect(config.getMcpServers()).toEqual({ + server1: { url: 'http://localhost:8080' }, server2: { url: 'http://localhost:8081' }, }); + expect(config.isMcpServerDisabled('server1')).toBe(true); + expect(config.isMcpServerDisabled('server2')).toBe(false); }); it('should prioritize mcp server flag if set', async () => { @@ -2178,8 +2255,8 @@ describe('parseArguments with positional prompt', () => { }); describe('Telemetry configuration via environment variables', () => { - it('should prioritize GEMINI_TELEMETRY_ENABLED over settings', async () => { - vi.stubEnv('GEMINI_TELEMETRY_ENABLED', 'true'); + it('should prioritize QWEN_TELEMETRY_ENABLED over settings', async () => { + vi.stubEnv('QWEN_TELEMETRY_ENABLED', 'true'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { telemetry: { enabled: false } }; @@ -2187,8 +2264,8 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryEnabled()).toBe(true); }); - it('should prioritize GEMINI_TELEMETRY_TARGET over settings', async () => { - vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'gcp'); + it('should prioritize QWEN_TELEMETRY_TARGET over settings', async () => { + vi.stubEnv('QWEN_TELEMETRY_TARGET', 'gcp'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -2198,8 +2275,8 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryTarget()).toBe('gcp'); }); - it('should throw when GEMINI_TELEMETRY_TARGET is invalid', async () => { - vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'bogus'); + it('should throw when QWEN_TELEMETRY_TARGET is invalid', async () => { + vi.stubEnv('QWEN_TELEMETRY_TARGET', 'bogus'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -2211,9 +2288,9 @@ describe('Telemetry configuration via environment variables', () => { vi.unstubAllEnvs(); }); - it('should prioritize GEMINI_TELEMETRY_OTLP_ENDPOINT over settings and default env var', async () => { + it('should prioritize QWEN_TELEMETRY_OTLP_ENDPOINT over settings and default env var', async () => { vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://default.env.com'); - vi.stubEnv('GEMINI_TELEMETRY_OTLP_ENDPOINT', 'http://gemini.env.com'); + vi.stubEnv('QWEN_TELEMETRY_OTLP_ENDPOINT', 'http://gemini.env.com'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -2223,8 +2300,8 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com'); }); - it('should prioritize GEMINI_TELEMETRY_OTLP_PROTOCOL over settings', async () => { - vi.stubEnv('GEMINI_TELEMETRY_OTLP_PROTOCOL', 'http'); + it('should prioritize QWEN_TELEMETRY_OTLP_PROTOCOL over settings', async () => { + vi.stubEnv('QWEN_TELEMETRY_OTLP_PROTOCOL', 'http'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { telemetry: { otlpProtocol: 'grpc' } }; @@ -2232,8 +2309,8 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); - it('should prioritize GEMINI_TELEMETRY_LOG_PROMPTS over settings', async () => { - vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); + it('should prioritize QWEN_TELEMETRY_LOG_PROMPTS over settings', async () => { + vi.stubEnv('QWEN_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { telemetry: { logPrompts: true } }; @@ -2241,8 +2318,8 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); - it('should prioritize GEMINI_TELEMETRY_OUTFILE over settings', async () => { - vi.stubEnv('GEMINI_TELEMETRY_OUTFILE', '/gemini/env/telemetry.log'); + it('should prioritize QWEN_TELEMETRY_OUTFILE over settings', async () => { + vi.stubEnv('QWEN_TELEMETRY_OUTFILE', '/gemini/env/telemetry.log'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -2252,8 +2329,8 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log'); }); - it('should prioritize GEMINI_TELEMETRY_USE_COLLECTOR over settings', async () => { - vi.stubEnv('GEMINI_TELEMETRY_USE_COLLECTOR', 'true'); + it('should prioritize QWEN_TELEMETRY_USE_COLLECTOR over settings', async () => { + vi.stubEnv('QWEN_TELEMETRY_USE_COLLECTOR', 'true'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { telemetry: { useCollector: false } }; @@ -2261,8 +2338,8 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryUseCollector()).toBe(true); }); - it('should use settings value when GEMINI_TELEMETRY_ENABLED is not set', async () => { - vi.stubEnv('GEMINI_TELEMETRY_ENABLED', undefined); + it('should use settings value when QWEN_TELEMETRY_ENABLED is not set', async () => { + vi.stubEnv('QWEN_TELEMETRY_ENABLED', undefined); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { telemetry: { enabled: true } }; @@ -2270,8 +2347,8 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryEnabled()).toBe(true); }); - it('should use settings value when GEMINI_TELEMETRY_TARGET is not set', async () => { - vi.stubEnv('GEMINI_TELEMETRY_TARGET', undefined); + it('should use settings value when QWEN_TELEMETRY_TARGET is not set', async () => { + vi.stubEnv('QWEN_TELEMETRY_TARGET', undefined); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -2281,16 +2358,16 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryTarget()).toBe('local'); }); - it("should treat GEMINI_TELEMETRY_ENABLED='1' as true", async () => { - vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '1'); + it("should treat QWEN_TELEMETRY_ENABLED='1' as true", async () => { + vi.stubEnv('QWEN_TELEMETRY_ENABLED', '1'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const config = await loadCliConfig({}, argv, undefined, []); expect(config.getTelemetryEnabled()).toBe(true); }); - it("should treat GEMINI_TELEMETRY_ENABLED='0' as false", async () => { - vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '0'); + it("should treat QWEN_TELEMETRY_ENABLED='0' as false", async () => { + vi.stubEnv('QWEN_TELEMETRY_ENABLED', '0'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const config = await loadCliConfig( @@ -2302,16 +2379,16 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryEnabled()).toBe(false); }); - it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='1' as true", async () => { - vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', '1'); + it("should treat QWEN_TELEMETRY_LOG_PROMPTS='1' as true", async () => { + vi.stubEnv('QWEN_TELEMETRY_LOG_PROMPTS', '1'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const config = await loadCliConfig({}, argv, undefined, []); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); - it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='false' as false", async () => { - vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); + it("should treat QWEN_TELEMETRY_LOG_PROMPTS='false' as false", async () => { + vi.stubEnv('QWEN_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; const argv = await parseArguments(); const config = await loadCliConfig( diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 48961cdca..88153fe75 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -11,7 +11,7 @@ import { DEFAULT_QWEN_EMBEDDING_MODEL, FileDiscoveryService, FileEncoding, - getCurrentGeminiMdFilename, + getAllGeminiMdFilenames, loadServerHierarchicalMemory, setGeminiMdFilename as setServerGeminiMdFilename, resolveTelemetrySettings, @@ -33,6 +33,7 @@ import { NativeLspService, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; +import { hooksCommand } from '../commands/hooks.js'; import type { Settings } from './settings.js'; import { resolveCliGenerationConfig, @@ -124,6 +125,7 @@ export interface CliArgs { acp: boolean | undefined; experimentalAcp: boolean | undefined; experimentalLsp: boolean | undefined; + experimentalHooks: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; openaiLogging: boolean | undefined; @@ -337,6 +339,12 @@ export async function parseArguments(): Promise { 'Enable experimental LSP (Language Server Protocol) feature for code intelligence', default: false, }) + .option('experimental-hooks', { + type: 'boolean', + description: + 'Enable experimental hooks feature for lifecycle event customization', + default: false, + }) .option('channel', { type: 'string', choices: ['VSCode', 'ACP', 'SDK', 'CI'], @@ -561,7 +569,9 @@ export async function parseArguments(): Promise { // Register MCP subcommands .command(mcpCommand) // Register Extension subcommands - .command(extensionsCommand); + .command(extensionsCommand) + // Register Hooks subcommands + .command(hooksCommand); yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json @@ -580,9 +590,11 @@ export async function parseArguments(): Promise { // and not return to main CLI logic if ( result._.length > 0 && - (result._[0] === 'mcp' || result._[0] === 'extensions') + (result._[0] === 'mcp' || + result._[0] === 'extensions' || + result._[0] === 'hooks') ) { - // MCP commands handle their own execution and process exit + // MCP/Extensions/Hooks commands handle their own execution and process exit process.exit(0); } @@ -688,8 +700,8 @@ export async function loadCliConfig( if (settings.context?.fileName) { setServerGeminiMdFilename(settings.context.fileName); } else { - // Reset to default if not provided in settings. - setServerGeminiMdFilename(getCurrentGeminiMdFilename()); + // Reset to default context filenames if not provided in settings. + setServerGeminiMdFilename(getAllGeminiMdFilenames()); } // Automatically load output-language.md if it exists @@ -1011,7 +1023,7 @@ export async function loadCliConfig( useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep, shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, - skipLoopDetection: settings.model?.skipLoopDetection ?? false, + skipLoopDetection: settings.model?.skipLoopDetection ?? true, skipStartupContext: settings.model?.skipStartupContext ?? false, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, truncateToolOutputLines: settings.tools?.truncateToolOutputLines, @@ -1021,6 +1033,10 @@ export async function loadCliConfig( output: { format: outputSettingsFormat, }, + hooks: settings.hooks, + hooksConfig: settings.hooksConfig, + enableHooks: + argv.experimentalHooks === true || settings.hooksConfig?.enabled === true, channel: argv.channel, // Precedence: explicit CLI flag > settings file > default(true). // NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 089fd85f1..f98e528fb 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -38,7 +38,7 @@ function getSandboxCommand( // note environment variable takes precedence over argument (from command line or settings) const environmentConfiguredSandbox = - process.env['GEMINI_SANDBOX']?.toLowerCase().trim() ?? ''; + process.env['QWEN_SANDBOX']?.toLowerCase().trim() ?? ''; sandbox = environmentConfiguredSandbox?.length > 0 ? environmentConfiguredSandbox @@ -63,7 +63,7 @@ function getSandboxCommand( return sandbox; } throw new FatalSandboxError( - `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, + `Missing sandbox command '${sandbox}' (from QWEN_SANDBOX)`, ); } @@ -80,8 +80,8 @@ function getSandboxCommand( // throw an error if user requested sandbox but no command was found if (sandbox === true) { throw new FatalSandboxError( - 'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' + - 'install docker or podman or specify command in GEMINI_SANDBOX', + 'QWEN_SANDBOX is true but failed to determine command for sandbox; ' + + 'install docker or podman or specify command in QWEN_SANDBOX', ); } @@ -98,7 +98,7 @@ export async function loadSandboxConfig( const packageJson = await getPackageJson(); const image = argv.sandboxImage ?? - process.env['GEMINI_SANDBOX_IMAGE'] ?? + process.env['QWEN_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri; return command && image ? { command, image } : undefined; diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index d4241c7ba..2234c9ea4 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -453,7 +453,7 @@ describe('Settings Loading and Merging', () => { ); }); - it('should warn about unknown top-level keys in a v2 settings file', () => { + it('should silently ignore unknown top-level keys in a v2 settings file', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); @@ -471,13 +471,7 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(getSettingsWarnings(settings)).toEqual( - expect.arrayContaining([ - expect.stringContaining( - "Unknown setting 'someUnknownKey' will be ignored", - ), - ]), - ); + expect(getSettingsWarnings(settings)).toEqual([]); }); it('should not warn for valid v2 container keys', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index bfc670b60..3ce34edc1 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -34,7 +34,6 @@ import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { setNestedPropertySafe } from '../utils/settingsUtils.js'; import { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; -const debugLogger = createDebugLogger('SETTINGS'); import { runMigrations, needsMigration } from './migration/index.js'; import { V1_TO_V2_MIGRATION_MAP, @@ -42,6 +41,8 @@ import { } from './migration/versions/v1-to-v2-shared.js'; import { writeWithBackupSync } from '../utils/writeWithBackup.js'; +const debugLogger = createDebugLogger('SETTINGS'); + function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; let currentSchema: SettingsSchema | undefined = getSettingsSchema(); @@ -165,7 +166,7 @@ function getSettingsFileKeyWarnings( ); } - // Unknown top-level keys. + // Unknown top-level keys — log silently to debug output. const schemaKeys = new Set(Object.keys(getSettingsSchema())); for (const key of Object.keys(settings)) { if (key === SETTINGS_VERSION_KEY) { @@ -178,8 +179,8 @@ function getSettingsFileKeyWarnings( continue; } - warnings.push( - `Warning: Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, + debugLogger.warn( + `Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, ); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fd6c3e85b..b2d24712b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -589,7 +589,7 @@ const SETTINGS_SCHEMA = { label: 'Skip Loop Detection', category: 'Model', requiresRestart: false, - default: false, + default: true, description: 'Disable all loop detection checks (streaming and LLM).', showInDialog: false, }, @@ -822,9 +822,9 @@ const SETTINGS_SCHEMA = { label: 'Interactive Shell (PTY)', category: 'Tools', requiresRestart: true, - default: false, + default: true, description: - 'Use node-pty for an interactive shell experience. Fallback to child_process still applies.', + 'Use node-pty for an interactive shell experience. Falls back to child_process if PTY is unavailable.', showInDialog: true, }, pager: { @@ -1176,6 +1176,75 @@ const SETTINGS_SCHEMA = { description: 'Configuration for web search providers.', showInDialog: false, }, + + hooksConfig: { + type: 'object', + label: 'Hooks Config', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Hook configurations for intercepting and customizing agent behavior.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Hooks', + category: 'Advanced', + requiresRestart: true, + default: true, + description: + 'Canonical toggle for the hooks system. When disabled, no hooks will be executed.', + showInDialog: false, + }, + disabled: { + type: 'array', + label: 'Disabled Hooks', + category: 'Advanced', + requiresRestart: false, + default: [] as string[], + description: + 'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + + hooks: { + type: 'object', + label: 'Hooks', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Hook event configurations for extending CLI behavior at various lifecycle points.', + showInDialog: false, + properties: { + UserPromptSubmit: { + type: 'array', + label: 'Before Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before agent processing. Can modify prompts or inject context.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + }, + Stop: { + type: 'array', + label: 'After Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after agent processing. Can post-process responses or log interactions.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + }, + }, + }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 9d06dcf31..5b29969c2 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -158,9 +158,9 @@ describe('Trusted Folders Loading', () => { expect(errors[0].message).toContain('Unexpected token'); }); - it('should use GEMINI_CLI_TRUSTED_FOLDERS_PATH env var if set', () => { + it('should use QWEN_CODE_TRUSTED_FOLDERS_PATH env var if set', () => { const customPath = '/custom/path/to/trusted_folders.json'; - process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath; + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = customPath; (mockFsExistsSync as Mock).mockImplementation((p) => p === customPath); const userContent = { @@ -180,7 +180,7 @@ describe('Trusted Folders Loading', () => { ]); expect(errors).toEqual([]); - delete process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']; + delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; }); it('setValue should update the user config and save it', () => { diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 355146025..1a243e537 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -22,8 +22,8 @@ export const SETTINGS_DIRECTORY_NAME = '.qwen'; export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); export function getTrustedFoldersPath(): string { - if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) { - return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']; + if (process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']) { + return process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; } return path.join(USER_SETTINGS_DIR, TRUSTED_FOLDERS_FILENAME); } diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 03c164d8e..bc28a781a 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -64,6 +64,42 @@ export function generateCodingPlanTemplate( contextWindowSize: 1000000, }, }, + { + id: 'glm-5', + name: '[Bailian Coding Plan] glm-5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + { + id: 'kimi-k2.5', + name: '[Bailian Coding Plan] kimi-k2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[Bailian Coding Plan] MiniMax-M2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, + }, + }, { id: 'qwen3-coder-plus', name: '[Bailian Coding Plan] qwen3-coder-plus', @@ -106,42 +142,6 @@ export function generateCodingPlanTemplate( contextWindowSize: 202752, }, }, - { - id: 'glm-5', - name: '[Bailian Coding Plan] glm-5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 202752, - }, - }, - { - id: 'MiniMax-M2.5', - name: '[Bailian Coding Plan] MiniMax-M2.5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 1000000, - }, - }, - { - id: 'kimi-k2.5', - name: '[Bailian Coding Plan] kimi-k2.5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 262144, - }, - }, ]; } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index d0ff00693..9b47de5b5 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -113,9 +113,9 @@ describe('gemini.tsx main function', () => { beforeEach(() => { // Store and clear sandbox-related env variables to ensure a consistent test environment - originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX']; + originalEnvGeminiSandbox = process.env['QWEN_SANDBOX']; originalEnvSandbox = process.env['SANDBOX']; - delete process.env['GEMINI_SANDBOX']; + delete process.env['QWEN_SANDBOX']; delete process.env['SANDBOX']; initialUnhandledRejectionListeners = @@ -125,9 +125,9 @@ describe('gemini.tsx main function', () => { afterEach(() => { // Restore original env variables if (originalEnvGeminiSandbox !== undefined) { - process.env['GEMINI_SANDBOX'] = originalEnvGeminiSandbox; + process.env['QWEN_SANDBOX'] = originalEnvGeminiSandbox; } else { - delete process.env['GEMINI_SANDBOX']; + delete process.env['QWEN_SANDBOX']; } if (originalEnvSandbox !== undefined) { process.env['SANDBOX'] = originalEnvSandbox; @@ -504,6 +504,7 @@ describe('gemini.tsx main function kitty protocol', () => { authType: undefined, maxSessionTurns: undefined, experimentalLsp: undefined, + experimentalHooks: undefined, channel: undefined, chatRecording: undefined, sessionId: undefined, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 1144aa31c..a5af9d471 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -97,7 +97,7 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.', - 'list available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]': 'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]', 'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:', 'No tools available': 'Keine Werkzeuge verfügbar', @@ -360,7 +360,9 @@ export default { 'Show tool-specific usage statistics.': 'Werkzeugspezifische Nutzungsstatistiken anzeigen.', 'exit the cli': 'CLI beenden', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'MCP-Verwaltungsdialog öffnen oder mit OAuth-fähigem Server authentifizieren', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': 'Konfigurierte MCP-Server und Werkzeuge auflisten oder mit OAuth-fähigen Servern authentifizieren', 'Manage workspace directories': 'Arbeitsbereichsverzeichnisse verwalten', 'Add directories to the workspace. Use comma to separate multiple paths': @@ -882,9 +884,101 @@ export default { 'Do you want to proceed?': 'Möchten Sie fortfahren?', 'Yes, allow once': 'Ja, einmal erlauben', 'Allow always': 'Immer erlauben', + Yes: 'Ja', No: 'Nein', 'No (esc)': 'Nein (Esc)', 'Yes, allow always for this session': 'Ja, für diese Sitzung immer erlauben', + + // MCP Management Dialog (translations for MCP UI components) + 'Manage MCP servers': 'MCP-Server verwalten', + 'Server Detail': 'Serverdetails', + 'Disable Server': 'Server deaktivieren', + Tools: 'Werkzeuge', + 'Tool Detail': 'Werkzeugdetails', + 'MCP Management': 'MCP-Verwaltung', + 'Loading...': 'Lädt...', + 'Unknown step': 'Unbekannter Schritt', + 'Esc to back': 'Esc zurück', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ navigieren · Enter auswählen · Esc schließen', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ navigieren · Enter auswählen · Esc zurück', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ navigieren · Enter bestätigen · Esc zurück', + 'User Settings (global)': 'Benutzereinstellungen (global)', + 'Workspace Settings (project-specific)': + 'Arbeitsbereichseinstellungen (projektspezifisch)', + 'Disable server:': 'Server deaktivieren:', + 'Select where to add the server to the exclude list:': + 'Wählen Sie, wo der Server zur Ausschlussliste hinzugefügt werden soll:', + 'Press Enter to confirm, Esc to cancel': + 'Enter zum Bestätigen, Esc zum Abbrechen', + Disable: 'Deaktivieren', + Enable: 'Aktivieren', + Reconnect: 'Neu verbinden', + 'View tools': 'Werkzeuge anzeigen', + 'Status:': 'Status:', + 'Command:': 'Befehl:', + 'Working Directory:': 'Arbeitsverzeichnis:', + 'Capabilities:': 'Fähigkeiten:', + 'No server selected': 'Kein Server ausgewählt', + '(disabled)': '(deaktiviert)', + 'Error:': 'Fehler:', + Extension: 'Erweiterung', + tool: 'Werkzeug', + tools: 'Werkzeuge', + connected: 'verbunden', + connecting: 'verbindet', + disconnected: 'getrennt', + error: 'Fehler', + + // MCP Server List + 'User MCPs': 'Benutzer-MCPs', + 'Project MCPs': 'Projekt-MCPs', + 'Extension MCPs': 'Erweiterungs-MCPs', + server: 'Server', + servers: 'Server', + 'Add MCP servers to your settings to get started.': + 'Fügen Sie MCP-Server zu Ihren Einstellungen hinzu, um zu beginnen.', + 'Run qwen --debug to see error logs': + 'Führen Sie qwen --debug aus, um Fehlerprotokolle anzuzeigen', + + // MCP Tool List + 'No tools available for this server.': + 'Keine Werkzeuge für diesen Server verfügbar.', + destructive: 'destruktiv', + 'read-only': 'schreibgeschützt', + 'open-world': 'offene Welt', + idempotent: 'idempotent', + 'Tools for {{name}}': 'Werkzeuge für {{name}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'erforderlich', + Type: 'Typ', + Enum: 'Aufzählung', + Parameters: 'Parameter', + 'No tool selected': 'Kein Werkzeug ausgewählt', + Annotations: 'Anmerkungen', + Title: 'Titel', + 'Read Only': 'Schreibgeschützt', + Destructive: 'Destruktiv', + Idempotent: 'Idempotent', + 'Open World': 'Offene Welt', + Server: 'Server', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} ungültige Werkzeuge', + invalid: 'ungültig', + 'invalid: {{reason}}': 'ungültig: {{reason}}', + 'missing name': 'Name fehlt', + 'missing description': 'Beschreibung fehlt', + '(unnamed)': '(unbenannt)', + 'Warning: This tool cannot be called by the LLM': + 'Warnung: Dieses Werkzeug kann nicht vom LLM aufgerufen werden', + Reason: 'Grund', + 'Tools must have both name and description to be used by the LLM.': + 'Werkzeuge müssen sowohl einen Namen als auch eine Beschreibung haben, um vom LLM verwendet zu werden.', 'Modify in progress:': 'Änderung in Bearbeitung:', 'Save and close external editor to continue': 'Speichern und externen Editor schließen, um fortzufahren', @@ -1457,6 +1551,10 @@ export default { 'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.', - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': - 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).', + '{{region}} configuration updated successfully.': + '{{region}}-Konfiguration erfolgreich aktualisiert.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Tipp: Verwenden Sie /model, um zwischen verfügbaren Coding Plan-Modellen zu wechseln.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 1c27b760f..dedec4b75 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -116,8 +116,8 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'Analyzes the project and creates a tailored QWEN.md file.', - 'list available Qwen Code tools. Usage: /tools [desc]': - 'list available Qwen Code tools. Usage: /tools [desc]', + 'List available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]', 'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:', 'No tools available': 'No tools available', 'View or change the approval mode for tool usage': @@ -289,6 +289,73 @@ export default { 'Failed to save and edit subagent: {{error}}': 'Failed to save and edit subagent: {{error}}', + // ============================================================================ + // Extensions - Management Dialog + // ============================================================================ + 'Manage Extensions': 'Manage Extensions', + 'Extension Details': 'Extension Details', + 'View Extension': 'View Extension', + 'Update Extension': 'Update Extension', + 'Disable Extension': 'Disable Extension', + 'Enable Extension': 'Enable Extension', + 'Uninstall Extension': 'Uninstall Extension', + 'Select Scope': 'Select Scope', + 'User Scope': 'User Scope', + 'Workspace Scope': 'Workspace Scope', + 'No extensions found.': 'No extensions found.', + Active: 'Active', + Disabled: 'Disabled', + 'Update available': 'Update available', + 'Up to date': 'Up to date', + 'Checking...': 'Checking...', + 'Updating...': 'Updating...', + Unknown: 'Unknown', + Error: 'Error', + 'Version:': 'Version:', + 'Status:': 'Status:', + 'Are you sure you want to uninstall extension "{{name}}"?': + 'Are you sure you want to uninstall extension "{{name}}"?', + 'This action cannot be undone.': 'This action cannot be undone.', + 'Extension "{{name}}" disabled successfully.': + 'Extension "{{name}}" disabled successfully.', + 'Extension "{{name}}" enabled successfully.': + 'Extension "{{name}}" enabled successfully.', + 'Extension "{{name}}" updated successfully.': + 'Extension "{{name}}" updated successfully.', + 'Failed to update extension "{{name}}": {{error}}': + 'Failed to update extension "{{name}}": {{error}}', + 'Select the scope for this action:': 'Select the scope for this action:', + 'User - Applies to all projects': 'User - Applies to all projects', + 'Workspace - Applies to current project only': + 'Workspace - Applies to current project only', + // Extension dialog - missing keys + 'Name:': 'Name:', + 'MCP Servers:': 'MCP Servers:', + 'Settings:': 'Settings:', + active: 'active', + disabled: 'disabled', + 'View Details': 'View Details', + 'Update failed:': 'Update failed:', + 'Updating {{name}}...': 'Updating {{name}}...', + 'Update complete!': 'Update complete!', + 'User (global)': 'User (global)', + 'Workspace (project-specific)': 'Workspace (project-specific)', + 'Disable "{{name}}" - Select Scope': 'Disable "{{name}}" - Select Scope', + 'Enable "{{name}}" - Select Scope': 'Enable "{{name}}" - Select Scope', + 'No extension selected': 'No extension selected', + 'Press Y/Enter to confirm, N/Esc to cancel': + 'Press Y/Enter to confirm, N/Esc to cancel', + 'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter to confirm, N/Esc to cancel', + '{{count}} extensions installed': '{{count}} extensions installed', + "Use '/extensions install' to install your first extension.": + "Use '/extensions install' to install your first extension.", + // Update status values + 'up to date': 'up to date', + 'update available': 'update available', + 'checking...': 'checking...', + 'not updatable': 'not updatable', + error: 'error', + // ============================================================================ // Commands - General (continued) // ============================================================================ @@ -376,8 +443,10 @@ export default { 'Show tool-specific usage statistics.': 'Show tool-specific usage statistics.', 'exit the cli': 'exit the cli', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers', 'Manage workspace directories': 'Manage workspace directories', 'Add directories to the workspace. Use comma to separate multiple paths': 'Add directories to the workspace. Use comma to separate multiple paths', @@ -726,6 +795,7 @@ export default { 'List configured MCP servers and tools': 'List configured MCP servers and tools', 'Restarts MCP servers.': 'Restarts MCP servers.', + 'Open MCP management dialog': 'Open MCP management dialog', 'Config not loaded.': 'Config not loaded.', 'Could not retrieve tool registry.': 'Could not retrieve tool registry.', 'No MCP servers configured with OAuth authentication.': @@ -742,6 +812,96 @@ export default { "Re-discovering tools from '{{name}}'...": "Re-discovering tools from '{{name}}'...", + // ============================================================================ + // MCP Management Dialog + // ============================================================================ + 'Manage MCP servers': 'Manage MCP servers', + 'Server Detail': 'Server Detail', + 'Disable Server': 'Disable Server', + Tools: 'Tools', + 'Tool Detail': 'Tool Detail', + 'MCP Management': 'MCP Management', + 'Loading...': 'Loading...', + 'Unknown step': 'Unknown step', + 'Esc to back': 'Esc to back', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ to navigate · Enter to select · Esc to close', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ to navigate · Enter to select · Esc to back', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ to navigate · Enter to confirm · Esc to back', + 'User Settings (global)': 'User Settings (global)', + 'Workspace Settings (project-specific)': + 'Workspace Settings (project-specific)', + 'Disable server:': 'Disable server:', + 'Select where to add the server to the exclude list:': + 'Select where to add the server to the exclude list:', + 'Press Enter to confirm, Esc to cancel': + 'Press Enter to confirm, Esc to cancel', + 'View tools': 'View tools', + Reconnect: 'Reconnect', + Enable: 'Enable', + Disable: 'Disable', + 'Command:': 'Command:', + 'Working Directory:': 'Working Directory:', + 'Capabilities:': 'Capabilities:', + 'No server selected': 'No server selected', + prompts: 'prompts', + '(disabled)': '(disabled)', + 'Error:': 'Error:', + Extension: 'Extension', + tool: 'tool', + tools: 'tools', + connected: 'connected', + connecting: 'connecting', + disconnected: 'disconnected', + + // MCP Server List + 'User MCPs': 'User MCPs', + 'Project MCPs': 'Project MCPs', + 'Extension MCPs': 'Extension MCPs', + server: 'server', + servers: 'servers', + 'Add MCP servers to your settings to get started.': + 'Add MCP servers to your settings to get started.', + 'Run qwen --debug to see error logs': 'Run qwen --debug to see error logs', + + // MCP Tool List + 'No tools available for this server.': 'No tools available for this server.', + destructive: 'destructive', + 'read-only': 'read-only', + 'open-world': 'open-world', + idempotent: 'idempotent', + 'Tools for {{name}}': 'Tools for {{name}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'required', + Type: 'Type', + Enum: 'Enum', + Parameters: 'Parameters', + 'No tool selected': 'No tool selected', + Annotations: 'Annotations', + Title: 'Title', + 'Read Only': 'Read Only', + Destructive: 'Destructive', + Idempotent: 'Idempotent', + 'Open World': 'Open World', + Server: 'Server', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} invalid tools', + invalid: 'invalid', + 'invalid: {{reason}}': 'invalid: {{reason}}', + 'missing name': 'missing name', + 'missing description': 'missing description', + '(unnamed)': '(unnamed)', + 'Warning: This tool cannot be called by the LLM': + 'Warning: This tool cannot be called by the LLM', + Reason: 'Reason', + 'Tools must have both name and description to be used by the LLM.': + 'Tools must have both name and description to be used by the LLM.', + // ============================================================================ // Commands - Chat // ============================================================================ @@ -874,6 +1034,7 @@ export default { 'Do you want to proceed?': 'Do you want to proceed?', 'Yes, allow once': 'Yes, allow once', 'Allow always': 'Allow always', + Yes: 'Yes', No: 'No', 'No (esc)': 'No (esc)', 'Yes, allow always for this session': 'Yes, allow always for this session', @@ -1446,6 +1607,10 @@ export default { 'New model configurations are available for {{region}}. Update now?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} configuration updated successfully. Model switched to "{{model}}".', - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', + '{{region}} configuration updated successfully.': + '{{region}} configuration updated successfully.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Tip: Use /model to switch between available Coding Plan models.', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 634cec49d..b577e2cc1 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -83,7 +83,7 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成', - 'list available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]': '利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]', 'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:', 'No tools available': '利用可能なツールはありません', @@ -317,7 +317,9 @@ export default { 'セッション統計を確認。使い方: /stats [model|tools]', 'Show model-specific usage statistics.': 'モデル別の使用統計を表示', 'Show tool-specific usage statistics.': 'ツール別の使用統計を表示', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'MCP管理ダイアログを開く、またはOAuth対応サーバーで認証', + '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': @@ -622,9 +624,101 @@ export default { 'Do you want to proceed?': '続行しますか?', 'Yes, allow once': 'はい(今回のみ許可)', 'Allow always': '常に許可する', + Yes: 'はい', No: 'いいえ', 'No (esc)': 'いいえ (Esc)', 'Yes, allow always for this session': 'はい、このセッションで常に許可', + + // MCP Management - Core translations + 'Manage MCP servers': 'MCPサーバーを管理', + 'Server Detail': 'サーバー詳細', + 'Disable Server': 'サーバーを無効化', + Tools: 'ツール', + 'Tool Detail': 'ツール詳細', + 'MCP Management': 'MCP管理', + 'Loading...': '読み込み中...', + 'Unknown step': '不明なステップ', + 'Esc to back': 'Esc 戻る', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ ナビゲート · Enter 選択 · Esc 閉じる', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ ナビゲート · Enter 選択 · Esc 戻る', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ ナビゲート · Enter 確認 · Esc 戻る', + 'User Settings (global)': 'ユーザー設定(グローバル)', + 'Workspace Settings (project-specific)': + 'ワークスペース設定(プロジェクト固有)', + 'Disable server:': 'サーバーを無効化:', + 'Select where to add the server to the exclude list:': + 'サーバーを除外リストに追加する場所を選択してください:', + 'Press Enter to confirm, Esc to cancel': 'Enter で確認、Esc でキャンセル', + Disable: '無効化', + Enable: '有効化', + Reconnect: '再接続', + 'View tools': 'ツールを表示', + 'Status:': 'ステータス:', + 'Source:': 'ソース:', + 'Command:': 'コマンド:', + 'Working Directory:': '作業ディレクトリ:', + 'Capabilities:': '機能:', + 'No server selected': 'サーバーが選択されていません', + '(disabled)': '(無効)', + 'Error:': 'エラー:', + Extension: '拡張機能', + tool: 'ツール', + tools: 'ツール', + connected: '接続済み', + connecting: '接続中', + disconnected: '切断済み', + error: 'エラー', + + // MCP Server List + 'User MCPs': 'ユーザーMCP', + 'Project MCPs': 'プロジェクトMCP', + 'Extension MCPs': '拡張機能MCP', + server: 'サーバー', + servers: 'サーバー', + 'Add MCP servers to your settings to get started.': + '設定にMCPサーバーを追加して開始してください。', + 'Run qwen --debug to see error logs': + 'qwen --debug を実行してエラーログを確認してください', + + // MCP Tool List + 'No tools available for this server.': + 'このサーバーには使用可能なツールがありません。', + destructive: '破壊的', + 'read-only': '読み取り専用', + 'open-world': 'オープンワールド', + idempotent: '冪等', + 'Tools for {{name}}': '{{name}} のツール', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: '必須', + Type: '型', + Enum: '列挙', + Parameters: 'パラメータ', + 'No tool selected': 'ツールが選択されていません', + Annotations: '注釈', + Title: 'タイトル', + 'Read Only': '読み取り専用', + Destructive: '破壊的', + Idempotent: '冪等', + 'Open World': 'オープンワールド', + Server: 'サーバー', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} 個の無効なツール', + invalid: '無効', + 'invalid: {{reason}}': '無効: {{reason}}', + 'missing name': '名前なし', + 'missing description': '説明なし', + '(unnamed)': '(名前なし)', + 'Warning: This tool cannot be called by the LLM': + '警告: このツールはLLMによって呼び出すことができません', + Reason: '理由', + 'Tools must have both name and description to be used by the LLM.': + 'ツールはLLMによって使用されるには名前と説明の両方が必要です。', 'Modify in progress:': '変更中:', 'Save and close external editor to continue': '続行するには外部エディタを保存して閉じてください', @@ -964,6 +1058,10 @@ export default { '{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。', - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': - '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました(バックアップ済み)。', + '{{region}} configuration updated successfully.': + '{{region}} の設定が正常に更新されました。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました。', + 'Tip: Use /model to switch between available Coding Plan models.': + 'ヒント: /model で利用可能な Coding Plan モデルを切り替えられます。', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 729ebbd74..c1503e810 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -109,8 +109,8 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'Analisa o projeto e cria um arquivo QWEN.md personalizado.', - 'list available Qwen Code tools. Usage: /tools [desc]': - 'listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]', + 'List available Qwen Code tools. Usage: /tools [desc]': + 'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]', 'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:', 'No tools available': 'Nenhuma ferramenta disponível', 'View or change the approval mode for tool usage': @@ -385,8 +385,10 @@ export default { 'Show tool-specific usage statistics.': 'Mostrar estatísticas de uso específicas da ferramenta.', 'exit the cli': 'sair da cli', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': - 'listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'Abrir diálogo de gerenciamento MCP ou autenticar com servidor habilitado para OAuth', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth', 'Manage workspace directories': 'Gerenciar diretórios do workspace', 'Add directories to the workspace. Use comma to separate multiple paths': 'Adicionar diretórios ao workspace. Use vírgula para separar vários caminhos', @@ -888,9 +890,102 @@ export default { 'Do you want to proceed?': 'Você deseja prosseguir?', 'Yes, allow once': 'Sim, permitir uma vez', 'Allow always': 'Permitir sempre', + Yes: 'Sim', No: 'Não', 'No (esc)': 'Não (esc)', 'Yes, allow always for this session': 'Sim, permitir sempre para esta sessão', + + // MCP Management - Core translations + 'Manage MCP servers': 'Gerenciar servidores MCP', + 'Server Detail': 'Detalhes do servidor', + 'Disable Server': 'Desativar servidor', + Tools: 'Ferramentas', + 'Tool Detail': 'Detalhes da ferramenta', + 'MCP Management': 'Gerenciamento MCP', + 'Loading...': 'Carregando...', + 'Unknown step': 'Etapa desconhecida', + 'Esc to back': 'Esc para voltar', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ navegar · Enter selecionar · Esc fechar', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ navegar · Enter selecionar · Esc voltar', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ navegar · Enter confirmar · Esc voltar', + 'User Settings (global)': 'Configurações do usuário (global)', + 'Workspace Settings (project-specific)': + 'Configurações do workspace (específico do projeto)', + 'Disable server:': 'Desativar servidor:', + 'Select where to add the server to the exclude list:': + 'Selecione onde adicionar o servidor à lista de exclusão:', + 'Press Enter to confirm, Esc to cancel': + 'Enter para confirmar, Esc para cancelar', + Disable: 'Desativar', + Enable: 'Ativar', + Reconnect: 'Reconectar', + 'View tools': 'Ver ferramentas', + 'Status:': 'Status:', + 'Source:': 'Fonte:', + 'Command:': 'Comando:', + 'Working Directory:': 'Diretório de trabalho:', + 'Capabilities:': 'Capacidades:', + 'No server selected': 'Nenhum servidor selecionado', + '(disabled)': '(desativado)', + 'Error:': 'Erro:', + Extension: 'Extensão', + tool: 'ferramenta', + tools: 'ferramentas', + connected: 'conectado', + connecting: 'conectando', + disconnected: 'desconectado', + error: 'erro', + + // MCP Server List + 'User MCPs': 'MCPs do usuário', + 'Project MCPs': 'MCPs do projeto', + 'Extension MCPs': 'MCPs de extensão', + server: 'servidor', + servers: 'servidores', + 'Add MCP servers to your settings to get started.': + 'Adicione servidores MCP às suas configurações para começar.', + 'Run qwen --debug to see error logs': + 'Execute qwen --debug para ver os logs de erro', + + // MCP Tool List + 'No tools available for this server.': + 'Nenhuma ferramenta disponível para este servidor.', + destructive: 'destrutivo', + 'read-only': 'somente leitura', + 'open-world': 'mundo aberto', + idempotent: 'idempotente', + 'Tools for {{name}}': 'Ferramentas para {{name}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'obrigatório', + Type: 'Tipo', + Enum: 'Enumeração', + Parameters: 'Parâmetros', + 'No tool selected': 'Nenhuma ferramenta selecionada', + Annotations: 'Anotações', + Title: 'Título', + 'Read Only': 'Somente leitura', + Destructive: 'Destrutivo', + Idempotent: 'Idempotente', + 'Open World': 'Mundo aberto', + Server: 'Servidor', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} ferramentas inválidas', + invalid: 'inválido', + 'invalid: {{reason}}': 'inválido: {{reason}}', + 'missing name': 'nome ausente', + 'missing description': 'descrição ausente', + '(unnamed)': '(sem nome)', + 'Warning: This tool cannot be called by the LLM': + 'Aviso: Esta ferramenta não pode ser chamada pelo LLM', + Reason: 'Motivo', + 'Tools must have both name and description to be used by the LLM.': + 'As ferramentas devem ter tanto nome quanto descrição para serem usadas pelo LLM.', 'Modify in progress:': 'Modificação em progresso:', 'Save and close external editor to continue': 'Salve e feche o editor externo para continuar', @@ -1451,6 +1546,10 @@ export default { 'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': 'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".', - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': - 'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).', + '{{region}} configuration updated successfully.': + 'Configuração do {{region}} atualizada com sucesso.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Dica: Use /model para alternar entre os modelos disponíveis do Coding Plan.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 867de9b9a..60b63880f 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -117,7 +117,7 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'Анализ проекта и создание адаптированного файла QWEN.md', - 'list available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]': 'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]', 'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:', 'No tools available': 'Нет доступных инструментов', @@ -380,7 +380,9 @@ export default { 'Show tool-specific usage statistics.': 'Показать статистику использования инструментов.', 'exit the cli': 'Выход из CLI', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'Открыть диалог управления MCP или авторизоваться на сервере с поддержкой OAuth', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': 'Показать настроенные MCP-серверы и инструменты, или авторизоваться на серверах с поддержкой OAuth', 'Manage workspace directories': 'Управление директориями рабочего пространства', @@ -889,9 +891,36 @@ export default { 'Do you want to proceed?': 'Вы хотите продолжить?', 'Yes, allow once': 'Да, разрешить один раз', 'Allow always': 'Всегда разрешать', + Yes: 'Да', No: 'Нет', 'No (esc)': 'Нет (esc)', 'Yes, allow always for this session': 'Да, всегда разрешать для этой сессии', + + // MCP Management - Core translations + Disable: 'Отключить', + Enable: 'Включить', + Reconnect: 'Переподключить', + 'View tools': 'Просмотреть инструменты', + '(disabled)': '(отключен)', + 'Error:': 'Ошибка:', + Extension: 'Расширение', + tool: 'инструмент', + connected: 'подключен', + connecting: 'подключение', + disconnected: 'отключен', + error: 'ошибка', + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} недействительных инструментов', + invalid: 'недействительный', + 'invalid: {{reason}}': 'недействительно: {{reason}}', + 'missing name': 'отсутствует имя', + 'missing description': 'отсутствует описание', + '(unnamed)': '(без имени)', + 'Warning: This tool cannot be called by the LLM': + 'Предупреждение: Этот инструмент не может быть вызван LLM', + Reason: 'Причина', + 'Tools must have both name and description to be used by the LLM.': + 'Инструменты должны иметь как имя, так и описание, чтобы использоваться LLM.', 'Modify in progress:': 'Идет изменение:', 'Save and close external editor to continue': 'Сохраните и закройте внешний редактор для продолжения', @@ -1463,4 +1492,77 @@ export default { 'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).', + + // ============================================================================ + // MCP Management Dialog + // ============================================================================ + 'MCP Management': 'Управление MCP', + 'Server List': 'Список серверов', + 'Server Detail': 'Детали сервера', + 'Disable Server': 'Отключить сервер', + 'Tool List': 'Список инструментов', + 'Tool Detail': 'Детали инструмента', + 'Loading...': 'Загрузка...', + 'Unknown step': 'Неизвестный шаг', + 'Esc to back': 'Esc для возврата', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ навигация · Enter выбрать · Esc закрыть', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ навигация · Enter выбрать · Esc назад', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ навигация · Enter подтвердить · Esc назад', + 'User Settings (global)': 'Настройки пользователя (глобальные)', + 'Workspace Settings (project-specific)': + 'Настройки рабочего пространства (проектные)', + 'Disable server:': 'Отключить сервер:', + 'Select where to add the server to the exclude list:': + 'Выберите, где добавить сервер в список исключений:', + 'Press Enter to confirm, Esc to cancel': + 'Enter для подтверждения, Esc для отмены', + 'Status:': 'Статус:', + 'Command:': 'Команда:', + 'Working Directory:': 'Рабочий каталог:', + 'Capabilities:': 'Возможности:', + 'No server selected': 'Сервер не выбран', + + // MCP Server List + 'User MCPs': 'MCP пользователя', + 'Project MCPs': 'MCP проекта', + 'Extension MCPs': 'MCP расширений', + server: 'сервер', + servers: 'серверов', + 'Add MCP servers to your settings to get started.': + 'Добавьте серверы MCP в настройки, чтобы начать.', + 'Run qwen --debug to see error logs': + 'Запустите qwen --debug для просмотра журналов ошибок', + + // MCP Tool List + 'No tools available for this server.': + 'Для этого сервера нет доступных инструментов.', + destructive: 'деструктивный', + 'read-only': 'только чтение', + 'open-world': 'открытый мир', + idempotent: 'идемпотентный', + 'Tools for {{name}}': 'Инструменты для {{name}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'обязательный', + Type: 'Тип', + Enum: 'Перечисление', + Parameters: 'Параметры', + 'No tool selected': 'Инструмент не выбран', + Annotations: 'Аннотации', + Title: 'Заголовок', + 'Read Only': 'Только чтение', + Destructive: 'Деструктивный', + Idempotent: 'Идемпотентный', + 'Open World': 'Открытый мир', + Server: 'Сервер', + '{{region}} configuration updated successfully.': + 'Конфигурация {{region}} успешно обновлена.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Совет: Используйте /model для переключения между доступными моделями Coding Plan.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 5bc2bef92..8ddff791b 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -33,7 +33,7 @@ export default { '!': '!', '!npm run start': '!npm run start', 'start server': 'start server', - 'Commands:': '命令:', + 'Commands:': '命令:', 'shell command': 'shell 命令', 'Model Context Protocol command (from external servers)': '模型上下文协议命令(来自外部服务器)', @@ -114,7 +114,7 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': '分析项目并创建定制的 QWEN.md 文件', - 'list available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]': '列出可用的 Qwen Code 工具。用法:/tools [desc]', 'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:', 'No tools available': '没有可用工具', @@ -278,6 +278,68 @@ export default { 'Failed to save and edit subagent: {{error}}': '保存并编辑子智能体失败: {{error}}', + // ============================================================================ + // Extensions - Management Dialog + // ============================================================================ + 'Manage Extensions': '管理扩展', + 'Extension Details': '扩展详情', + 'View Extension': '查看扩展', + 'Update Extension': '更新扩展', + 'Disable Extension': '禁用扩展', + 'Enable Extension': '启用扩展', + 'Uninstall Extension': '卸载扩展', + 'Select Scope': '选择作用域', + 'User Scope': '用户作用域', + 'Workspace Scope': '工作区作用域', + 'No extensions found.': '未找到扩展。', + Active: '已启用', + Disabled: '已禁用', + 'Update available': '有可用更新', + 'Up to date': '已是最新', + 'Checking...': '检查中...', + 'Updating...': '更新中...', + Unknown: '未知', + Error: '错误', + 'Version:': '版本:', + 'Status:': '状态:', + 'Are you sure you want to uninstall extension "{{name}}"?': + '确定要卸载扩展 "{{name}}" 吗?', + 'This action cannot be undone.': '此操作无法撤销。', + 'Extension "{{name}}" disabled successfully.': '扩展 "{{name}}" 禁用成功。', + 'Extension "{{name}}" enabled successfully.': '扩展 "{{name}}" 启用成功。', + 'Extension "{{name}}" updated successfully.': '扩展 "{{name}}" 更新成功。', + 'Failed to update extension "{{name}}": {{error}}': + '更新扩展 "{{name}}" 失败:{{error}}', + 'Select the scope for this action:': '选择此操作的作用域:', + 'User - Applies to all projects': '用户 - 应用于所有项目', + 'Workspace - Applies to current project only': '工作区 - 仅应用于当前项目', + // Extension dialog - missing keys + 'Name:': '名称:', + 'MCP Servers:': 'MCP 服务器:', + 'Settings:': '设置:', + active: '已启用', + disabled: '已禁用', + 'View Details': '查看详情', + 'Update failed:': '更新失败:', + 'Updating {{name}}...': '正在更新 {{name}}...', + 'Update complete!': '更新完成!', + 'User (global)': '用户(全局)', + 'Workspace (project-specific)': '工作区(项目特定)', + 'Disable "{{name}}" - Select Scope': '禁用 "{{name}}" - 选择作用域', + 'Enable "{{name}}" - Select Scope': '启用 "{{name}}" - 选择作用域', + 'No extension selected': '未选择扩展', + 'Press Y/Enter to confirm, N/Esc to cancel': '按 Y/Enter 确认,N/Esc 取消', + 'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter 确认,N/Esc 取消', + '{{count}} extensions installed': '已安装 {{count}} 个扩展', + "Use '/extensions install' to install your first extension.": + "使用 '/extensions install' 安装您的第一个扩展。", + // Update status values + 'up to date': '已是最新', + 'update available': '有可用更新', + 'checking...': '检查中...', + 'not updatable': '不可更新', + error: '错误', + // ============================================================================ // Commands - General (continued) // ============================================================================ @@ -361,7 +423,9 @@ export default { 'Show model-specific usage statistics.': '显示模型相关的使用统计信息', 'Show tool-specific usage statistics.': '显示工具相关的使用统计信息', 'exit the cli': '退出命令行界面', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + '打开 MCP 管理对话框,或在支持 OAuth 的服务器上进行身份验证', + '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': @@ -685,6 +749,7 @@ export default { '使用支持 OAuth 的 MCP 服务器进行认证', 'List configured MCP servers and tools': '列出已配置的 MCP 服务器和工具', 'Restarts MCP servers.': '重启 MCP 服务器', + 'Open MCP management dialog': '打开 MCP 管理对话框', 'Config not loaded.': '配置未加载', 'Could not retrieve tool registry.': '无法检索工具注册表', 'No MCP servers configured with OAuth authentication.': @@ -700,6 +765,92 @@ export default { "Re-discovering tools from '{{name}}'...": "正在重新发现 '{{name}}' 的工具...", + // ============================================================================ + // MCP Management Dialog + // ============================================================================ + 'Manage MCP servers': '管理 MCP 服务器', + 'Server Detail': '服务器详情', + 'Disable Server': '禁用服务器', + Tools: '工具', + 'Tool Detail': '工具详情', + 'MCP Management': 'MCP 管理', + 'Loading...': '加载中...', + 'Unknown step': '未知步骤', + 'Esc to back': 'Esc 返回', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ 导航 · Enter 选择 · Esc 关闭', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ 导航 · Enter 选择 · Esc 返回', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ 导航 · Enter 确认 · Esc 返回', + 'User Settings (global)': '用户设置(全局)', + 'Workspace Settings (project-specific)': '工作区设置(项目级)', + 'Disable server:': '禁用服务器:', + 'Select where to add the server to the exclude list:': + '选择将服务器添加到排除列表的位置:', + 'Press Enter to confirm, Esc to cancel': '按 Enter 确认,Esc 取消', + 'View tools': '查看工具', + Reconnect: '重新连接', + Enable: '启用', + Disable: '禁用', + '(disabled)': '(已禁用)', + 'Error:': '错误:', + Extension: '扩展', + tool: '工具', + tools: '个工具', + connected: '已连接', + connecting: '连接中', + disconnected: '已断开', + + // MCP Server List + 'User MCPs': '用户 MCP', + 'Project MCPs': '项目 MCP', + 'Extension MCPs': '扩展 MCP', + server: '个服务器', + servers: '个服务器', + 'Add MCP servers to your settings to get started.': + '请在设置中添加 MCP 服务器以开始使用。', + 'Run qwen --debug to see error logs': '运行 qwen --debug 查看错误日志', + + // MCP Server Detail + 'Command:': '命令:', + 'Working Directory:': '工作目录:', + 'Capabilities:': '功能:', + + // MCP Tool List + 'No tools available for this server.': '此服务器没有可用工具。', + destructive: '破坏性', + 'read-only': '只读', + 'open-world': '开放世界', + idempotent: '幂等', + 'Tools for {{name}}': '{{name}} 的工具', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + Type: '类型', + Parameters: '参数', + 'No tool selected': '未选择工具', + Annotations: '注解', + Title: '标题', + 'Read Only': '只读', + Destructive: '破坏性', + Idempotent: '幂等', + 'Open World': '开放世界', + Server: '服务器', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} 个无效工具', + invalid: '无效', + 'invalid: {{reason}}': '无效:{{reason}}', + 'missing name': '缺少名称', + 'missing description': '缺少描述', + '(unnamed)': '(未命名)', + 'Warning: This tool cannot be called by the LLM': + '警告:此工具无法被 LLM 调用', + Reason: '原因', + 'Tools must have both name and description to be used by the LLM.': + '工具必须同时具有名称和描述才能被 LLM 使用。', + // ============================================================================ // Commands - Chat // ============================================================================ @@ -825,6 +976,7 @@ export default { 'Do you want to proceed?': '是否继续?', 'Yes, allow once': '是,允许一次', 'Allow always': '总是允许', + Yes: '是', No: '否', 'No (esc)': '否 (esc)', 'Yes, allow always for this session': '是,本次会话总是允许', @@ -1279,6 +1431,9 @@ export default { '{{region}} 有新的模型配置可用。是否立即更新?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} 配置更新成功。模型已切换至 "{{model}}"。', - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': - '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json(已备份)。', + '{{region}} configuration updated successfully.': '{{region}} 配置更新成功。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。', + 'Tip: Use /model to switch between available Coding Plan models.': + '提示:使用 /model 切换可用的 Coding Plan 模型。', }; diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index cda06daad..08ee98eb2 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -21,6 +21,7 @@ import { editorCommand } from '../ui/commands/editorCommand.js'; import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { languageCommand } from '../ui/commands/languageCommand.js'; @@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader { exportCommand, extensionsCommand, helpCommand, + hooksCommand, await ideCommand(), initCommand, languageCommand, diff --git a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts index 99bcb9e26..6d0c661cc 100644 --- a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts +++ b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts @@ -14,9 +14,7 @@ import type { InsightProgressCallback, } from '../types/StaticInsightTypes.js'; -import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core'; - -const logger = createDebugLogger('StaticInsightGenerator'); +import { updateSymlink, type Config } from '@qwen-code/qwen-code-core'; export class StaticInsightGenerator { private dataProcessor: DataProcessor; @@ -54,40 +52,12 @@ export class StaticInsightGenerator { return outputPath; } - // Create or update the "latest" alias (symlink preferred, copy as fallback) - private async updateLatestAlias( + private async updateInsightSymlink( outputDir: string, targetPath: string, ): Promise { const latestPath = path.join(outputDir, 'insight.html'); - const relativeTarget = path.relative(outputDir, targetPath); - - // Remove existing file/symlink if it exists - try { - await fs.unlink(latestPath); - } catch { - // File doesn't exist, ignore - } - - // Try symlink first (preferred - lightweight, always points to latest) - try { - await fs.symlink(relativeTarget, latestPath); - logger.debug('Created insight symlink:', relativeTarget); - return; - } catch (error) { - logger.debug( - 'Failed to create insight symlink, falling back to copy:', - error, - ); - } - - // Fallback: copy file (works everywhere, uses more disk space) - try { - await fs.copyFile(targetPath, latestPath); - logger.debug('Created insight copy:', targetPath); - } catch (error) { - logger.debug('Failed to create insight latest alias:', error); - } + await updateSymlink(latestPath, targetPath); } // Generate the static insight HTML file @@ -116,8 +86,7 @@ export class StaticInsightGenerator { // Write the HTML file await fs.writeFile(outputPath, html, 'utf-8'); - // Update latest alias (symlink preferred, copy as fallback) - await this.updateLatestAlias(outputDir, outputPath); + await this.updateInsightSymlink(outputDir, outputPath); return outputPath; } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 781aab375..c6bfa67c3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -102,6 +102,8 @@ import { useDialogClose } from './hooks/useDialogClose.js'; import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; +import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js'; +import { useMcpDialog } from './hooks/useMcpDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; import { requestConsentInteractive, @@ -493,6 +495,12 @@ export const AppContainer = (props: AppContainerProps) => { openAgentsManagerDialog, closeAgentsManagerDialog, } = useAgentsManagerDialog(); + const { + isExtensionsManagerDialogOpen, + openExtensionsManagerDialog, + closeExtensionsManagerDialog, + } = useExtensionsManagerDialog(); + const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog(); const slashCommandActions = useMemo( () => ({ @@ -515,6 +523,8 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openExtensionsManagerDialog, + openMcpDialog, openResumeDialog, }), [ @@ -530,6 +540,8 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openExtensionsManagerDialog, + openMcpDialog, openResumeDialog, ], ); @@ -1299,8 +1311,10 @@ export const AppContainer = (props: AppContainerProps) => { showIdeRestartPrompt || isSubagentCreateDialogOpen || isAgentsManagerDialogOpen || + isMcpDialogOpen || isApprovalModeDialogOpen || - isResumeDialogOpen; + isResumeDialogOpen || + isExtensionsManagerDialogOpen; const { isFeedbackDialogOpen, @@ -1410,6 +1424,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // Extensions manager dialog + isExtensionsManagerDialogOpen, + // MCP dialog + isMcpDialogOpen, // Feedback dialog isFeedbackDialogOpen, }), @@ -1500,6 +1518,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // Extensions manager dialog + isExtensionsManagerDialogOpen, + // MCP dialog + isMcpDialogOpen, // Feedback dialog isFeedbackDialogOpen, ], @@ -1541,6 +1563,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Extensions manager dialog + closeExtensionsManagerDialog, + // MCP dialog + closeMcpDialog, // Resume session dialog openResumeDialog, closeResumeDialog, @@ -1584,6 +1610,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Extensions manager dialog + closeExtensionsManagerDialog, + // MCP dialog + closeMcpDialog, // Resume session dialog openResumeDialog, closeResumeDialog, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 24cfbf61c..283a0d155 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -389,13 +389,24 @@ export const useAuthCommand = ( { type: MessageType.INFO, text: t( - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.', { region: t('Alibaba Cloud Coding Plan') }, ), }, Date.now(), ); + // Hint about /model command + addItem( + { + type: MessageType.INFO, + text: t( + 'Tip: Use /model to switch between available Coding Plan models.', + ), + }, + Date.now(), + ); + // Log success const authEvent = new AuthEvent( AuthType.USE_OPENAI, diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index eac703cfa..d8d8e83a0 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -150,7 +150,8 @@ Memory Usage: 100 MB`; Runtime: Node.js v20.0.0 / npm 10.0.0 IDE Client: VSCode OS: test-platform x64 (22.0.0) -Auth: ${AuthType.USE_OPENAI} (https://api.openai.com/v1) +Auth: API Key - ${AuthType.USE_OPENAI} +Base URL: https://api.openai.com/v1 Model: qwen3-coder-plus Session ID: test-session-id Sandbox: test diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index c14fdb389..33ea72e30 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -16,9 +16,8 @@ import { beforeEach, type MockedFunction, } from 'vitest'; -import { ExtensionUpdateState } from '../state/extensions.js'; + import { - type Extension, ExtensionManager, parseInstallSource, } from '@qwen-code/qwen-code-core'; @@ -33,24 +32,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }); const mockGetExtensions = vi.fn(); -const mockUpdateExtension = vi.fn(); -const mockUpdateAllUpdatableExtensions = vi.fn(); -const mockCheckForAllExtensionUpdates = vi.fn(); -const mockInstallExtension = vi.fn(); -const mockUninstallExtension = vi.fn(); const mockGetLoadedExtensions = vi.fn(); -const mockEnableExtension = vi.fn(); -const mockDisableExtension = vi.fn(); +const mockInstallExtension = vi.fn(); const createMockExtensionManager = () => ({ - updateExtension: mockUpdateExtension, - updateAllUpdatableExtensions: mockUpdateAllUpdatableExtensions, - checkForAllExtensionUpdates: mockCheckForAllExtensionUpdates, installExtension: mockInstallExtension, - uninstallExtension: mockUninstallExtension, getLoadedExtensions: mockGetLoadedExtensions, - enableExtension: mockEnableExtension, - disableExtension: mockDisableExtension, }); describe('extensionsCommand', () => { @@ -62,7 +49,6 @@ describe('extensionsCommand', () => { mockExtensionManager = createMockExtensionManager(); mockGetExtensions.mockReturnValue([]); mockGetLoadedExtensions.mockReturnValue([]); - mockCheckForAllExtensionUpdates.mockResolvedValue(undefined); mockContext = createMockCommandContext({ services: { config: { @@ -78,334 +64,57 @@ describe('extensionsCommand', () => { }); }); - describe('list', () => { - it('should add an EXTENSIONS_LIST item to the UI when extensions exist', async () => { + describe('default action (manage)', () => { + it('should open extensions manager dialog when extensions exist', async () => { if (!extensionsCommand.action) throw new Error('Action not defined'); mockGetExtensions.mockReturnValue([{ name: 'test-ext', isActive: true }]); - await extensionsCommand.action(mockContext, ''); + const result = await extensionsCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.EXTENSIONS_LIST, - }, - expect.any(Number), - ); + expect(result).toEqual({ + type: 'dialog', + dialog: 'extensions_manage', + }); }); - it('should show info message when no extensions installed', async () => { + it('should open extensions manager dialog when no extensions installed', async () => { if (!extensionsCommand.action) throw new Error('Action not defined'); mockGetExtensions.mockReturnValue([]); - await extensionsCommand.action(mockContext, ''); + const result = await extensionsCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'No extensions installed.', - }, - expect.any(Number), - ); + expect(result).toEqual({ + type: 'dialog', + dialog: 'extensions_manage', + }); }); }); - describe('update', () => { - const updateAction = extensionsCommand.subCommands?.find( - (cmd) => cmd.name === 'update', + describe('manage', () => { + const manageAction = extensionsCommand.subCommands?.find( + (cmd) => cmd.name === 'manage', )?.action; - if (!updateAction) { - throw new Error('Update action not found'); + if (!manageAction) { + throw new Error('Manage action not found'); } - it('should show usage if no args are provided', async () => { - await updateAction(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions update |--all', - }, - expect.any(Number), - ); - }); + it('should return dialog action for extensions manager', async () => { + mockGetExtensions.mockReturnValue([{ name: 'test-ext', isActive: true }]); + const result = await manageAction(mockContext, ''); - it('should inform user if there are no extensions to update with --all', async () => { - mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]); - mockUpdateAllUpdatableExtensions.mockResolvedValue([]); - await updateAction(mockContext, '--all'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'No extensions to update.', - }, - expect.any(Number), - ); - }); - - it('should call setPendingItem and addItem in a finally block on success', async () => { - mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]); - mockUpdateAllUpdatableExtensions.mockResolvedValue([ - { - name: 'ext-one', - originalVersion: '1.0.0', - updatedVersion: '1.0.1', - }, - { - name: 'ext-two', - originalVersion: '2.0.0', - updatedVersion: '2.0.1', - }, - ]); - await updateAction(mockContext, '--all'); - expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ - type: MessageType.EXTENSIONS_LIST, - }); - expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.EXTENSIONS_LIST, - }, - expect.any(Number), - ); - }); - - it('should call setPendingItem and addItem in a finally block on failure', async () => { - mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]); - mockUpdateAllUpdatableExtensions.mockRejectedValue( - new Error('Something went wrong'), - ); - await updateAction(mockContext, '--all'); - expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ - type: MessageType.EXTENSIONS_LIST, - }); - expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.EXTENSIONS_LIST, - }, - expect.any(Number), - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Something went wrong', - }, - expect.any(Number), - ); - }); - - it('should update a single extension by name', async () => { - const extension: Extension = { - id: 'ext-one', - name: 'ext-one', - version: '1.0.0', - isActive: true, - path: '/test/dir/ext-one', - contextFiles: [], - config: { name: 'ext-one', version: '1.0.0' }, - installMetadata: { - type: 'git', - autoUpdate: false, - source: 'https://github.com/some/extension.git', - }, - }; - mockUpdateExtension.mockResolvedValue({ - name: extension.name, - originalVersion: extension.version, - updatedVersion: '1.0.1', - }); - mockGetExtensions.mockReturnValue([extension]); - mockContext.ui.extensionsUpdateState.set(extension.name, { - status: ExtensionUpdateState.UPDATE_AVAILABLE, - processed: false, - }); - await updateAction(mockContext, 'ext-one'); - expect(mockUpdateExtension).toHaveBeenCalledWith( - extension, - ExtensionUpdateState.UPDATE_AVAILABLE, - expect.any(Function), - ); - }); - - it('should handle errors when updating a single extension', async () => { - // Provide at least one extension so we don't get "No extensions installed" message - const otherExtension: Extension = { - id: 'other-ext', - name: 'other-ext', - version: '1.0.0', - isActive: true, - path: '/test/dir/other-ext', - contextFiles: [], - config: { name: 'other-ext', version: '1.0.0' }, - }; - mockGetExtensions.mockReturnValue([otherExtension]); - await updateAction(mockContext, 'ext-one'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Extension "ext-one" not found.', - }, - expect.any(Number), - ); - }); - - it('should update multiple extensions by name', async () => { - const extensionOne: Extension = { - id: 'ext-one', - name: 'ext-one', - version: '1.0.0', - isActive: true, - path: '/test/dir/ext-one', - contextFiles: [], - config: { name: 'ext-one', version: '1.0.0' }, - installMetadata: { - type: 'git', - autoUpdate: false, - source: 'https://github.com/some/extension.git', - }, - }; - const extensionTwo: Extension = { - id: 'ext-two', - name: 'ext-two', - version: '1.0.0', - isActive: true, - path: '/test/dir/ext-two', - contextFiles: [], - config: { name: 'ext-two', version: '1.0.0' }, - installMetadata: { - type: 'git', - autoUpdate: false, - source: 'https://github.com/some/extension.git', - }, - }; - mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]); - mockContext.ui.extensionsUpdateState.set(extensionOne.name, { - status: ExtensionUpdateState.UPDATE_AVAILABLE, - processed: false, - }); - mockContext.ui.extensionsUpdateState.set(extensionTwo.name, { - status: ExtensionUpdateState.UPDATE_AVAILABLE, - processed: false, - }); - mockUpdateExtension - .mockResolvedValueOnce({ - name: 'ext-one', - originalVersion: '1.0.0', - updatedVersion: '1.0.1', - }) - .mockResolvedValueOnce({ - name: 'ext-two', - originalVersion: '2.0.0', - updatedVersion: '2.0.1', - }); - await updateAction(mockContext, 'ext-one ext-two'); - expect(mockUpdateExtension).toHaveBeenCalledTimes(2); - expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ - type: MessageType.EXTENSIONS_LIST, - }); - expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.EXTENSIONS_LIST, - }, - expect.any(Number), - ); - }); - - describe('completion', () => { - const updateCompletion = extensionsCommand.subCommands?.find( - (cmd) => cmd.name === 'update', - )?.completion; - - if (!updateCompletion) { - throw new Error('Update completion not found'); - } - - const extensionOne: Extension = { - id: 'ext-one', - name: 'ext-one', - version: '1.0.0', - isActive: true, - path: '/test/dir/ext-one', - contextFiles: [], - config: { name: 'ext-one', version: '1.0.0' }, - installMetadata: { - type: 'git', - autoUpdate: false, - source: 'https://github.com/some/extension.git', - }, - }; - const extensionTwo: Extension = { - id: 'another-ext', - contextFiles: [], - config: { name: 'another-ext', version: '1.0.0' }, - name: 'another-ext', - version: '1.0.0', - isActive: true, - path: '/test/dir/another-ext', - installMetadata: { - type: 'git', - autoUpdate: false, - source: 'https://github.com/some/extension.git', - }, - }; - const allExt: Extension = { - id: 'all-ext', - name: 'all-ext', - contextFiles: [], - config: { name: 'all-ext', version: '1.0.0' }, - version: '1.0.0', - isActive: true, - path: '/test/dir/all-ext', - installMetadata: { - type: 'git', - autoUpdate: false, - source: 'https://github.com/some/extension.git', - }, - }; - - it.each([ - { - description: 'should return matching extension names', - extensions: [extensionOne, extensionTwo], - partialArg: 'ext', - expected: ['ext-one'], - }, - { - description: 'should return --all when partialArg matches', - extensions: [], - partialArg: '--al', - expected: ['--all'], - }, - { - description: - 'should return both extension names and --all when both match', - extensions: [allExt], - partialArg: 'all', - expected: ['--all', 'all-ext'], - }, - { - description: 'should return an empty array if no matches', - extensions: [extensionOne], - partialArg: 'nomatch', - expected: [], - }, - ])('$description', async ({ extensions, partialArg, expected }) => { - mockGetExtensions.mockReturnValue(extensions); - const suggestions = await updateCompletion(mockContext, partialArg); - expect(suggestions).toEqual(expected); + expect(result).toEqual({ + type: 'dialog', + dialog: 'extensions_manage', }); }); - it('should call reloadCommands in finally block', async () => { - mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]); - mockUpdateAllUpdatableExtensions.mockResolvedValue([ - { - name: 'ext-one', - originalVersion: '1.0.0', - updatedVersion: '1.0.1', - }, - ]); - await updateAction(mockContext, '--all'); - expect(mockContext.ui.reloadCommands).toHaveBeenCalled(); + it('should return dialog action even when no extensions installed', async () => { + mockGetExtensions.mockReturnValue([]); + const result = await manageAction(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'extensions_manage', + }); }); }); @@ -501,363 +210,4 @@ describe('extensionsCommand', () => { ); }); }); - - describe('uninstall', () => { - const uninstallAction = extensionsCommand.subCommands?.find( - (cmd) => cmd.name === 'uninstall', - )?.action; - - if (!uninstallAction) { - throw new Error('Uninstall action not found'); - } - - let realMockExtensionManager: ExtensionManager; - - beforeEach(() => { - vi.resetAllMocks(); - realMockExtensionManager = Object.create(ExtensionManager.prototype); - realMockExtensionManager.uninstallExtension = mockUninstallExtension; - - mockContext = createMockCommandContext({ - services: { - config: { - getExtensions: mockGetExtensions, - getWorkingDir: () => '/test/dir', - getExtensionManager: () => realMockExtensionManager, - }, - }, - ui: { - dispatchExtensionStateUpdate: vi.fn(), - }, - }); - }); - - it('should show usage if no name is provided', async () => { - await uninstallAction(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions uninstall ', - }, - expect.any(Number), - ); - }); - - it('should uninstall extension successfully', async () => { - mockUninstallExtension.mockResolvedValue(undefined); - - await uninstallAction(mockContext, 'test-extension'); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Uninstalling extension "test-extension"...', - }, - expect.any(Number), - ); - expect(mockUninstallExtension).toHaveBeenCalledWith( - 'test-extension', - false, - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Extension "test-extension" uninstalled successfully.', - }, - expect.any(Number), - ); - expect(mockContext.ui.reloadCommands).toHaveBeenCalled(); - }); - - it('should handle uninstall errors', async () => { - mockUninstallExtension.mockRejectedValue( - new Error('Extension not found.'), - ); - - await uninstallAction(mockContext, 'nonexistent-extension'); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Failed to uninstall extension "nonexistent-extension": Extension not found.', - }, - expect.any(Number), - ); - }); - }); - - describe('disable', () => { - const disableAction = extensionsCommand.subCommands?.find( - (cmd) => cmd.name === 'disable', - )?.action; - - if (!disableAction) { - throw new Error('Disable action not found'); - } - - let realMockExtensionManager: ExtensionManager; - - beforeEach(() => { - vi.resetAllMocks(); - realMockExtensionManager = Object.create(ExtensionManager.prototype); - realMockExtensionManager.disableExtension = mockDisableExtension; - realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions; - - mockContext = createMockCommandContext({ - invocation: { - raw: '/extensions disable', - name: 'disable', - args: '', - }, - services: { - config: { - getExtensions: mockGetExtensions, - getWorkingDir: () => '/test/dir', - getExtensionManager: () => realMockExtensionManager, - }, - }, - ui: { - dispatchExtensionStateUpdate: vi.fn(), - }, - }); - }); - - it('should show usage if invalid args are provided', async () => { - await disableAction(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions disable [--scope=]', - }, - expect.any(Number), - ); - }); - - it('should disable extension at user scope', async () => { - mockDisableExtension.mockResolvedValue(undefined); - - await disableAction(mockContext, 'test-extension --scope=user'); - - expect(mockDisableExtension).toHaveBeenCalledWith( - 'test-extension', - 'User', - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Extension "test-extension" disabled for scope "User"', - }, - expect.any(Number), - ); - }); - - it('should disable extension at workspace scope', async () => { - mockDisableExtension.mockResolvedValue(undefined); - - await disableAction(mockContext, 'test-extension --scope workspace'); - - expect(mockDisableExtension).toHaveBeenCalledWith( - 'test-extension', - 'Workspace', - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Extension "test-extension" disabled for scope "Workspace"', - }, - expect.any(Number), - ); - }); - - it('should show error for invalid scope', async () => { - await disableAction(mockContext, 'test-extension --scope=invalid'); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Unsupported scope "invalid", should be one of "user" or "workspace"', - }, - expect.any(Number), - ); - }); - }); - - describe('enable', () => { - const enableAction = extensionsCommand.subCommands?.find( - (cmd) => cmd.name === 'enable', - )?.action; - - if (!enableAction) { - throw new Error('Enable action not found'); - } - - let realMockExtensionManager: ExtensionManager; - - beforeEach(() => { - vi.resetAllMocks(); - realMockExtensionManager = Object.create(ExtensionManager.prototype); - realMockExtensionManager.enableExtension = mockEnableExtension; - realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions; - - mockContext = createMockCommandContext({ - invocation: { - raw: '/extensions enable', - name: 'enable', - args: '', - }, - services: { - config: { - getExtensions: mockGetExtensions, - getWorkingDir: () => '/test/dir', - getExtensionManager: () => realMockExtensionManager, - }, - }, - ui: { - dispatchExtensionStateUpdate: vi.fn(), - }, - }); - }); - - it('should show usage if invalid args are provided', async () => { - await enableAction(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions enable [--scope=]', - }, - expect.any(Number), - ); - }); - - it('should enable extension at user scope', async () => { - mockEnableExtension.mockResolvedValue(undefined); - - await enableAction(mockContext, 'test-extension --scope=user'); - - expect(mockEnableExtension).toHaveBeenCalledWith( - 'test-extension', - 'User', - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Extension "test-extension" enabled for scope "User"', - }, - expect.any(Number), - ); - }); - - it('should enable extension at workspace scope', async () => { - mockEnableExtension.mockResolvedValue(undefined); - - await enableAction(mockContext, 'test-extension --scope workspace'); - - expect(mockEnableExtension).toHaveBeenCalledWith( - 'test-extension', - 'Workspace', - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Extension "test-extension" enabled for scope "Workspace"', - }, - expect.any(Number), - ); - }); - - it('should show error for invalid scope', async () => { - await enableAction(mockContext, 'test-extension --scope=invalid'); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Unsupported scope "invalid", should be one of "user" or "workspace"', - }, - expect.any(Number), - ); - }); - }); - - describe('detail', () => { - const detailAction = extensionsCommand.subCommands?.find( - (cmd) => cmd.name === 'detail', - )?.action; - - if (!detailAction) { - throw new Error('Detail action not found'); - } - - let realMockExtensionManager: ExtensionManager; - - beforeEach(() => { - vi.resetAllMocks(); - realMockExtensionManager = Object.create(ExtensionManager.prototype); - realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions; - - mockContext = createMockCommandContext({ - invocation: { - raw: '/extensions detail', - name: 'detail', - args: '', - }, - services: { - config: { - getExtensions: mockGetExtensions, - getWorkingDir: () => '/test/dir', - getExtensionManager: () => realMockExtensionManager, - }, - }, - ui: { - dispatchExtensionStateUpdate: vi.fn(), - }, - }); - }); - - it('should show usage if no name is provided', async () => { - await detailAction(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions detail ', - }, - expect.any(Number), - ); - }); - - it('should show error if extension not found', async () => { - mockGetExtensions.mockReturnValue([]); - await detailAction(mockContext, 'nonexistent-extension'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Extension "nonexistent-extension" not found.', - }, - expect.any(Number), - ); - }); - - it('should show extension details when found', async () => { - const extension: Extension = { - id: 'test-ext', - name: 'test-ext', - version: '1.0.0', - isActive: true, - path: '/test/dir/test-ext', - contextFiles: [], - config: { name: 'test-ext', version: '1.0.0' }, - }; - mockGetExtensions.mockReturnValue([extension]); - realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true); - - await detailAction(mockContext, 'test-ext'); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: expect.stringContaining('test-ext'), - }, - expect.any(Number), - ); - }); - }); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 132f92901..99667959b 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -5,7 +5,6 @@ */ import { getErrorMessage } from '../../utils/errors.js'; -import { ExtensionUpdateState } from '../state/extensions.js'; import { MessageType } from '../types.js'; import { type CommandContext, @@ -16,12 +15,9 @@ import { t } from '../../i18n/index.js'; import { ExtensionManager, parseInstallSource, - type ExtensionUpdateInfo, + createDebugLogger, } from '@qwen-code/qwen-code-core'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; -import { SettingScope } from '../../config/settings.js'; import open from 'open'; -import { extensionToOutputString } from '../../commands/extensions/utils.js'; const debugLogger = createDebugLogger('EXTENSIONS_COMMAND'); const EXTENSION_EXPLORE_URL = { @@ -31,23 +27,6 @@ const EXTENSION_EXPLORE_URL = { type ExtensionExploreSource = keyof typeof EXTENSION_EXPLORE_URL; -function showMessageIfNoExtensions( - context: CommandContext, - extensions: unknown[], -): boolean { - if (extensions.length === 0) { - context.ui.addItem( - { - type: MessageType.INFO, - text: t('No extensions installed.'), - }, - Date.now(), - ); - return true; - } - return false; -} - async function exploreAction(context: CommandContext, args: string) { const source = args.trim(); const extensionsUrl = source @@ -113,130 +92,11 @@ async function exploreAction(context: CommandContext, args: string) { } } -async function listAction(context: CommandContext) { - const extensions = context.services.config - ? context.services.config.getExtensions() - : []; - - if (showMessageIfNoExtensions(context, extensions)) { - return; - } - - context.ui.addItem( - { - type: MessageType.EXTENSIONS_LIST, - }, - Date.now(), - ); -} - -async function updateAction(context: CommandContext, args: string) { - const updateArgs = args.split(' ').filter((value) => value.length > 0); - const all = updateArgs.length === 1 && updateArgs[0] === '--all'; - const names = all ? undefined : updateArgs; - - if (!all && names?.length === 0) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Usage: /extensions update |--all'), - }, - Date.now(), - ); - return; - } - - let updateInfos: ExtensionUpdateInfo[] = []; - - const extensionManager = context.services.config!.getExtensionManager(); - const extensions = context.services.config - ? context.services.config.getExtensions() - : []; - - if (showMessageIfNoExtensions(context, extensions)) { - return Promise.resolve(); - } - - try { - context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_START' }); - await extensionManager.checkForAllExtensionUpdates((extensionName, state) => - context.ui.dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extensionName, state }, - }), - ); - context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_END' }); - - context.ui.setPendingItem({ - type: MessageType.EXTENSIONS_LIST, - }); - if (all) { - updateInfos = await extensionManager.updateAllUpdatableExtensions( - context.ui.extensionsUpdateState, - (extensionName, state) => - context.ui.dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extensionName, state }, - }), - ); - } else if (names?.length) { - const extensions = context.services.config!.getExtensions(); - for (const name of names) { - const extension = extensions.find( - (extension) => extension.name === name, - ); - if (!extension) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Extension "{{name}}" not found.', { name }), - }, - Date.now(), - ); - continue; - } - const updateInfo = await extensionManager.updateExtension( - extension, - context.ui.extensionsUpdateState.get(extension.name)?.status ?? - ExtensionUpdateState.UNKNOWN, - (extensionName, state) => - context.ui.dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extensionName, state }, - }), - ); - if (updateInfo) updateInfos.push(updateInfo); - } - } - - if (updateInfos.length === 0) { - context.ui.addItem( - { - type: MessageType.INFO, - text: t('No extensions to update.'), - }, - Date.now(), - ); - return; - } - } catch (error) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: getErrorMessage(error), - }, - Date.now(), - ); - } finally { - context.ui.addItem( - { - type: MessageType.EXTENSIONS_LIST, - }, - Date.now(), - ); - context.ui.reloadCommands(); - context.ui.setPendingItem(null); - } +async function listAction(_context: CommandContext, _args: string) { + return { + type: 'dialog' as const, + dialog: 'extensions_manage' as const, + }; } async function installAction(context: CommandContext, args: string) { @@ -296,235 +156,6 @@ async function installAction(context: CommandContext, args: string) { } } -async function uninstallAction(context: CommandContext, args: string) { - const extensionManager = context.services.config?.getExtensionManager(); - if (!(extensionManager instanceof ExtensionManager)) { - debugLogger.error( - `Cannot ${context.invocation?.name} extensions in this environment`, - ); - return; - } - - const name = args.trim(); - if (!name) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Usage: /extensions uninstall '), - }, - Date.now(), - ); - return; - } - - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Uninstalling extension "{{name}}"...', { name }), - }, - Date.now(), - ); - - try { - await extensionManager.uninstallExtension(name, false); - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Extension "{{name}}" uninstalled successfully.', { name }), - }, - Date.now(), - ); - context.ui.reloadCommands(); - } catch (error) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Failed to uninstall extension "{{name}}": {{error}}', { - name, - error: getErrorMessage(error), - }), - }, - Date.now(), - ); - } -} - -function getEnableDisableContext( - context: CommandContext, - argumentsString: string, -): { - extensionManager: ExtensionManager; - names: string[]; - scope: SettingScope; -} | null { - const extensionManager = context.services.config?.getExtensionManager(); - if (!(extensionManager instanceof ExtensionManager)) { - debugLogger.error( - `Cannot ${context.invocation?.name} extensions in this environment`, - ); - return null; - } - const parts = argumentsString.split(' '); - const name = parts[0]; - if ( - name === '' || - !( - (parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope= - (parts.length === 3 && parts[1] === '--scope') // --scope - ) - ) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t( - 'Usage: /extensions {{command}} [--scope=]', - { - command: context.invocation?.name ?? '', - }, - ), - }, - Date.now(), - ); - return null; - } - let scope: SettingScope; - // Transform `--scope=` to `--scope `. - if (parts.length === 2) { - parts.push(...parts[1].split('=')); - parts.splice(1, 1); - } - switch (parts[2].toLowerCase()) { - case 'workspace': - scope = SettingScope.Workspace; - break; - case 'user': - scope = SettingScope.User; - break; - default: - context.ui.addItem( - { - type: MessageType.ERROR, - text: t( - 'Unsupported scope "{{scope}}", should be one of "user" or "workspace"', - { - scope: parts[2], - }, - ), - }, - Date.now(), - ); - return null; - } - let names: string[] = []; - if (name === '--all') { - let extensions = extensionManager.getLoadedExtensions(); - if (context.invocation?.name === 'enable') { - extensions = extensions.filter((ext) => !ext.isActive); - } - if (context.invocation?.name === 'disable') { - extensions = extensions.filter((ext) => ext.isActive); - } - names = extensions.map((ext) => ext.name); - } else { - names = [name]; - } - - return { - extensionManager, - names, - scope, - }; -} - -async function disableAction(context: CommandContext, args: string) { - const enableContext = getEnableDisableContext(context, args); - if (!enableContext) return; - - const { names, scope, extensionManager } = enableContext; - for (const name of names) { - await extensionManager.disableExtension(name, scope); - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Extension "{{name}}" disabled for scope "{{scope}}"', { - name, - scope, - }), - }, - Date.now(), - ); - context.ui.reloadCommands(); - } -} - -async function enableAction(context: CommandContext, args: string) { - const enableContext = getEnableDisableContext(context, args); - if (!enableContext) return; - - const { names, scope, extensionManager } = enableContext; - for (const name of names) { - await extensionManager.enableExtension(name, scope); - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Extension "{{name}}" enabled for scope "{{scope}}"', { - name, - scope, - }), - }, - Date.now(), - ); - context.ui.reloadCommands(); - } -} - -async function detailAction(context: CommandContext, args: string) { - const extensionManager = context.services.config?.getExtensionManager(); - if (!(extensionManager instanceof ExtensionManager)) { - debugLogger.error( - `Cannot ${context.invocation?.name} extensions in this environment`, - ); - return; - } - - const name = args.trim(); - if (!name) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Usage: /extensions detail '), - }, - Date.now(), - ); - return; - } - - const extensions = context.services.config!.getExtensions(); - const extension = extensions.find((extension) => extension.name === name); - if (!extension) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Extension "{{name}}" not found.', { name }), - }, - Date.now(), - ); - return; - } - context.ui.addItem( - { - type: MessageType.INFO, - text: extensionToOutputString( - extension, - extensionManager, - process.cwd(), - true, - ), - }, - Date.now(), - ); -} - export async function completeExtensions( context: CommandContext, partialArg: string, @@ -589,45 +220,15 @@ const exploreExtensionsCommand: SlashCommand = { completion: completeExtensionsExplore, }; -const listExtensionsCommand: SlashCommand = { - name: 'list', +const manageExtensionsCommand: SlashCommand = { + name: 'manage', get description() { - return t('List active extensions'); + return t('Manage installed extensions'); }, kind: CommandKind.BUILT_IN, action: listAction, }; -const updateExtensionsCommand: SlashCommand = { - name: 'update', - get description() { - return t('Update extensions. Usage: update |--all'); - }, - kind: CommandKind.BUILT_IN, - action: updateAction, - completion: completeExtensions, -}; - -const disableCommand: SlashCommand = { - name: 'disable', - get description() { - return t('Disable an extension'); - }, - kind: CommandKind.BUILT_IN, - action: disableAction, - completion: completeExtensionsAndScopes, -}; - -const enableCommand: SlashCommand = { - name: 'enable', - get description() { - return t('Enable an extension'); - }, - kind: CommandKind.BUILT_IN, - action: enableAction, - completion: completeExtensionsAndScopes, -}; - const installCommand: SlashCommand = { name: 'install', get description() { @@ -637,26 +238,6 @@ const installCommand: SlashCommand = { action: installAction, }; -const uninstallCommand: SlashCommand = { - name: 'uninstall', - get description() { - return t('Uninstall an extension'); - }, - kind: CommandKind.BUILT_IN, - action: uninstallAction, - completion: completeExtensions, -}; - -const detailCommand: SlashCommand = { - name: 'detail', - get description() { - return t('Get detail of an extension'); - }, - kind: CommandKind.BUILT_IN, - action: detailAction, - completion: completeExtensions, -}; - export const extensionsCommand: SlashCommand = { name: 'extensions', get description() { @@ -664,16 +245,11 @@ export const extensionsCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, subCommands: [ - listExtensionsCommand, - updateExtensionsCommand, - disableCommand, - enableCommand, + manageExtensionsCommand, installCommand, - uninstallCommand, exploreExtensionsCommand, - detailCommand, ], - action: (context, args) => + action: async (context, args) => // Default to list if no subcommand is provided - listExtensionsCommand.action!(context, args), + manageExtensionsCommand.action!(context, args), }; diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts new file mode 100644 index 000000000..04951db7a --- /dev/null +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -0,0 +1,322 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + SlashCommandActionReturn, + CommandContext, + MessageActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; +import type { HookRegistryEntry } from '@qwen-code/qwen-code-core'; + +/** + * Format hook source for display + */ +function formatHookSource(source: string): string { + switch (source) { + case 'project': + return 'Project'; + case 'user': + return 'User'; + case 'system': + return 'System'; + case 'extensions': + return 'Extension'; + default: + return source; + } +} + +/** + * Format hook status for display + */ +function formatHookStatus(enabled: boolean): string { + return enabled ? '✓ Enabled' : '✗ Disabled'; +} + +const listCommand: SlashCommand = { + name: 'list', + get description() { + return t('List all configured hooks'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + _args: string, + ): Promise => { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'info', + content: t( + 'Hooks are not enabled. Enable hooks in settings to use this feature.', + ), + }; + } + + const registry = hookSystem.getRegistry(); + const allHooks = registry.getAllHooks(); + + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: t( + 'No hooks configured. Add hooks in your settings.json file.', + ), + }; + } + + // Group hooks by event + const hooksByEvent = new Map(); + for (const hook of allHooks) { + const eventName = hook.eventName; + if (!hooksByEvent.has(eventName)) { + hooksByEvent.set(eventName, []); + } + hooksByEvent.get(eventName)!.push(hook); + } + + let output = `**Configured Hooks (${allHooks.length} total)**\n\n`; + + for (const [eventName, hooks] of hooksByEvent) { + output += `### ${eventName}\n`; + for (const hook of hooks) { + const name = hook.config.name || hook.config.command || 'unnamed'; + const source = formatHookSource(hook.source); + const status = formatHookStatus(hook.enabled); + const matcher = hook.matcher ? ` (matcher: ${hook.matcher})` : ''; + output += `- **${name}** [${source}] ${status}${matcher}\n`; + } + output += '\n'; + } + + return { + type: 'message', + messageType: 'info', + content: output, + }; + }, +}; + +const enableCommand: SlashCommand = { + name: 'enable', + get description() { + return t('Enable a disabled hook'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Please specify a hook name. Usage: /hooks enable ', + ), + }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: t('Hooks are not enabled.'), + }; + } + + const registry = hookSystem.getRegistry(); + registry.setHookEnabled(hookName, true); + + return { + type: 'message', + messageType: 'info', + content: t('Hook "{{name}}" has been enabled for this session.', { + name: hookName, + }), + }; + }, + completion: async (context: CommandContext, partialArg: string) => { + const { config } = context.services; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const registry = hookSystem.getRegistry(); + const allHooks = registry.getAllHooks(); + + // Return disabled hooks for enable command (deduplicated by name) + const disabledHookNames = allHooks + .filter((hook) => !hook.enabled) + .map((hook) => hook.config.name || hook.config.command || '') + .filter((name) => name && name.startsWith(partialArg)); + return [...new Set(disabledHookNames)]; + }, +}; + +const disableCommand: SlashCommand = { + name: 'disable', + get description() { + return t('Disable an active hook'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Please specify a hook name. Usage: /hooks disable ', + ), + }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: t('Hooks are not enabled.'), + }; + } + + const registry = hookSystem.getRegistry(); + registry.setHookEnabled(hookName, false); + + return { + type: 'message', + messageType: 'info', + content: t('Hook "{{name}}" has been disabled for this session.', { + name: hookName, + }), + }; + }, + completion: async (context: CommandContext, partialArg: string) => { + const { config } = context.services; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const registry = hookSystem.getRegistry(); + const allHooks = registry.getAllHooks(); + + // Return enabled hooks for disable command (deduplicated by name) + const enabledHookNames = allHooks + .filter((hook) => hook.enabled) + .map((hook) => hook.config.name || hook.config.command || '') + .filter((name) => name && name.startsWith(partialArg)); + return [...new Set(enabledHookNames)]; + }, +}; + +export const hooksCommand: SlashCommand = { + name: 'hooks', + get description() { + return t('Manage Qwen Code hooks'); + }, + kind: CommandKind.BUILT_IN, + subCommands: [listCommand, enableCommand, disableCommand], + action: async ( + context: CommandContext, + args: string, + ): Promise => { + // If no subcommand provided, show list + if (!args.trim()) { + const result = await listCommand.action?.(context, ''); + return result ?? { type: 'message', messageType: 'info', content: '' }; + } + + const [subcommand, ...rest] = args.trim().split(/\s+/); + const subArgs = rest.join(' '); + + let result: SlashCommandActionReturn | void; + switch (subcommand.toLowerCase()) { + case 'list': + result = await listCommand.action?.(context, subArgs); + break; + case 'enable': + result = await enableCommand.action?.(context, subArgs); + break; + case 'disable': + result = await disableCommand.action?.(context, subArgs); + break; + default: + return { + type: 'message', + messageType: 'error', + content: t( + 'Unknown subcommand: {{cmd}}. Available: list, enable, disable', + { + cmd: subcommand, + }, + ), + }; + } + return result ?? { type: 'message', messageType: 'info', content: '' }; + }, + completion: async (context: CommandContext, partialArg: string) => { + const subcommands = ['list', 'enable', 'disable']; + const parts = partialArg.split(/\s+/); + + if (parts.length <= 1) { + // Complete subcommand + return subcommands.filter((cmd) => cmd.startsWith(partialArg)); + } + + // Complete subcommand arguments + const [subcommand, ...rest] = parts; + const subArgs = rest.join(' '); + + switch (subcommand.toLowerCase()) { + case 'enable': + return enableCommand.completion?.(context, subArgs) ?? []; + case 'disable': + return disableCommand.completion?.(context, subArgs) ?? []; + default: + return []; + } + }, +}; diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 6f963397f..f6fe3ca8d 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -12,13 +12,8 @@ import { MCPDiscoveryState, getMCPServerStatus, getMCPDiscoveryState, - DiscoveredMCPTool, } from '@qwen-code/qwen-code-core'; -import type { CallableTool } from '@google/genai'; -import { Type } from '@google/genai'; -import { MessageType } from '../types.js'; - vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actual = await importOriginal(); @@ -37,23 +32,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); -// Helper function to create a mock DiscoveredMCPTool -const createMockMCPTool = ( - name: string, - serverName: string, - description?: string, -) => - new DiscoveredMCPTool( - { - callTool: vi.fn(), - tool: vi.fn(), - } as unknown as CallableTool, - serverName, - name, - description || `Description for ${name}`, - { type: Type.OBJECT, properties: {} }, - ); - describe('mcpCommand', () => { let mockContext: ReturnType; let mockConfig: { @@ -70,7 +48,7 @@ describe('mcpCommand', () => { // Set up default mock environment vi.unstubAllEnvs(); - // Default mock implementations + // Default mock implementations - these are kept for auth subcommand tests vi.mocked(getMCPServerStatus).mockReturnValue(MCPServerStatus.CONNECTED); vi.mocked(getMCPDiscoveryState).mockReturnValue( MCPDiscoveryState.COMPLETED, @@ -98,7 +76,16 @@ describe('mcpCommand', () => { }); describe('basic functionality', () => { - it('should show an error if config is not available', async () => { + it('should open MCP management dialog by default', async () => { + const result = await mcpCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'mcp', + }); + }); + + it('should open MCP management dialog even if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { config: null, @@ -108,21 +95,19 @@ describe('mcpCommand', () => { const result = await mcpCommand.action!(contextWithoutConfig, ''); expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Config not loaded.', + type: 'dialog', + dialog: 'mcp', }); }); - it('should show an error if tool registry is not available', async () => { + it('should open MCP management dialog even if tool registry is not available', async () => { mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined); const result = await mcpCommand.action!(mockContext, ''); expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Could not retrieve tool registry.', + type: 'dialog', + dialog: 'mcp', }); }); }); @@ -138,73 +123,31 @@ describe('mcpCommand', () => { mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); }); - it('should display configured MCP servers with status indicators and their tools', async () => { - // Setup getMCPServerStatus mock implementation - vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { - if (serverName === 'server1') return MCPServerStatus.CONNECTED; - if (serverName === 'server2') return MCPServerStatus.CONNECTED; - return MCPServerStatus.DISCONNECTED; // server3 + it('should open MCP management dialog regardless of server configuration', async () => { + const result = await mcpCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'mcp', }); - - // Mock tools from each server using actual DiscoveredMCPTool instances - const mockServer1Tools = [ - createMockMCPTool('server1_tool1', 'server1'), - createMockMCPTool('server1_tool2', 'server1'), - ]; - const mockServer2Tools = [createMockMCPTool('server2_tool1', 'server2')]; - const mockServer3Tools = [createMockMCPTool('server3_tool1', 'server3')]; - - const allTools = [ - ...mockServer1Tools, - ...mockServer2Tools, - ...mockServer3Tools, - ]; - - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue(allTools), - }); - - await mcpCommand.action!(mockContext, ''); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.MCP_STATUS, - tools: allTools.map((tool) => ({ - serverName: tool.serverName, - name: tool.name, - description: tool.description, - schema: tool.schema, - })), - showTips: true, - }), - expect.any(Number), - ); }); - it('should display tool descriptions when desc argument is used', async () => { - await mcpCommand.action!(mockContext, 'desc'); + it('should open MCP management dialog with desc argument', async () => { + const result = await mcpCommand.action!(mockContext, 'desc'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.MCP_STATUS, - showDescriptions: true, - showTips: false, - }), - expect.any(Number), - ); + expect(result).toEqual({ + type: 'dialog', + dialog: 'mcp', + }); }); - it('should not display descriptions when nodesc argument is used', async () => { - await mcpCommand.action!(mockContext, 'nodesc'); + it('should open MCP management dialog with nodesc argument', async () => { + const result = await mcpCommand.action!(mockContext, 'nodesc'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.MCP_STATUS, - showDescriptions: false, - showTips: false, - }), - expect.any(Number), - ); + expect(result).toEqual({ + type: 'dialog', + dialog: 'mcp', + }); }); }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index d8fec7177..2a5100577 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -6,24 +6,17 @@ import type { SlashCommand, - SlashCommandActionReturn, CommandContext, MessageActionReturn, + OpenDialogActionReturn, } from './types.js'; import { CommandKind } from './types.js'; -import type { DiscoveredMCPPrompt } from '@qwen-code/qwen-code-core'; import { - DiscoveredMCPTool, - getMCPDiscoveryState, - getMCPServerStatus, - MCPDiscoveryState, - MCPServerStatus, getErrorMessage, MCPOAuthTokenStorage, MCPOAuthProvider, } from '@qwen-code/qwen-code-core'; import { appEvents, AppEvent } from '../../utils/events.js'; -import { MessageType, type HistoryItemMcpStatus } from '../types.js'; import { t } from '../../i18n/index.js'; const authCommand: SlashCommand = { @@ -189,183 +182,30 @@ const authCommand: SlashCommand = { }, }; -const listCommand: SlashCommand = { - name: 'list', +const manageCommand: SlashCommand = { + name: 'manage', get description() { - return t('List configured MCP servers and tools'); + return t('Open MCP management dialog'); }, kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - const { config } = context.services; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - const toolRegistry = config.getToolRegistry(); - if (!toolRegistry) { - return { - type: 'message', - messageType: 'error', - content: t('Could not retrieve tool registry.'), - }; - } - - const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean); - - const hasDesc = - lowerCaseArgs.includes('desc') || lowerCaseArgs.includes('descriptions'); - const hasNodesc = - lowerCaseArgs.includes('nodesc') || - lowerCaseArgs.includes('nodescriptions'); - const showSchema = lowerCaseArgs.includes('schema'); - - const showDescriptions = !hasNodesc && (hasDesc || showSchema); - const showTips = lowerCaseArgs.length === 0; - - const mcpServers = config.getMcpServers() || {}; - const serverNames = Object.keys(mcpServers); - const blockedMcpServers = config.getBlockedMcpServers() || []; - - const connectingServers = serverNames.filter( - (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, - ); - const discoveryState = getMCPDiscoveryState(); - const discoveryInProgress = - discoveryState === MCPDiscoveryState.IN_PROGRESS || - connectingServers.length > 0; - - const allTools = toolRegistry.getAllTools(); - const mcpTools = allTools.filter( - (tool) => tool instanceof DiscoveredMCPTool, - ) as DiscoveredMCPTool[]; - - const promptRegistry = await config.getPromptRegistry(); - const mcpPrompts = promptRegistry - .getAllPrompts() - .filter( - (prompt) => - 'serverName' in prompt && - serverNames.includes(prompt.serverName as string), - ) as DiscoveredMCPPrompt[]; - - const authStatus: HistoryItemMcpStatus['authStatus'] = {}; - const tokenStorage = new MCPOAuthTokenStorage(); - for (const serverName of serverNames) { - const server = mcpServers[serverName]; - if (server.oauth?.enabled) { - const creds = await tokenStorage.getCredentials(serverName); - if (creds) { - if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) { - authStatus[serverName] = 'expired'; - } else { - authStatus[serverName] = 'authenticated'; - } - } else { - authStatus[serverName] = 'unauthenticated'; - } - } else { - authStatus[serverName] = 'not-configured'; - } - } - - const mcpStatusItem: HistoryItemMcpStatus = { - type: MessageType.MCP_STATUS, - servers: mcpServers, - tools: mcpTools.map((tool) => ({ - serverName: tool.serverName, - name: tool.name, - description: tool.description, - schema: tool.schema, - })), - prompts: mcpPrompts.map((prompt) => ({ - serverName: prompt.serverName as string, - name: prompt.name, - description: prompt.description, - })), - authStatus, - blockedServers: blockedMcpServers, - discoveryInProgress, - connectingServers, - showDescriptions, - showSchema, - showTips, - }; - - context.ui.addItem(mcpStatusItem, Date.now()); - }, -}; - -const refreshCommand: SlashCommand = { - name: 'refresh', - get description() { - return t('Restarts MCP servers.'); - }, - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - ): Promise => { - const { config } = context.services; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - const toolRegistry = config.getToolRegistry(); - if (!toolRegistry) { - return { - type: 'message', - messageType: 'error', - content: t('Could not retrieve tool registry.'), - }; - } - - context.ui.addItem( - { - type: 'info', - text: t('Restarting MCP servers...'), - }, - Date.now(), - ); - - await toolRegistry.restartMcpServers(); - - // Update the client with the new tools - const geminiClient = config.getGeminiClient(); - if (geminiClient) { - await geminiClient.setTools(); - } - - // Reload the slash commands to reflect the changes. - context.ui.reloadCommands(); - - return listCommand.action!(context, ''); - }, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'mcp', + }), }; export const mcpCommand: SlashCommand = { name: 'mcp', get description() { return t( - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers', ); }, kind: CommandKind.BUILT_IN, - subCommands: [listCommand, authCommand, refreshCommand], - // Default action when no subcommand is provided - action: async ( - context: CommandContext, - args: string, - ): Promise => - // If no subcommand, run the list command - listCommand.action!(context, args), + subCommands: [manageCommand, authCommand], + // Default action when no subcommand is provided - open dialog + action: async (): Promise => ({ + type: 'dialog', + dialog: 'mcp', + }), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 90330e988..19db869ea 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -148,7 +148,9 @@ export interface OpenDialogActionReturn { | 'subagent_list' | 'permissions' | 'approval-mode' - | 'resume'; + | 'resume' + | 'extensions_manage' + | 'mcp'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c79e91119..26390e270 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -34,6 +34,8 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; +import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js'; +import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; interface DialogManagerProps { @@ -292,6 +294,18 @@ export const DialogManager = ({ ); } + if (uiState.isExtensionsManagerDialogOpen) { + return ( + + ); + } + if (uiState.isMcpDialogOpen) { + return ; + } + if (uiState.isResumeDialogOpen) { return ( = ({ embeddedShellFocused, availableTerminalHeightGemini, }) => { + const marginTop = + item.type === 'gemini_content' || item.type === 'gemini_thought_content' + ? 0 + : 1; + const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); const contentWidth = terminalWidth - 4; const boxWidth = mainAreaWidth || contentWidth; @@ -69,6 +78,7 @@ const HistoryItemDisplayComponent: React.FC = ({ @@ -80,7 +90,7 @@ const HistoryItemDisplayComponent: React.FC = ({ )} {itemForDisplay.type === 'gemini' && ( - = ({ /> )} {itemForDisplay.type === 'gemini_content' && ( - = ({ /> )} {itemForDisplay.type === 'gemini_thought' && ( - = ({ /> )} {itemForDisplay.type === 'gemini_thought_content' && ( - > should render a full gemini item when using availableTerminalHeightGemini 1`] = ` -" ✦ Example code block: +" + ✦ Example code block: 1 Line 1 2 Line 2 3 Line 3 @@ -109,7 +110,8 @@ exports[` > should render a full gemini_content item when `; exports[` > should render a truncated gemini item 1`] = ` -" ✦ Example code block: +" + ✦ Example code block: ... first 41 lines hidden ... 42 Line 42 43 Line 43 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 d6cf8d2f8..5b1c5bb95 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -11,7 +11,7 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -32,7 +32,7 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -53,7 +53,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode true* │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -74,7 +74,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false* │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false* │ │ ▼ │ @@ -95,7 +95,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode (Modified in System) false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -116,7 +116,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode (Modified in Workspace) false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -137,7 +137,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false* │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -179,7 +179,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -200,7 +200,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode true* │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE true* │ │ ▼ │ diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx new file mode 100644 index 000000000..22f11e16b --- /dev/null +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js'; +import { UIStateContext } from '../../contexts/UIStateContext.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import type { UIState } from '../../contexts/UIStateContext.js'; +import type { Config, Extension } from '@qwen-code/qwen-code-core'; +import { ExtensionUpdateState } from '../../state/extensions.js'; + +const createMockExtension = ( + name: string, + isActive = true, + version = '1.0.0', +): Extension => + ({ + id: name, + name, + version, + path: `/home/user/.qwen/extensions/${name}`, + isActive, + installMetadata: { + type: 'git', + source: `github:user/${name}`, + }, + mcpServers: {}, + commands: [], + skills: [], + agents: [], + resolvedSettings: [], + config: {}, + contextFiles: [], + }) as unknown as Extension; + +const createMockConfig = (extensions: Extension[] = []): Config => + ({ + getExtensions: () => extensions, + getExtensionManager: () => ({ + getLoadedExtensions: () => extensions, + refreshCache: vi.fn().mockResolvedValue(undefined), + checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined), + disableExtension: vi.fn().mockResolvedValue(undefined), + enableExtension: vi.fn().mockResolvedValue(undefined), + uninstallExtension: vi.fn().mockResolvedValue(undefined), + updateExtension: vi.fn().mockResolvedValue(undefined), + }), + getLoadedExtensions: () => extensions, + }) as unknown as Config; + +const createMockUIState = ( + extensionsUpdateState = new Map(), +): UIState => + ({ + extensionsUpdateState, + }) as unknown as UIState; + +describe('ExtensionsManagerDialog Snapshots', () => { + const baseProps = { + onClose: vi.fn(), + config: createMockConfig(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should render empty state when no extensions installed', () => { + const uiState = createMockUIState(); + const { lastFrame } = render( + + + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render extension list with extensions', () => { + const extensions = [ + createMockExtension('test-extension', true), + createMockExtension('another-extension', false), + ]; + const uiState = createMockUIState( + new Map([ + ['test-extension', ExtensionUpdateState.UP_TO_DATE], + ['another-extension', ExtensionUpdateState.UPDATE_AVAILABLE], + ]), + ); + const { lastFrame } = render( + + + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with update available status', () => { + const extensions = [createMockExtension('outdated-extension', true)]; + const uiState = createMockUIState( + new Map([['outdated-extension', ExtensionUpdateState.UPDATE_AVAILABLE]]), + ); + const { lastFrame } = render( + + + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with checking status', () => { + const extensions = [createMockExtension('checking-extension', true)]; + const uiState = createMockUIState( + new Map([ + ['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES], + ]), + ); + const { lastFrame } = render( + + + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx new file mode 100644 index 000000000..8a5a90d01 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -0,0 +1,526 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useMemo, useEffect } from 'react'; +import { Box, Text } from 'ink'; +import { + ExtensionListStep, + ExtensionDetailStep, + ActionSelectionStep, + UninstallConfirmStep, + ScopeSelectStep, +} from './steps/index.js'; +import { MANAGEMENT_STEPS, type ExtensionAction } from './types.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { t } from '../../../i18n/index.js'; +import type { Extension, Config } from '@qwen-code/qwen-code-core'; +import { SettingScope, createDebugLogger } from '@qwen-code/qwen-code-core'; +import { ExtensionUpdateState } from '../../state/extensions.js'; +import { getErrorMessage } from '../../../utils/errors.js'; + +interface ExtensionsManagerDialogProps { + onClose: () => void; + config: Config | null; +} + +const debugLogger = createDebugLogger('EXTENSIONS_MANAGER_DIALOG'); + +export function ExtensionsManagerDialog({ + onClose, + config, +}: ExtensionsManagerDialogProps) { + const { extensionsUpdateState } = useUIState(); + + const [extensions, setExtensions] = useState([]); + const [selectedExtensionIndex, setSelectedExtensionIndex] = + useState(-1); + const [navigationStack, setNavigationStack] = useState([ + MANAGEMENT_STEPS.EXTENSION_LIST, + ]); + const [updateInProgress, setUpdateInProgress] = useState(false); + const [updateError, setUpdateError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + // Load extensions + const loadExtensions = useCallback(async () => { + if (!config) return; + + const extensionManager = config.getExtensionManager(); + if (!extensionManager) { + debugLogger.error('ExtensionManager not available'); + return; + } + + try { + await extensionManager.refreshCache(); + const loadedExtensions = extensionManager.getLoadedExtensions(); + setExtensions(loadedExtensions); + } catch (error) { + debugLogger.error('Failed to load extensions:', error); + } + }, [config]); + + // Initial load + useEffect(() => { + loadExtensions(); + }, [loadExtensions]); + + // Memoized selected extension + const selectedExtension = useMemo( + () => + selectedExtensionIndex >= 0 ? extensions[selectedExtensionIndex] : null, + [extensions, selectedExtensionIndex], + ); + + // Check if update is available for selected extension + const hasUpdateAvailable = useMemo(() => { + if (!selectedExtension) return false; + const state = extensionsUpdateState.get(selectedExtension.name); + return state === ExtensionUpdateState.UPDATE_AVAILABLE; + }, [selectedExtension, extensionsUpdateState]); + + // Helper to get current step + const getCurrentStep = useCallback( + () => + navigationStack[navigationStack.length - 1] || + MANAGEMENT_STEPS.EXTENSION_LIST, + [navigationStack], + ); + + const handleSelectExtension = useCallback((extensionIndex: number) => { + setSelectedExtensionIndex(extensionIndex); + setSuccessMessage(null); // Clear success message when navigating + setErrorMessage(null); // Clear error message when navigating + setNavigationStack((prev) => [...prev, MANAGEMENT_STEPS.ACTION_SELECTION]); + }, []); + + const handleNavigateToStep = useCallback((step: string) => { + setNavigationStack((prev) => [...prev, step]); + }, []); + + const handleNavigateBack = useCallback(() => { + setNavigationStack((prev) => { + if (prev.length <= 1) { + return prev; + } + return prev.slice(0, -1); + }); + // Clear messages when navigating back + setErrorMessage(null); + }, []); + + const handleUpdateExtension = useCallback(async () => { + if (!config || !selectedExtension) return; + + setUpdateInProgress(true); + setUpdateError(null); + + try { + const extensionManager = config.getExtensionManager(); + if (!extensionManager) { + throw new Error('ExtensionManager not available'); + } + + const state = extensionsUpdateState.get(selectedExtension.name); + if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) { + throw new Error('No update available'); + } + + // Use the extension manager to update + await extensionManager.updateExtension( + selectedExtension, + ExtensionUpdateState.UPDATE_AVAILABLE, + (name, newState) => { + debugLogger.debug(`Update state for ${name}:`, newState); + }, + ); + + // Reload extensions after update to get new version info + await loadExtensions(); + + // Trigger a re-check of update status for all extensions + await extensionManager.checkForAllExtensionUpdates((name, newState) => { + debugLogger.debug(`Recheck update state for ${name}:`, newState); + }); + + // Show success message + setSuccessMessage( + t('Extension "{{name}}" updated successfully.', { + name: selectedExtension.name, + }), + ); + + // Go back to action selection + handleNavigateBack(); + } catch (error) { + debugLogger.error('Failed to update extension:', error); + setUpdateError( + error instanceof Error ? error.message : 'Unknown error occurred', + ); + } finally { + setUpdateInProgress(false); + } + }, [ + config, + selectedExtension, + extensionsUpdateState, + loadExtensions, + handleNavigateBack, + ]); + + const handleActionSelect = useCallback( + (action: ExtensionAction) => { + switch (action) { + case 'view': + handleNavigateToStep(MANAGEMENT_STEPS.EXTENSION_DETAIL); + break; + case 'update': + handleNavigateToStep(MANAGEMENT_STEPS.UPDATE_PROGRESS); + handleUpdateExtension(); + break; + case 'disable': + handleNavigateToStep(MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT); + break; + case 'enable': + handleNavigateToStep(MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT); + break; + case 'uninstall': + handleNavigateToStep(MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION); + break; + default: + break; + } + }, + [handleNavigateToStep, handleUpdateExtension], + ); + + // Unified handler for toggling extension state (enable/disable) + const handleToggleExtensionState = useCallback( + async (scope: 'user' | 'workspace', newState: boolean) => { + if (!config || !selectedExtension) return; + + try { + const extensionManager = config.getExtensionManager(); + if (!extensionManager) { + throw new Error('ExtensionManager not available'); + } + + const settingScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + + if (newState) { + await extensionManager.enableExtension( + selectedExtension.name, + settingScope, + ); + } else { + await extensionManager.disableExtension( + selectedExtension.name, + settingScope, + ); + } + + // Update local state + setExtensions((prev) => + prev.map((ext) => + ext.name === selectedExtension.name + ? { ...ext, isActive: newState } + : ext, + ), + ); + + // Show success message + const actionKey = newState ? 'enabled' : 'disabled'; + setSuccessMessage( + t(`Extension "{{name}}" ${actionKey} successfully.`, { + name: selectedExtension.name, + }), + ); + setErrorMessage(null); + + // Go back to extension list to show success message + setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]); + } catch (error) { + debugLogger.error( + `Failed to ${newState ? 'enable' : 'disable'} extension:`, + error, + ); + setErrorMessage( + t('Failed to {{action}} extension "{{name}}": {{error}}', { + action: newState ? 'enable' : 'disable', + name: selectedExtension.name, + error: getErrorMessage(error), + }), + ); + setSuccessMessage(null); + } + }, + [config, selectedExtension], + ); + + const handleDisableExtension = useCallback( + async (scope: 'user' | 'workspace') => { + await handleToggleExtensionState(scope, false); + }, + [handleToggleExtensionState], + ); + + const handleEnableExtension = useCallback( + async (scope: 'user' | 'workspace') => { + await handleToggleExtensionState(scope, true); + }, + [handleToggleExtensionState], + ); + + const handleUninstallExtension = useCallback( + async (extension: Extension) => { + if (!config) return; + + try { + const extensionManager = config.getExtensionManager(); + if (!extensionManager) { + throw new Error('ExtensionManager not available'); + } + + await extensionManager.uninstallExtension(extension.name, false); + + // Reload extensions + await loadExtensions(); + + // Navigate back to extension list + setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]); + setSelectedExtensionIndex(-1); + } catch (error) { + debugLogger.error('Failed to uninstall extension:', error); + throw error; + } + }, + [config, loadExtensions], + ); + + // Centralized ESC key handling + useKeypress( + (key) => { + if (key.name !== 'escape') { + return; + } + + const currentStep = getCurrentStep(); + // If there's a success message, clear it first instead of closing + if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + setSuccessMessage(null); + return; + } + if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + onClose(); + } else { + handleNavigateBack(); + } + }, + { isActive: true }, + ); + + const renderStepHeader = useCallback(() => { + const currentStep = getCurrentStep(); + const getStepHeaderText = () => { + switch (currentStep) { + case MANAGEMENT_STEPS.EXTENSION_LIST: + return t('Manage Extensions'); + case MANAGEMENT_STEPS.ACTION_SELECTION: + return selectedExtension?.name || t('Choose Action'); + case MANAGEMENT_STEPS.EXTENSION_DETAIL: + return t('Extension Details'); + case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + return t('Disable Extension'); + case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT: + return t('Enable Extension'); + case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION: + return t('Uninstall Extension'); + case MANAGEMENT_STEPS.UPDATE_PROGRESS: + return t('Update Extension'); + default: + return t('Unknown Step'); + } + }; + + return ( + + + {getStepHeaderText()} + + + ); + }, [getCurrentStep, selectedExtension]); + + const renderStepFooter = useCallback(() => { + const currentStep = getCurrentStep(); + const getNavigationInstructions = () => { + if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + if (extensions.length === 0) { + return t('Esc to close'); + } + return t('Enter to select, ↑↓ to navigate, Esc to close'); + } + + if (currentStep === MANAGEMENT_STEPS.EXTENSION_DETAIL) { + return t('Esc to go back'); + } + + if (currentStep === MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION) { + return t('Y/Enter to confirm, N/Esc to cancel'); + } + + if (currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) { + return updateInProgress ? t('Updating...') : ''; + } + + return t('Enter to select, ↑↓ to navigate, Esc to go back'); + }; + + return ( + + {getNavigationInstructions()} + + ); + }, [getCurrentStep, extensions.length, updateInProgress]); + + const renderStepContent = useCallback(() => { + const currentStep = getCurrentStep(); + + // Show error message if present (only on extension list step) + if (errorMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + return ( + + {errorMessage} + + ); + } + + // Show success message if present (only on extension list step) + if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + return ( + + {successMessage} + + ); + } + + if (updateError && currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) { + return ( + + {t('Update failed:')} + {updateError} + + ); + } + + switch (currentStep) { + case MANAGEMENT_STEPS.EXTENSION_LIST: + return ( + + ); + case MANAGEMENT_STEPS.ACTION_SELECTION: + return ( + + ); + case MANAGEMENT_STEPS.EXTENSION_DETAIL: + return ; + case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + return ( + + ); + case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT: + return ( + + ); + case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION: + return ( + + ); + case MANAGEMENT_STEPS.UPDATE_PROGRESS: + return ( + + + {updateInProgress + ? t('Updating {{name}}...', { + name: selectedExtension?.name || '', + }) + : t('Update complete!')} + + + ); + default: + return ( + + + {t('Invalid step: {{step}}', { step: currentStep })} + + + ); + } + }, [ + getCurrentStep, + extensions, + extensionsUpdateState, + selectedExtension, + hasUpdateAvailable, + updateInProgress, + updateError, + successMessage, + errorMessage, + handleSelectExtension, + handleNavigateToStep, + handleNavigateBack, + handleActionSelect, + handleDisableExtension, + handleEnableExtension, + handleUninstallExtension, + ]); + + return ( + + + {renderStepHeader()} + {renderStepContent()} + {renderStepFooter()} + + + ); +} diff --git a/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap b/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap new file mode 100644 index 000000000..af6ba07c4 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ExtensionsManagerDialog Snapshots > should render empty state when no extensions installed 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Manage Extensions │ +│ │ +│ No extensions installed. │ +│ Use '/extensions install' to install your first extension. │ +│ │ +│ Esc to close │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`ExtensionsManagerDialog Snapshots > should render extension list with extensions 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Manage Extensions │ +│ │ +│ No extensions installed. │ +│ Use '/extensions install' to install your first extension. │ +│ │ +│ Esc to close │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`ExtensionsManagerDialog Snapshots > should render with checking status 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Manage Extensions │ +│ │ +│ No extensions installed. │ +│ Use '/extensions install' to install your first extension. │ +│ │ +│ Esc to close │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`ExtensionsManagerDialog Snapshots > should render with update available status 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Manage Extensions │ +│ │ +│ No extensions installed. │ +│ Use '/extensions install' to install your first extension. │ +│ │ +│ Esc to close │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; diff --git a/packages/cli/src/ui/components/extensions/index.ts b/packages/cli/src/ui/components/extensions/index.ts new file mode 100644 index 000000000..e368898af --- /dev/null +++ b/packages/cli/src/ui/components/extensions/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js'; +export type { ExtensionsManagerDialogProps } from './types.js'; +export { MANAGEMENT_STEPS } from './types.js'; diff --git a/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.test.tsx b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.test.tsx new file mode 100644 index 000000000..d2d7a2709 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.test.tsx @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi } from 'vitest'; +import { ActionSelectionStep } from './ActionSelectionStep.js'; +import { KeypressProvider } from '../../../contexts/KeypressContext.js'; +import type { Extension } from '@qwen-code/qwen-code-core'; + +const createMockExtension = (name: string, isActive = true): Extension => + ({ + id: name, + name, + version: '1.0.0', + path: `/home/user/.qwen/extensions/${name}`, + isActive, + installMetadata: { + type: 'git', + source: `github:user/${name}`, + }, + mcpServers: {}, + commands: [], + skills: [], + agents: [], + resolvedSettings: [], + config: {}, + contextFiles: [], + }) as unknown as Extension; + +describe('ActionSelectionStep Snapshots', () => { + const baseProps = { + onNavigateToStep: vi.fn(), + onNavigateBack: vi.fn(), + onActionSelect: vi.fn(), + }; + + it('should render for active extension without update', () => { + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render for disabled extension', () => { + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render for extension with update available', () => { + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render for disabled extension with update', () => { + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with no extension selected', () => { + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx new file mode 100644 index 000000000..aa4e0cf18 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo } from 'react'; +import { Box } from 'ink'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; +import { type ExtensionAction } from '../types.js'; + +interface ActionSelectionStepProps { + selectedExtension: Extension | null; + hasUpdateAvailable: boolean; + onNavigateToStep: (step: string) => void; + onNavigateBack: () => void; + onActionSelect: (action: ExtensionAction) => void; +} + +export const ActionSelectionStep = ({ + selectedExtension, + hasUpdateAvailable, + onNavigateBack, + onActionSelect, +}: ActionSelectionStepProps) => { + const [selectedAction, setSelectedAction] = useState( + null, + ); + + const isActive = selectedExtension?.isActive ?? false; + + // Build action list based on extension state + const actions = useMemo(() => { + const allActions = [ + { + key: 'view', + get label() { + return t('View Details'); + }, + value: 'view' as const, + }, + ...(hasUpdateAvailable + ? [ + { + key: 'update', + get label() { + return t('Update Extension'); + }, + value: 'update' as const, + }, + ] + : []), + ...(isActive + ? [ + { + key: 'disable', + get label() { + return t('Disable Extension'); + }, + value: 'disable' as const, + }, + ] + : [ + { + key: 'enable', + get label() { + return t('Enable Extension'); + }, + value: 'enable' as const, + }, + ]), + { + key: 'uninstall', + get label() { + return t('Uninstall Extension'); + }, + value: 'uninstall' as const, + }, + { + key: 'back', + get label() { + return t('Back'); + }, + value: 'back' as const, + }, + ]; + return allActions; + }, [hasUpdateAvailable, isActive]); + + const handleActionSelect = (value: ExtensionAction) => { + if (value === 'back') { + onNavigateBack(); + return; + } + + setSelectedAction(value); + onActionSelect(value); + }; + + const selectedIndex = selectedAction + ? actions.findIndex((action) => action.value === selectedAction) + : 0; + + return ( + + + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/steps/ExtensionDetailStep.tsx b/packages/cli/src/ui/components/extensions/steps/ExtensionDetailStep.tsx new file mode 100644 index 000000000..10b17a6c1 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ExtensionDetailStep.tsx @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; + +interface ExtensionDetailStepProps { + selectedExtension: Extension | null; +} + +export const ExtensionDetailStep = ({ + selectedExtension, +}: ExtensionDetailStepProps) => { + if (!selectedExtension) { + return ( + + {t('No extension selected')} + + ); + } + + const ext = selectedExtension; + const isActive = ext.isActive; + const activeColor = isActive ? theme.status.success : theme.text.secondary; + const activeString = isActive ? t('active') : t('disabled'); + + // Fixed width for labels to ensure alignment + const LABEL_WIDTH = 12; + + return ( + + + + + {t('Name:')} + + {ext.name} + + + + + {t('Version:')} + + {ext.version} + + + + + {t('Status:')} + + {activeString} + + + + + {t('Path:')} + + {ext.path} + + + {ext.installMetadata && ( + + + {t('Source:')} + + {ext.installMetadata.source} + + )} + + {ext.mcpServers && Object.keys(ext.mcpServers).length > 0 && ( + + + {t('MCP Servers:')} + + {Object.keys(ext.mcpServers).join(', ')} + + )} + + {ext.commands && ext.commands.length > 0 && ( + + + {t('Commands:')} + + {ext.commands.join(', ')} + + )} + + {ext.skills && ext.skills.length > 0 && ( + + + {t('Skills:')} + + {ext.skills.map((s) => s.name).join(', ')} + + )} + + {ext.agents && ext.agents.length > 0 && ( + + + {t('Agents:')} + + {ext.agents.map((a) => a.name).join(', ')} + + )} + + {ext.resolvedSettings && ext.resolvedSettings.length > 0 && ( + + + {t('Settings:')} + + + {ext.resolvedSettings.map((setting) => ( + + - {setting.name}: {setting.value} + + ))} + + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.test.tsx b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.test.tsx new file mode 100644 index 000000000..80f53cc71 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.test.tsx @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi } from 'vitest'; +import { ExtensionListStep } from './ExtensionListStep.js'; +import { KeypressProvider } from '../../../contexts/KeypressContext.js'; +import type { Extension } from '@qwen-code/qwen-code-core'; +import { ExtensionUpdateState } from '../../../state/extensions.js'; + +const createMockExtension = ( + name: string, + isActive = true, + version = '1.0.0', +): Extension => + ({ + id: name, + name, + version, + path: `/home/user/.qwen/extensions/${name}`, + isActive, + installMetadata: { + type: 'git', + source: `github:user/${name}`, + }, + mcpServers: {}, + commands: [], + skills: [], + agents: [], + resolvedSettings: [], + config: {}, + contextFiles: [], + }) as unknown as Extension; + +describe('ExtensionListStep Snapshots', () => { + const baseProps = { + onExtensionSelect: vi.fn(), + }; + + it('should render empty state', () => { + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render list with single extension', () => { + const extensions = [createMockExtension('test-extension', true)]; + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render list with multiple extensions', () => { + const extensions = [ + createMockExtension('active-extension', true), + createMockExtension('disabled-extension', false), + createMockExtension('update-available', true), + ]; + const updateState = new Map([ + ['active-extension', ExtensionUpdateState.UP_TO_DATE], + ['disabled-extension', ExtensionUpdateState.NOT_UPDATABLE], + ['update-available', ExtensionUpdateState.UPDATE_AVAILABLE], + ]); + + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with checking status', () => { + const extensions = [createMockExtension('checking-extension', true)]; + const updateState = new Map([ + ['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES], + ]); + + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with error status', () => { + const extensions = [createMockExtension('error-extension', true)]; + const updateState = new Map([ + ['error-extension', ExtensionUpdateState.ERROR], + ]); + + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx new file mode 100644 index 000000000..103ecf93e --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; +import { ExtensionUpdateState } from '../../../state/extensions.js'; + +interface ExtensionListStepProps { + extensions: Extension[]; + extensionsUpdateState: Map; + onExtensionSelect: (extensionIndex: number) => void; +} + +export const ExtensionListStep = ({ + extensions, + extensionsUpdateState, + onExtensionSelect, +}: ExtensionListStepProps) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + // Calculate max widths for each column for alignment + const { maxNameWidth, maxVersionWidth, maxStatusWidth } = useMemo(() => { + let maxName = 0; + let maxVersion = 0; + let maxStatus = 0; + for (const ext of extensions) { + maxName = Math.max(maxName, ext.name.length); + maxVersion = Math.max(maxVersion, ext.version.length); + const statusLength = ext.isActive + ? t('active').length + : t('disabled').length; + maxStatus = Math.max(maxStatus, statusLength); + } + return { + maxNameWidth: maxName, + maxVersionWidth: maxVersion, + maxStatusWidth: maxStatus, + }; + }, [extensions]); + + // Reset selection when extensions change + useEffect(() => { + if (extensions.length > 0 && selectedIndex >= extensions.length) { + setSelectedIndex(0); + } + }, [extensions, selectedIndex]); + + // Keyboard navigation + useKeypress( + (key) => { + if (key.name === 'up' || key.name === 'k') { + setSelectedIndex((prev) => + prev > 0 ? prev - 1 : extensions.length - 1, + ); + } else if (key.name === 'down' || key.name === 'j') { + setSelectedIndex((prev) => + prev < extensions.length - 1 ? prev + 1 : 0, + ); + } else if (key.name === 'return' || key.name === 'space') { + if (extensions.length > 0) { + onExtensionSelect(selectedIndex); + } + } + }, + { isActive: true }, + ); + + if (extensions.length === 0) { + return ( + + + {t('No extensions installed.')} + + + {t("Use '/extensions install' to install your first extension.")} + + + ); + } + + const getUpdateStateColor = (state: string | undefined): string => { + if (!state) return theme.text.secondary; + + switch (state) { + case ExtensionUpdateState.CHECKING_FOR_UPDATES: + case ExtensionUpdateState.UPDATING: + return theme.text.secondary; + case ExtensionUpdateState.UPDATE_AVAILABLE: + case ExtensionUpdateState.UPDATED_NEEDS_RESTART: + return theme.status.warning; + case ExtensionUpdateState.ERROR: + return theme.status.error; + case ExtensionUpdateState.UP_TO_DATE: + case ExtensionUpdateState.NOT_UPDATABLE: + case ExtensionUpdateState.UPDATED: + return theme.status.success; + default: + return theme.text.secondary; + } + }; + + const getLocalizedUpdateState = (state: string | undefined): string => { + if (!state) return ''; + // Map internal state values to translation keys + const stateMap: Record = { + 'up to date': t('up to date'), + 'update available': t('update available'), + 'checking...': t('checking...'), + 'not updatable': t('not updatable'), + error: t('error'), + }; + return stateMap[state] || state; + }; + + const renderExtensionItem = ( + extension: Extension, + index: number, + isSelected: boolean, + ) => { + const isActive = extension.isActive; + const activeColor = isActive ? theme.status.success : theme.text.secondary; + const activeString = isActive ? t('active') : t('disabled'); + + const updateState = extensionsUpdateState.get(extension.name); + const stateColor = getUpdateStateColor(updateState); + const stateText = getLocalizedUpdateState(updateState); + + return ( + + + + {isSelected ? '●' : ' '} + + + + + {extension.name} + + + + v{extension.version} + + + ({activeString}) + + {stateText && [{stateText}]} + + ); + }; + + return ( + + + {extensions.map((extension, index) => + renderExtensionItem(extension, index, index === selectedIndex), + )} + + + + {t('{{count}} extensions installed', { + count: extensions.length.toString(), + })} + + + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx b/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx new file mode 100644 index 000000000..809776a5a --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { theme } from '../../../semantic-colors.js'; +import { t } from '../../../../i18n/index.js'; + +interface ScopeSelectStepProps { + selectedExtension: Extension | null; + mode: 'disable' | 'enable'; + onScopeSelect: (scope: 'user' | 'workspace') => void; + onNavigateBack: () => void; +} + +export function ScopeSelectStep({ + selectedExtension, + mode, + onScopeSelect, + onNavigateBack, +}: ScopeSelectStepProps) { + const scopeItems = [ + { + key: 'user', + get label() { + return t('User (global)'); + }, + value: 'user' as const, + }, + { + key: 'workspace', + get label() { + return t('Workspace (project-specific)'); + }, + value: 'workspace' as const, + }, + { + key: 'back', + get label() { + return t('Back'); + }, + value: 'back' as const, + }, + ]; + + const handleSelect = (value: 'user' | 'workspace' | 'back') => { + if (value === 'back') { + onNavigateBack(); + return; + } + onScopeSelect(value); + }; + + if (!selectedExtension) { + return ( + + {t('No extension selected')} + + ); + } + + const title = + mode === 'disable' + ? t('Disable "{{name}}" - Select Scope', { name: selectedExtension.name }) + : t('Enable "{{name}}" - Select Scope', { name: selectedExtension.name }); + + return ( + + {title} + + + + + ); +} diff --git a/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx b/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx new file mode 100644 index 000000000..0a48418a3 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; + +interface UninstallConfirmStepProps { + selectedExtension: Extension | null; + onConfirm: (extension: Extension) => Promise; + onNavigateBack: () => void; +} + +const debugLogger = createDebugLogger('EXTENSION_UNINSTALL_STEP'); + +export function UninstallConfirmStep({ + selectedExtension, + onConfirm, + onNavigateBack, +}: UninstallConfirmStepProps) { + useKeypress( + async (key) => { + if (!selectedExtension) return; + + if (key.name === 'y' || key.name === 'return') { + try { + await onConfirm(selectedExtension); + // Navigation will be handled by the parent component after successful uninstall + } catch (error) { + debugLogger.error('Failed to uninstall extension:', error); + } + } else if (key.name === 'n' || key.name === 'escape') { + onNavigateBack(); + } + }, + { isActive: true }, + ); + + if (!selectedExtension) { + return ( + + {t('No extension selected')} + + ); + } + + return ( + + + {t('Are you sure you want to uninstall extension "{{name}}"?', { + name: selectedExtension.name, + })} + + + {t('This action cannot be undone.')} + + + {t('Press Y/Enter to confirm, N/Esc to cancel')} + + + ); +} diff --git a/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap new file mode 100644 index 000000000..a6635ebf0 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap @@ -0,0 +1,38 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = ` +"● View Details + Disable Extension + Uninstall Extension + Back" +`; + +exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = ` +"● View Details + Enable Extension + Uninstall Extension + Back" +`; + +exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = ` +"● View Details + Update Extension + Enable Extension + Uninstall Extension + Back" +`; + +exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = ` +"● View Details + Update Extension + Disable Extension + Uninstall Extension + Back" +`; + +exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = ` +"● View Details + Enable Extension + Uninstall Extension + Back" +`; diff --git a/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap new file mode 100644 index 000000000..045d84986 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap @@ -0,0 +1,36 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ExtensionListStep Snapshots > should render empty state 1`] = ` +"No extensions installed. +Use '/extensions install' to install your first extension." +`; + +exports[`ExtensionListStep Snapshots > should render list with multiple extensions 1`] = ` +"● active-extension v1.0.0 (active) [up to date] + disabled-extension v1.0.0 (disabled) [not updatable] + update-available v1.0.0 (active) [update available] + + +3 extensions installed" +`; + +exports[`ExtensionListStep Snapshots > should render list with single extension 1`] = ` +"● test-extension v1.0.0 (active) + + +1 extensions installed" +`; + +exports[`ExtensionListStep Snapshots > should render with checking status 1`] = ` +"● checking-extension v1.0.0 (active) [checking for updates] + + +1 extensions installed" +`; + +exports[`ExtensionListStep Snapshots > should render with error status 1`] = ` +"● error-extension v1.0.0 (active) [error] + + +1 extensions installed" +`; diff --git a/packages/cli/src/ui/components/extensions/steps/index.ts b/packages/cli/src/ui/components/extensions/steps/index.ts new file mode 100644 index 000000000..45bde6671 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/index.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ExtensionListStep } from './ExtensionListStep.js'; +export { ExtensionDetailStep } from './ExtensionDetailStep.js'; +export { ActionSelectionStep } from './ActionSelectionStep.js'; +export { UninstallConfirmStep } from './UninstallConfirmStep.js'; +export { ScopeSelectStep } from './ScopeSelectStep.js'; diff --git a/packages/cli/src/ui/components/extensions/types.ts b/packages/cli/src/ui/components/extensions/types.ts new file mode 100644 index 000000000..09a8426bd --- /dev/null +++ b/packages/cli/src/ui/components/extensions/types.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Extension, Config } from '@qwen-code/qwen-code-core'; + +/** + * Management steps for the extensions manager dialog. + */ +export const MANAGEMENT_STEPS = { + EXTENSION_LIST: 'extension-list', + ACTION_SELECTION: 'action-selection', + EXTENSION_DETAIL: 'extension-detail', + UNINSTALL_CONFIRMATION: 'uninstall-confirmation', + DISABLE_SCOPE_SELECT: 'disable-scope-select', + ENABLE_SCOPE_SELECT: 'enable-scope-select', + UPDATE_PROGRESS: 'update-progress', +} as const; + +/** + * Props for step navigation. + */ +export interface StepNavigationProps { + onNavigateToStep: (step: string) => void; + onNavigateBack: () => void; +} + +/** + * Props for the extension list step. + */ +export interface ExtensionListStepProps extends StepNavigationProps { + extensions: Extension[]; + extensionsUpdateState: Map; + onExtensionSelect: (extensionIndex: number) => void; +} + +/** + * Props for the extension detail step. + */ +export interface ExtensionDetailStepProps extends StepNavigationProps { + selectedExtension: Extension | null; +} + +/** + * Props for the action selection step. + */ +export interface ActionSelectionStepProps extends StepNavigationProps { + selectedExtension: Extension | null; + hasUpdateAvailable: boolean; + onActionSelect: (action: ExtensionAction) => void; +} + +/** + * Props for the uninstall confirmation step. + */ +export interface UninstallConfirmStepProps extends StepNavigationProps { + selectedExtension: Extension | null; + onConfirm: (extension: Extension) => Promise; +} + +/** + * Props for the scope selection step. + */ +export interface ScopeSelectStepProps extends StepNavigationProps { + selectedExtension: Extension | null; + mode: 'disable' | 'enable'; + onScopeSelect: (scope: 'user' | 'workspace') => void; +} + +/** + * Available actions for an extension. + */ +export type ExtensionAction = + | 'view' + | 'update' + | 'disable' + | 'enable' + | 'uninstall' + | 'back'; + +/** + * Props for the ExtensionsManagerDialog component. + */ +export interface ExtensionsManagerDialogProps { + onClose: () => void; + config: Config | null; +} diff --git a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx new file mode 100644 index 000000000..a79af049b --- /dev/null +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -0,0 +1,554 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { t } from '../../../i18n/index.js'; +import type { + MCPManagementDialogProps, + MCPServerDisplayInfo, + MCPToolDisplayInfo, +} from './types.js'; +import { MCP_MANAGEMENT_STEPS } from './types.js'; +import { ServerListStep } from './steps/ServerListStep.js'; +import { ServerDetailStep } from './steps/ServerDetailStep.js'; +import { ToolListStep } from './steps/ToolListStep.js'; +import { ToolDetailStep } from './steps/ToolDetailStep.js'; +import { DisableScopeSelectStep } from './steps/DisableScopeSelectStep.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import { + getMCPServerStatus, + DiscoveredMCPTool, + type MCPServerConfig, + type AnyDeclarativeTool, + type DiscoveredMCPPrompt, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; +import { loadSettings, SettingScope } from '../../../config/settings.js'; +import { isToolValid, getToolInvalidReasons } from './utils.js'; + +const debugLogger = createDebugLogger('MCP_DIALOG'); + +export const MCPManagementDialog: React.FC = ({ + onClose, +}) => { + const config = useConfig(); + + const [servers, setServers] = useState([]); + const [selectedServerIndex, setSelectedServerIndex] = useState(-1); + const [selectedTool, setSelectedTool] = useState( + null, + ); + const [navigationStack, setNavigationStack] = useState([ + MCP_MANAGEMENT_STEPS.SERVER_LIST, + ]); + const [isLoading, setIsLoading] = useState(true); + + // Load MCP server data - extracted to a separate function for reuse + const fetchServerData = useCallback(async (): Promise< + MCPServerDisplayInfo[] + > => { + if (!config) return []; + + const mcpServers = config.getMcpServers() || {}; + const toolRegistry = config.getToolRegistry(); + const promptRegistry = config.getPromptRegistry(); + + // Get settings to determine the scope of each server + const settings = loadSettings(); + const userSettings = settings.forScope(SettingScope.User).settings; + const workspaceSettings = settings.forScope( + SettingScope.Workspace, + ).settings; + + const serverInfos: MCPServerDisplayInfo[] = []; + + for (const [name, serverConfig] of Object.entries(mcpServers) as Array< + [string, MCPServerConfig] + >) { + const status = getMCPServerStatus(name); + + // Get tools for this server + const allTools: AnyDeclarativeTool[] = toolRegistry?.getAllTools() || []; + const serverTools = allTools.filter( + (t): t is DiscoveredMCPTool => + t instanceof DiscoveredMCPTool && t.serverName === name, + ); + + // Get prompts for this server + const allPrompts: DiscoveredMCPPrompt[] = + promptRegistry?.getAllPrompts() || []; + const serverPrompts = allPrompts.filter( + (p) => 'serverName' in p && p.serverName === name, + ); + + // Determine source type + let source: 'user' | 'project' | 'extension' = 'user'; + if (serverConfig.extensionName) { + source = 'extension'; + } + + // Determine the scope of the configuration + let scope: 'user' | 'workspace' | 'extension' = 'user'; + if (serverConfig.extensionName) { + scope = 'extension'; + } else if (workspaceSettings.mcpServers?.[name]) { + scope = 'workspace'; + } else if (userSettings.mcpServers?.[name]) { + scope = 'user'; + } + + // Use config.isMcpServerDisabled() to check if server is disabled + const isDisabled = config.isMcpServerDisabled(name); + + // Count invalid tools (missing name or description) + const invalidToolCount = serverTools.filter( + (t) => !t.name || !t.description, + ).length; + + serverInfos.push({ + name, + status, + source, + scope, + config: serverConfig, + toolCount: serverTools.length, + invalidToolCount, + promptCount: serverPrompts.length, + isDisabled, + }); + } + + return serverInfos; + }, [config]); + + // Load MCP server data on initial render + useEffect(() => { + const loadServers = async () => { + setIsLoading(true); + try { + const serverInfos = await fetchServerData(); + setServers(serverInfos); + } catch (error) { + debugLogger.error('Error loading MCP servers:', error); + } finally { + setIsLoading(false); + } + }; + + loadServers(); + }, [fetchServerData]); + + // Selected server + const selectedServer = useMemo(() => { + if (selectedServerIndex >= 0 && selectedServerIndex < servers.length) { + return servers[selectedServerIndex]; + } + return null; + }, [servers, selectedServerIndex]); + + // Current step + const getCurrentStep = useCallback( + () => + navigationStack[navigationStack.length - 1] || + MCP_MANAGEMENT_STEPS.SERVER_LIST, + [navigationStack], + ); + + // Navigation handlers + const handleNavigateToStep = useCallback((step: string) => { + setNavigationStack((prev) => [...prev, step]); + }, []); + + const handleNavigateBack = useCallback(() => { + setNavigationStack((prev) => { + if (prev.length <= 1) return prev; + return prev.slice(0, -1); + }); + }, []); + + // Select server + const handleSelectServer = useCallback( + (index: number) => { + setSelectedServerIndex(index); + handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL); + }, + [handleNavigateToStep], + ); + + // Get server tool list + const getServerTools = useCallback((): MCPToolDisplayInfo[] => { + if (!config || !selectedServer) return []; + + const toolRegistry = config.getToolRegistry(); + if (!toolRegistry) return []; + + const allTools: AnyDeclarativeTool[] = toolRegistry.getAllTools(); + const mcpTools: DiscoveredMCPTool[] = []; + for (const tool of allTools) { + if ( + tool instanceof DiscoveredMCPTool && + tool.serverName === selectedServer.name + ) { + mcpTools.push(tool); + } + } + return mcpTools.map((tool) => { + // Check if tool is valid (has both name and description required by LLM) + const isValid = isToolValid(tool.name, tool.description); + + let invalidReason: string | undefined; + if (!isValid) { + const reasons = getToolInvalidReasons(tool.name, tool.description); + invalidReason = reasons.map((r) => t(r)).join(', '); + } + + return { + name: tool.name || t('(unnamed)'), + description: tool.description, + serverName: tool.serverName, + schema: tool.parameterSchema as object | undefined, + annotations: tool.annotations, + isValid, + invalidReason, + }; + }); + }, [config, selectedServer]); + + // View tool list + const handleViewTools = useCallback(() => { + handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST); + }, [handleNavigateToStep]); + + // Select tool + const handleSelectTool = useCallback( + (tool: MCPToolDisplayInfo) => { + setSelectedTool(tool); + handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL); + }, + [handleNavigateToStep], + ); + + // Reload server data - uses the extracted fetchServerData function + const reloadServers = useCallback(async () => { + setIsLoading(true); + try { + const serverInfos = await fetchServerData(); + setServers(serverInfos); + } catch (error) { + debugLogger.error('Error reloading MCP servers:', error); + } finally { + setIsLoading(false); + } + }, [fetchServerData]); + + // Reconnect server + const handleReconnect = useCallback(async () => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(selectedServer.name); + } + // Reload server data to update status + await reloadServers(); + } catch (error) { + debugLogger.error( + `Error reconnecting to server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } + }, [config, selectedServer, reloadServers]); + + // Enable server + const handleEnableServer = useCallback(async () => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + + const server = selectedServer; + const settings = loadSettings(); + + // Remove from user and workspace exclusion lists + for (const scope of [SettingScope.User, SettingScope.Workspace]) { + const scopeSettings = settings.forScope(scope).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + + if (currentExcluded.includes(server.name)) { + const newExcluded = currentExcluded.filter( + (name: string) => name !== server.name, + ); + settings.setValue(scope, 'mcp.excluded', newExcluded); + } + } + + // Update runtime config exclusion list + const currentExcluded = config.getExcludedMcpServers() || []; + const newExcluded = currentExcluded.filter( + (name: string) => name !== server.name, + ); + config.setExcludedMcpServers(newExcluded); + + // Rediscover tools for this server + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(server.name); + } + + // Reload server data + await reloadServers(); + } catch (error) { + debugLogger.error( + `Error enabling server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } + }, [config, selectedServer, reloadServers]); + + // Handle disable/enable action + const handleDisable = useCallback(() => { + if (!selectedServer) return; + + // If server is already disabled, enable it directly + if (selectedServer.isDisabled) { + void handleEnableServer(); + } else { + // Otherwise navigate to disable scope selection + handleNavigateToStep(MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT); + } + }, [selectedServer, handleEnableServer, handleNavigateToStep]); + + // Execute disable after selecting scope + const handleSelectDisableScope = useCallback( + async (scope: 'user' | 'workspace') => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + + const server = selectedServer; + const settings = loadSettings(); + + // Get current exclusion list + const scopeSettings = settings.forScope( + scope === 'user' ? SettingScope.User : SettingScope.Workspace, + ).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + + // If server is not in exclusion list, add it + if (!currentExcluded.includes(server.name)) { + const newExcluded = [...currentExcluded, server.name]; + settings.setValue( + scope === 'user' ? SettingScope.User : SettingScope.Workspace, + 'mcp.excluded', + newExcluded, + ); + } + + // Use new disableMcpServer method to disable server + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.disableMcpServer(server.name); + } + + // Reload server list + await reloadServers(); + + // Return to server detail page + handleNavigateBack(); + } catch (error) { + debugLogger.error( + `Error disabling server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } + }, + [config, selectedServer, handleNavigateBack, reloadServers], + ); + + // Render step header + const renderStepHeader = useCallback(() => { + const currentStep = getCurrentStep(); + let headerText = ''; + + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_LIST: + headerText = t('Manage MCP servers'); + break; + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + headerText = selectedServer?.name || t('Server Detail'); + break; + case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + headerText = t('Disable Server'); + break; + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + headerText = t('Tools'); + break; + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + headerText = selectedTool?.name || t('Tool Detail'); + break; + default: + headerText = t('MCP Management'); + } + + return ( + + + {headerText} + + + ); + }, [getCurrentStep, selectedServer, selectedTool]); + + // Render step content + const renderStepContent = useCallback(() => { + if (isLoading) { + return {t('Loading...')}; + } + + const currentStep = getCurrentStep(); + + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_LIST: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + return ( + + ); + + default: + return ( + + {t('Unknown step')} + + ); + } + }, [ + isLoading, + getCurrentStep, + servers, + selectedServer, + selectedTool, + handleSelectServer, + handleViewTools, + handleReconnect, + handleDisable, + handleNavigateBack, + handleSelectTool, + handleSelectDisableScope, + getServerTools, + ]); + + // Render step footer + const renderStepFooter = useCallback(() => { + const currentStep = getCurrentStep(); + let footerText = ''; + + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_LIST: + if (servers.length === 0) { + footerText = t('Esc to close'); + } else { + footerText = t('↑↓ to navigate · Enter to select · Esc to close'); + } + break; + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + footerText = t('↑↓ to navigate · Enter to select · Esc to back'); + break; + case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + footerText = t('↑↓ to navigate · Enter to confirm · Esc to back'); + break; + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + footerText = t('↑↓ to navigate · Enter to select · Esc to back'); + break; + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + footerText = t('Esc to back'); + break; + default: + footerText = t('Esc to close'); + } + + return ( + + {footerText} + + ); + }, [getCurrentStep, servers.length]); + + // ESC key handler - only close dialog, child components handle back navigation to avoid duplicate triggers + useKeypress( + (key) => { + if ( + key.name === 'escape' && + getCurrentStep() === MCP_MANAGEMENT_STEPS.SERVER_LIST + ) { + onClose(); + } + }, + { isActive: true }, + ); + + return ( + + + {renderStepHeader()} + {renderStepContent()} + {renderStepFooter()} + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/constants.ts b/packages/cli/src/ui/components/mcp/constants.ts new file mode 100644 index 000000000..cfdc2691f --- /dev/null +++ b/packages/cli/src/ui/components/mcp/constants.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * MCP管理相关常量 + */ + +/** + * 最大显示工具数量 + */ +export const MAX_DISPLAY_TOOLS = 10; + +/** + * 最大显示prompt数量 + */ +export const MAX_DISPLAY_PROMPTS = 10; + +/** + * 日志列表可视区域最大显示数量 + */ +export const VISIBLE_LOGS_COUNT = 15; + +/** + * 工具列表可视区域最大显示数量 + */ +export const VISIBLE_TOOLS_COUNT = 10; + +/** + * 分组显示名称映射 + */ +export const SOURCE_DISPLAY_NAMES: Record = { + user: 'User MCPs', + project: 'Project MCPs', + extension: 'Extension MCPs', +}; + +/** + * 状态显示文本 + */ +export const STATUS_TEXT: Record = { + connected: 'connected', + connecting: 'connecting', + disconnected: 'failed', +}; diff --git a/packages/cli/src/ui/components/mcp/index.ts b/packages/cli/src/ui/components/mcp/index.ts new file mode 100644 index 000000000..01ebfee8f --- /dev/null +++ b/packages/cli/src/ui/components/mcp/index.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Main Dialog +export { MCPManagementDialog } from './MCPManagementDialog.js'; + +// Steps +export { ServerListStep } from './steps/ServerListStep.js'; +export { ServerDetailStep } from './steps/ServerDetailStep.js'; +export { ToolListStep } from './steps/ToolListStep.js'; +export { ToolDetailStep } from './steps/ToolDetailStep.js'; + +// Types +export type { + MCPManagementDialogProps, + MCPServerDisplayInfo, + MCPToolDisplayInfo, + MCPPromptDisplayInfo, + ServerListStepProps, + ServerDetailStepProps, + ToolListStepProps, + ToolDetailStepProps, + MCPManagementStep, +} from './types.js'; + +// Constants +export { MCP_MANAGEMENT_STEPS } from './types.js'; diff --git a/packages/cli/src/ui/components/mcp/steps/DisableScopeSelectStep.tsx b/packages/cli/src/ui/components/mcp/steps/DisableScopeSelectStep.tsx new file mode 100644 index 000000000..3c97ccfd1 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/DisableScopeSelectStep.tsx @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { t } from '../../../../i18n/index.js'; +import type { DisableScopeSelectStepProps } from '../types.js'; + +export const DisableScopeSelectStep: React.FC = ({ + server, + onSelectScope, + onBack, +}) => { + const [selectedScope, setSelectedScope] = useState<'user' | 'workspace'>( + 'user', + ); + + const scopes = [ + { + key: 'user', + get label() { + return t('User Settings (global)'); + }, + value: 'user' as const, + }, + { + key: 'workspace', + get label() { + return t('Workspace Settings (project-specific)'); + }, + value: 'workspace' as const, + }, + ]; + + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } else if (key.name === 'return') { + onSelectScope(selectedScope); + } + }, + { isActive: true }, + ); + + if (!server) { + return ( + + {t('No server selected')} + + ); + } + + return ( + + + + {t('Disable server:')} {server.name} + + + + {t('Select where to add the server to the exclude list:')} + + + + + + + items={scopes} + onHighlight={(value: 'user' | 'workspace') => setSelectedScope(value)} + onSelect={(value: 'user' | 'workspace') => onSelectScope(value)} + /> + + + + + {t('Press Enter to confirm, Esc to cancel')} + + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx new file mode 100644 index 000000000..07b8da439 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { t } from '../../../../i18n/index.js'; +import type { ServerDetailStepProps } from '../types.js'; +import { + getStatusColor, + getStatusIcon, + formatServerCommand, +} from '../utils.js'; + +// 标签列宽度 +const LABEL_WIDTH = 15; + +type ServerAction = 'view-tools' | 'reconnect' | 'toggle-disable'; + +export const ServerDetailStep: React.FC = ({ + server, + onViewTools, + onReconnect, + onDisable, + onBack, +}) => { + const [selectedAction, setSelectedAction] = + useState('view-tools'); + + const statusColor = server ? getStatusColor(server.status) : 'gray'; + + const actions = [ + { + key: 'view-tools', + get label() { + return t('View tools'); + }, + value: 'view-tools' as const, + }, + { + key: 'reconnect', + get label() { + return t('Reconnect'); + }, + value: 'reconnect' as const, + }, + { + key: 'toggle-disable', + get label() { + return server?.isDisabled ? t('Enable') : t('Disable'); + }, + value: 'toggle-disable' as const, + }, + ]; + + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } else if (key.name === 'return') { + switch (selectedAction) { + case 'view-tools': + onViewTools(); + break; + case 'reconnect': + onReconnect?.(); + break; + case 'toggle-disable': + onDisable?.(); + break; + default: + break; + } + } + }, + { isActive: true }, + ); + + if (!server) { + return ( + + {t('No server selected')} + + ); + } + + return ( + + {/* 服务器详情 */} + + + + {t('Status:')} + + + + {getStatusIcon(server.status)} {t(server.status)} + {server.isDisabled && ( + {t('(disabled)')} + )} + + + + + + + {t('Source:')} + + + + {server.scope === 'user' + ? t('User Settings') + : server.scope === 'workspace' + ? t('Workspace Settings') + : t('Extension')} + + + + + + + {t('Command:')} + + + {formatServerCommand(server)} + + + + {server.config.cwd && ( + + + {t('Working Directory:')} + + + {server.config.cwd} + + + )} + + + + {t('Capabilities:')} + + + + {server.toolCount > 0 ? t('tools') : ''} + {server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''} + {server.promptCount > 0 ? t('prompts') : ''} + + + + + + + {t('Tools:')} + + + + {server.toolCount}{' '} + {server.toolCount === 1 ? t('tool') : t('tools')} + {!!server.invalidToolCount && server.invalidToolCount > 0 && ( + + {' '} + ({server.invalidToolCount}{' '} + {server.invalidToolCount === 1 ? t('invalid') : t('invalid')}) + + )} + + + + + {server.errorMessage && ( + + + {t('Error:')} + + + + {server.errorMessage} + + + + )} + + + {/* 操作列表 */} + + + items={actions} + onHighlight={(value: ServerAction) => setSelectedAction(value)} + onSelect={(value: ServerAction) => { + switch (value) { + case 'view-tools': + onViewTools(); + break; + case 'reconnect': + onReconnect?.(); + break; + case 'toggle-disable': + onDisable?.(); + break; + default: + break; + } + }} + /> + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx new file mode 100644 index 000000000..35cff6708 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; +import type { ServerListStepProps, MCPServerDisplayInfo } from '../types.js'; +import { + groupServersBySource, + getStatusIcon, + getStatusColor, +} from '../utils.js'; + +export const ServerListStep: React.FC = ({ + servers, + onSelect, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const groupedServers = useMemo( + () => groupServersBySource(servers), + [servers], + ); + + // 动态计算服务器名称列的最大宽度(基于实际内容) + const serverNameWidth = useMemo(() => { + if (servers.length === 0) return 20; + const maxLength = Math.max(...servers.map((s) => s.name.length)); + // 最小 20,最大 35,留一些余量 + return Math.min(Math.max(maxLength + 2, 20), 35); + }, [servers]); + + // 计算扁平化的服务器列表用于导航 + const flatServers = useMemo(() => { + const result: MCPServerDisplayInfo[] = []; + for (const group of groupedServers) { + result.push(...group.servers); + } + return result; + }, [groupedServers]); + + // 键盘导航 + useKeypress( + (key) => { + if (key.name === 'up') { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.name === 'down') { + setSelectedIndex((prev) => Math.min(flatServers.length - 1, prev + 1)); + } else if (key.name === 'return') { + onSelect(selectedIndex); + } + }, + { isActive: true }, + ); + + if (servers.length === 0) { + return ( + + + {t('No MCP servers configured.')} + + + {t('Add MCP servers to your settings to get started.')} + + + ); + } + + // 计算当前选中项在分组中的位置 + const getSelectionPosition = (globalIndex: number) => { + let currentIndex = 0; + for (const group of groupedServers) { + if (globalIndex < currentIndex + group.servers.length) { + return { + groupIndex: groupedServers.indexOf(group), + itemIndex: globalIndex - currentIndex, + }; + } + currentIndex += group.servers.length; + } + return { groupIndex: 0, itemIndex: 0 }; + }; + + const currentPosition = getSelectionPosition(selectedIndex); + + return ( + + {/* 服务器统计 */} + + + {servers.length} {servers.length === 1 ? t('server') : t('servers')} + + + + {/* 分组服务器列表 */} + {groupedServers.map((group, groupIndex) => ( + + + {group.displayName} + {group.servers[0]?.configPath && ( + + {' '} + ({group.servers[0].configPath}) + + )} + + + {group.servers.map((server, itemIndex) => { + const isSelected = + groupIndex === currentPosition.groupIndex && + itemIndex === currentPosition.itemIndex; + const statusColor = getStatusColor(server.status); + + return ( + + + + {isSelected ? '❯' : ' '} + + + {/* 服务器名称 - 固定宽度 */} + + + {server.name} + + + · + {/* 状态图标和文本 */} + + {getStatusIcon(server.status)} {t(server.status)} + + {/* 显示 Scope 和禁用状态 */} + [{server.scope}] + {server.isDisabled && ( + {t('(disabled)')} + )} + {/* 显示无效工具警告 */} + {!!server.invalidToolCount && server.invalidToolCount > 0 && ( + + {' '} + {t('{{count}} invalid tools', { + count: String(server.invalidToolCount), + })} + + )} + + ); + })} + + + ))} + + {/* 提示信息 */} + {servers.some((s) => s.status === 'disconnected') && ( + + + ※ {t('Run qwen --debug to see error logs')} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx new file mode 100644 index 000000000..0bf32b860 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; +import type { ToolDetailStepProps } from '../types.js'; + +/** + * 截断过长的字符串 + */ +const truncate = (str: string, maxLen: number = 50): string => { + if (str.length <= maxLen) return str; + return str.substring(0, maxLen - 3) + '...'; +}; + +/** + * 渲染单个参数 + */ +const renderParameter = ( + name: string, + param: Record, + isRequired: boolean, +): React.ReactNode => { + const type = (param['type'] as string) || 'any'; + const description = (param['description'] as string) || ''; + const defaultValue = param['default']; + const enumValues = param['enum'] as string[] | undefined; + + return ( + + + • {name} + {isRequired && ( + ({t('required')}) + )} + + + {t('Type')}: + {type} + + {description && ( + + + {truncate(description, 80)} + + + )} + {enumValues && enumValues.length > 0 && ( + + + {t('Enum')}: {enumValues.join(', ')} + + + )} + {defaultValue !== undefined && ( + + + {t('Default')}:{' '} + {typeof defaultValue === 'string' + ? `"${truncate(defaultValue, 30)}"` + : String(defaultValue)} + + + )} + + ); +}; + +/** + * 渲染参数列表 + */ +const ParametersList: React.FC<{ + properties: Record; + required: string[]; +}> = ({ properties, required }) => { + const requiredSet = new Set(required); + + return ( + + {t('Parameters')}: + + {Object.entries(properties).map(([name, param]) => + renderParameter( + name, + param as Record, + requiredSet.has(name), + ), + )} + + + ); +}; + +/** + * 提取并展示schema的关键信息,使用类似示例的格式 + */ +const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => { + const obj = schema as Record; + const properties = obj['properties'] as Record | undefined; + const required = (obj['required'] as string[]) || []; + + return ( + + {/* 参数列表 */} + {properties && Object.keys(properties).length > 0 && ( + + )} + + ); +}; + +export const ToolDetailStep: React.FC = ({ + tool, + onBack, +}) => { + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } + }, + { isActive: true }, + ); + + if (!tool) { + return ( + + {t('No tool selected')} + + ); + } + + return ( + + {/* 无效工具警告 */} + {!tool.isValid && ( + + + {t('Warning: This tool cannot be called by the LLM')} + + + {t('Reason')}: {tool.invalidReason || t('unknown')} + + + {t( + 'Tools must have both name and description to be used by the LLM.', + )} + + + )} + + {/* 工具描述 */} + {tool.description && ( + + {tool.description} + + )} + + {/* 工具注解 */} + {tool.annotations && ( + + {t('Annotations')}: + + {tool.annotations.title && ( + + • {t('Title')}: {tool.annotations.title} + + )} + {tool.annotations.readOnlyHint !== undefined && ( + + • {t('Read Only')}:{' '} + {tool.annotations.readOnlyHint ? t('Yes') : t('No')} + + )} + {tool.annotations.destructiveHint !== undefined && ( + + • {t('Destructive')}:{' '} + {tool.annotations.destructiveHint ? t('Yes') : t('No')} + + )} + {tool.annotations.idempotentHint !== undefined && ( + + • {t('Idempotent')}:{' '} + {tool.annotations.idempotentHint ? t('Yes') : t('No')} + + )} + {tool.annotations.openWorldHint !== undefined && ( + + • {t('Open World')}:{' '} + {tool.annotations.openWorldHint ? t('Yes') : t('No')} + + )} + + + )} + + {/* Schema */} + {tool.schema && ( + + + + )} + + {/* 所属服务器 */} + + + {t('Server')}: {tool.serverName} + + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx new file mode 100644 index 000000000..de9f4fa6c --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; +import type { ToolListStepProps, MCPToolDisplayInfo } from '../types.js'; +import { VISIBLE_TOOLS_COUNT } from '../constants.js'; + +export const ToolListStep: React.FC = ({ + tools, + serverName, + onSelect, + onBack, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + // 动态计算工具名称列的最大宽度(基于实际内容) + const toolNameWidth = useMemo(() => { + if (tools.length === 0) return 30; + const maxLength = Math.max(...tools.map((t) => t.name.length)); + // 最小 30,最大 50,留一些余量 + return Math.min(Math.max(maxLength + 2, 30), 50); + }, [tools]); + + // 计算可视区域的起始索引(滚动窗口) + const scrollOffset = useMemo(() => { + if (tools.length <= VISIBLE_TOOLS_COUNT) { + return 0; + } + // 确保选中项在可视区域内 + if (selectedIndex < VISIBLE_TOOLS_COUNT - 1) { + return 0; + } + return Math.min( + selectedIndex - VISIBLE_TOOLS_COUNT + 1, + tools.length - VISIBLE_TOOLS_COUNT, + ); + }, [selectedIndex, tools.length]); + + // 当前可视的工具列表 + const displayTools = useMemo( + () => tools.slice(scrollOffset, scrollOffset + VISIBLE_TOOLS_COUNT), + [tools, scrollOffset], + ); + + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } else if (key.name === 'up') { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.name === 'down') { + setSelectedIndex((prev) => Math.min(tools.length - 1, prev + 1)); + } else if (key.name === 'return') { + if (tools[selectedIndex]) { + onSelect(tools[selectedIndex]); + } + } + }, + { isActive: true }, + ); + + if (tools.length === 0) { + return ( + + + {t('No tools available for this server.')} + + + ); + } + + const getToolAnnotations = (tool: MCPToolDisplayInfo): string => { + const hints: string[] = []; + if (tool.annotations?.destructiveHint) hints.push(t('destructive')); + if (tool.annotations?.readOnlyHint) hints.push(t('read-only')); + if (tool.annotations?.openWorldHint) hints.push(t('open-world')); + if (tool.annotations?.idempotentHint) hints.push(t('idempotent')); + return hints.join(', '); + }; + + return ( + + {/* 标题 */} + + {t('Tools for {{name}}', { name: serverName })} + + {' '} + ({tools.length} {tools.length === 1 ? t('tool') : t('tools')}) + + + + {/* 工具列表 */} + + {displayTools.map((tool, index) => { + const actualIndex = scrollOffset + index; + const isSelected = actualIndex === selectedIndex; + const annotations = getToolAnnotations(tool); + + return ( + + {/* 选择器和序号 */} + + + {isSelected ? '❯' : ' '} + + {actualIndex + 1}. + + {/* 工具名称 - 固定宽度 */} + + + {tool.name} + + + {/* 显示无效工具警告 */} + {!tool.isValid && ( + + {t('invalid: {{reason}}', { + reason: tool.invalidReason || t('unknown'), + })} + + )} + {annotations && tool.isValid && ( + {annotations} + )} + + ); + })} + + + {/* 滚动提示 */} + {tools.length > VISIBLE_TOOLS_COUNT && ( + + + {scrollOffset > 0 ? '↑ ' : ' '} + {t('{{current}}/{{total}}', { + current: (selectedIndex + 1).toString(), + total: tools.length.toString(), + })} + {scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts new file mode 100644 index 000000000..1133592bb --- /dev/null +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + MCPServerConfig, + MCPServerStatus, +} from '@qwen-code/qwen-code-core'; + +/** + * MCP管理步骤定义 + */ +export const MCP_MANAGEMENT_STEPS = { + SERVER_LIST: 'server-list', + SERVER_DETAIL: 'server-detail', + DISABLE_SCOPE_SELECT: 'disable-scope-select', + TOOL_LIST: 'tool-list', + TOOL_DETAIL: 'tool-detail', +} as const; + +export type MCPManagementStep = + (typeof MCP_MANAGEMENT_STEPS)[keyof typeof MCP_MANAGEMENT_STEPS]; + +/** + * MCP服务器显示信息 + */ +export interface MCPServerDisplayInfo { + /** 服务器名称 */ + name: string; + /** 连接状态 */ + status: MCPServerStatus; + /** 来源类型 */ + source: 'user' | 'project' | 'extension'; + /** 配置所在的 scope */ + scope: 'user' | 'workspace' | 'extension'; + /** 配置文件路径 */ + configPath?: string; + /** 服务器配置 */ + config: MCPServerConfig; + /** 工具数量 */ + toolCount: number; + /** 无效工具数量(缺少name或description) */ + invalidToolCount?: number; + /** Prompt数量 */ + promptCount: number; + /** 错误信息 */ + errorMessage?: string; + /** 是否被禁用(在排除列表中) */ + isDisabled: boolean; +} + +/** + * MCP工具显示信息 + */ +export interface MCPToolDisplayInfo { + /** 工具名称 */ + name: string; + /** 工具描述 */ + description?: string; + /** 所属服务器 */ + serverName: string; + /** 工具schema */ + schema?: object; + /** 工具注解 */ + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; + /** 工具是否有效(有name和description才能被LLM调用) */ + isValid: boolean; + /** 无效原因(当isValid为false时) */ + invalidReason?: string; +} + +/** + * MCP Prompt显示信息 + */ +export interface MCPPromptDisplayInfo { + /** Prompt名称 */ + name: string; + /** Prompt描述 */ + description?: string; + /** 所属服务器 */ + serverName: string; + /** 参数定义 */ + arguments?: Array<{ + name: string; + description?: string; + required?: boolean; + }>; +} + +/** + * 分组后的服务器列表 + */ +export interface GroupedServers { + /** 来源标识 */ + source: string; + /** 来源显示名称 */ + displayName: string; + /** 配置文件路径 */ + configPath?: string; + /** 服务器列表 */ + servers: MCPServerDisplayInfo[]; +} + +/** + * ServerListStep组件属性 + */ +export interface ServerListStepProps { + /** 服务器列表 */ + servers: MCPServerDisplayInfo[]; + /** 选择回调 */ + onSelect: (index: number) => void; +} + +/** + * ServerDetailStep组件属性 + */ +export interface ServerDetailStepProps { + /** 选中的服务器 */ + server: MCPServerDisplayInfo | null; + /** 查看工具列表回调 */ + onViewTools: () => void; + /** 重新连接回调 */ + onReconnect?: () => void; + /** 禁用服务器回调 */ + onDisable?: () => void; + /** 返回回调 */ + onBack: () => void; +} + +/** + * DisableScopeSelectStep组件属性 + */ +export interface DisableScopeSelectStepProps { + /** 选中的服务器 */ + server: MCPServerDisplayInfo | null; + /** 选择 scope 回调 */ + onSelectScope: (scope: 'user' | 'workspace') => void; + /** 返回回调 */ + onBack: () => void; +} + +/** + * ToolListStep组件属性 + */ +export interface ToolListStepProps { + /** 工具列表 */ + tools: MCPToolDisplayInfo[]; + /** 服务器名称 */ + serverName: string; + /** 选择回调 */ + onSelect: (tool: MCPToolDisplayInfo) => void; + /** 返回回调 */ + onBack: () => void; +} + +/** + * ToolDetailStep组件属性 + */ +export interface ToolDetailStepProps { + /** 工具信息 */ + tool: MCPToolDisplayInfo | null; + /** 返回回调 */ + onBack: () => void; +} + +/** + * MCP管理对话框属性 + */ +export interface MCPManagementDialogProps { + /** 关闭回调 */ + onClose: () => void; +} diff --git a/packages/cli/src/ui/components/mcp/utils.test.ts b/packages/cli/src/ui/components/mcp/utils.test.ts new file mode 100644 index 000000000..3b058ba55 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/utils.test.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + groupServersBySource, + getStatusColor, + getStatusIcon, + truncateText, + formatServerCommand, + isToolValid, + getToolInvalidReasons, +} from './utils.js'; +import type { MCPServerDisplayInfo } from './types.js'; +import { MCPServerStatus } from '@qwen-code/qwen-code-core'; + +describe('MCP utils', () => { + describe('groupServersBySource', () => { + it('should group servers by source', () => { + const servers: MCPServerDisplayInfo[] = [ + { + name: 'server1', + status: MCPServerStatus.CONNECTED, + source: 'user', + scope: 'user', + config: { command: 'cmd1' }, + toolCount: 1, + promptCount: 0, + isDisabled: false, + }, + { + name: 'server2', + status: MCPServerStatus.CONNECTED, + source: 'extension', + scope: 'extension', + config: { command: 'cmd2' }, + toolCount: 2, + promptCount: 0, + isDisabled: false, + }, + ]; + + const result = groupServersBySource(servers); + + expect(result).toHaveLength(2); + expect(result[0].source).toBe('user'); + expect(result[0].servers).toHaveLength(1); + expect(result[1].source).toBe('extension'); + }); + }); + + describe('getStatusColor', () => { + it('should return correct colors for each status', () => { + expect(getStatusColor(MCPServerStatus.CONNECTED)).toBe('green'); + expect(getStatusColor(MCPServerStatus.CONNECTING)).toBe('yellow'); + expect(getStatusColor(MCPServerStatus.DISCONNECTED)).toBe('red'); + expect(getStatusColor('unknown' as MCPServerStatus)).toBe('gray'); + }); + }); + + describe('getStatusIcon', () => { + it('should return correct icons for each status', () => { + expect(getStatusIcon(MCPServerStatus.CONNECTED)).toBe('✓'); + expect(getStatusIcon(MCPServerStatus.CONNECTING)).toBe('…'); + expect(getStatusIcon(MCPServerStatus.DISCONNECTED)).toBe('✗'); + expect(getStatusIcon('unknown' as MCPServerStatus)).toBe('?'); + }); + }); + + describe('truncateText', () => { + it('should truncate text longer than maxLength', () => { + expect(truncateText('hello world', 8)).toBe('hello...'); + }); + + it('should not truncate text shorter than maxLength', () => { + expect(truncateText('hello', 10)).toBe('hello'); + }); + }); + + describe('formatServerCommand', () => { + it('should format http URL', () => { + const server = { + config: { httpUrl: 'http://localhost:3000' }, + } as MCPServerDisplayInfo; + expect(formatServerCommand(server)).toBe('http://localhost:3000 (http)'); + }); + + it('should format stdio command', () => { + const server = { + config: { command: 'node', args: ['server.js'] }, + } as MCPServerDisplayInfo; + expect(formatServerCommand(server)).toBe('node server.js (stdio)'); + }); + + it('should return Unknown for empty config', () => { + const server = { config: {} } as MCPServerDisplayInfo; + expect(formatServerCommand(server)).toBe('Unknown'); + }); + }); + + describe('isToolValid', () => { + it('should return true for valid tool with name and description', () => { + expect(isToolValid('toolName', 'A description')).toBe(true); + }); + + it('should return false for tool without name', () => { + expect(isToolValid(undefined, 'A description')).toBe(false); + expect(isToolValid('', 'A description')).toBe(false); + }); + + it('should return false for tool without description', () => { + expect(isToolValid('toolName', undefined)).toBe(false); + expect(isToolValid('toolName', '')).toBe(false); + }); + + it('should return false for tool without both name and description', () => { + expect(isToolValid(undefined, undefined)).toBe(false); + expect(isToolValid('', '')).toBe(false); + }); + }); + + describe('getToolInvalidReasons', () => { + it('should return empty array for valid tool', () => { + expect(getToolInvalidReasons('toolName', 'A description')).toEqual([]); + }); + + it('should return missing name reason', () => { + expect(getToolInvalidReasons(undefined, 'A description')).toEqual([ + 'missing name', + ]); + expect(getToolInvalidReasons('', 'A description')).toEqual([ + 'missing name', + ]); + }); + + it('should return missing description reason', () => { + expect(getToolInvalidReasons('toolName', undefined)).toEqual([ + 'missing description', + ]); + expect(getToolInvalidReasons('toolName', '')).toEqual([ + 'missing description', + ]); + }); + + it('should return both reasons when both are missing', () => { + expect(getToolInvalidReasons(undefined, undefined)).toEqual([ + 'missing name', + 'missing description', + ]); + expect(getToolInvalidReasons('', '')).toEqual([ + 'missing name', + 'missing description', + ]); + }); + }); +}); diff --git a/packages/cli/src/ui/components/mcp/utils.ts b/packages/cli/src/ui/components/mcp/utils.ts new file mode 100644 index 000000000..4220fe7eb --- /dev/null +++ b/packages/cli/src/ui/components/mcp/utils.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { MCPServerDisplayInfo, GroupedServers } from './types.js'; +import { SOURCE_DISPLAY_NAMES } from './constants.js'; + +/** + * 按来源分组服务器 + */ +export function groupServersBySource( + servers: MCPServerDisplayInfo[], +): GroupedServers[] { + const groups = new Map(); + + for (const server of servers) { + const existing = groups.get(server.source); + if (existing) { + existing.push(server); + } else { + groups.set(server.source, [server]); + } + } + + // 按优先级排序: user > project > extension + const sourceOrder = ['user', 'project', 'extension']; + const result: GroupedServers[] = []; + + for (const source of sourceOrder) { + const servers = groups.get(source); + if (servers && servers.length > 0) { + result.push({ + source, + displayName: SOURCE_DISPLAY_NAMES[source] || source, + servers, + }); + } + } + + return result; +} + +/** + * 获取状态颜色 + */ +export function getStatusColor( + status: string, +): 'green' | 'yellow' | 'red' | 'gray' { + switch (status) { + case 'connected': + return 'green'; + case 'connecting': + return 'yellow'; + case 'disconnected': + return 'red'; + default: + return 'gray'; + } +} + +/** + * 获取状态图标 + */ +export function getStatusIcon(status: string): string { + switch (status) { + case 'connected': + return '✓'; + case 'connecting': + return '…'; + case 'disconnected': + return '✗'; + default: + return '?'; + } +} + +/** + * 截断文本 + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + '...'; +} + +/** + * 格式化服务器命令显示 + */ +export function formatServerCommand(server: MCPServerDisplayInfo): string { + const config = server.config; + if (config.httpUrl) { + return `${config.httpUrl} (http)`; + } + if (config.url) { + return `${config.url} (sse)`; + } + if (config.command) { + const args = config.args?.join(' ') || ''; + return `${config.command} ${args} (stdio)`.trim(); + } + return 'Unknown'; +} + +/** + * Check if a tool is valid (has both name and description required by LLM) + * @param name - Tool name + * @param description - Tool description + * @returns boolean indicating if the tool is valid + */ +export function isToolValid(name?: string, description?: string): boolean { + return !!name && !!description; +} + +/** + * Get the reason why a tool is invalid + * @param name - Tool name + * @param description - Tool description + * @returns Array of missing fields + */ +export function getToolInvalidReasons( + name?: string, + description?: string, +): string[] { + const reasons: string[] = []; + if (!name) reasons.push('missing name'); + if (!description) reasons.push('missing description'); + return reasons; +} diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx new file mode 100644 index 000000000..526bc9cfe --- /dev/null +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -0,0 +1,261 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import stringWidth from 'string-width'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { theme } from '../../semantic-colors.js'; +import { + SCREEN_READER_MODEL_PREFIX, + SCREEN_READER_USER_PREFIX, +} from '../../textConstants.js'; + +interface UserMessageProps { + text: string; +} + +interface UserShellMessageProps { + text: string; +} + +interface AssistantMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface AssistantMessageContentProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface ThinkMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface ThinkMessageContentProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface PrefixedTextMessageProps { + text: string; + prefix: string; + prefixColor: string; + textColor: string; + ariaLabel?: string; + marginTop?: number; + alignSelf?: 'auto' | 'flex-start' | 'center' | 'flex-end'; +} + +interface PrefixedMarkdownMessageProps { + text: string; + prefix: string; + prefixColor: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; + ariaLabel?: string; + textColor?: string; +} + +interface ContinuationMarkdownMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; + basePrefix: string; + textColor?: string; +} + +function getPrefixWidth(prefix: string): number { + // Reserve one extra column so text never touches the prefix glyph. + return stringWidth(prefix) + 1; +} + +const PrefixedTextMessage: React.FC = ({ + text, + prefix, + prefixColor, + textColor, + ariaLabel, + marginTop = 0, + alignSelf, +}) => { + const prefixWidth = getPrefixWidth(prefix); + + return ( + + + + {prefix} + + + + + {text} + + + + ); +}; + +const PrefixedMarkdownMessage: React.FC = ({ + text, + prefix, + prefixColor, + isPending, + availableTerminalHeight, + contentWidth, + ariaLabel, + textColor, +}) => { + const prefixWidth = getPrefixWidth(prefix); + + return ( + + + + {prefix} + + + + + + + ); +}; + +const ContinuationMarkdownMessage: React.FC< + ContinuationMarkdownMessageProps +> = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, + basePrefix, + textColor, +}) => { + const prefixWidth = getPrefixWidth(basePrefix); + + return ( + + + + ); +}; + +export const UserMessage: React.FC = ({ text }) => ( + +); + +export const UserShellMessage: React.FC = ({ text }) => { + const commandToDisplay = text.startsWith('!') ? text.substring(1) : text; + + return ( + + ); +}; + +export const AssistantMessage: React.FC = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, +}) => ( + +); + +export const AssistantMessageContent: React.FC< + AssistantMessageContentProps +> = ({ text, isPending, availableTerminalHeight, contentWidth }) => ( + +); + +export const ThinkMessage: React.FC = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, +}) => ( + +); + +export const ThinkMessageContent: React.FC = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, +}) => ( + +); diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.tsx deleted file mode 100644 index 14cb8a91f..000000000 --- a/packages/cli/src/ui/components/messages/ErrorMessage.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface ErrorMessageProps { - text: string; - /** Optional inline hint displayed after the error text in secondary/dimmed color */ - hint?: string; -} - -/** - * Renders an error message with a "✕" prefix. - * When a hint is provided (e.g., retry countdown), it is displayed inline - * in parentheses with a dimmed secondary color, similar to the ESC hint - * style used in LoadingIndicator. - */ -export const ErrorMessage: React.FC = ({ text, hint }) => { - const prefix = '✕ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - {text} - {hint && ({hint})} - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx deleted file mode 100644 index 987cbf38a..000000000 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @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'; -import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; - -interface GeminiMessageProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -export const GeminiMessage: React.FC = ({ - text, - isPending, - availableTerminalHeight, - contentWidth, -}) => { - const prefix = '✦ '; - const prefixWidth = prefix.length; - - return ( - - - - {prefix} - - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx deleted file mode 100644 index 29a82298f..000000000 --- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @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'; - -interface GeminiMessageContentProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -/* - * Gemini message content is a semi-hacked component. The intention is to represent a partial - * of GeminiMessage and is only used when a response gets too long. In that instance messages - * are split into multiple GeminiMessageContent's to enable the root component in - * App.tsx to be as performant as humanly possible. - */ -export const GeminiMessageContent: React.FC = ({ - text, - isPending, - availableTerminalHeight, - contentWidth, -}) => { - const originalPrefix = '✦ '; - const prefixWidth = originalPrefix.length; - - return ( - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx deleted file mode 100644 index b595c9d06..000000000 --- a/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @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; - contentWidth: 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, - contentWidth, -}) => { - 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 deleted file mode 100644 index 0f20c45d2..000000000 --- a/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @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; - contentWidth: 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, contentWidth }) => { - const originalPrefix = '✦ '; - const prefixWidth = originalPrefix.length; - - return ( - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx deleted file mode 100644 index af036237a..000000000 --- a/packages/cli/src/ui/components/messages/InfoMessage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; - -interface InfoMessageProps { - text: string; -} - -export const InfoMessage: React.FC = ({ text }) => { - // Don't render anything if text is empty - if (!text || text.trim() === '') { - return null; - } - - const prefix = 'ℹ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx b/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx deleted file mode 100644 index 0f4727574..000000000 --- a/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface RetryCountdownMessageProps { - text: string; -} - -/** - * Displays a retry countdown message in a dimmed/secondary style - * to visually distinguish it from error messages. - */ -export const RetryCountdownMessage: React.FC = ({ - text, -}) => { - if (!text || text.trim() === '') { - return null; - } - - const prefix = '↻ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - {text} - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/StatusMessages.tsx b/packages/cli/src/ui/components/messages/StatusMessages.tsx new file mode 100644 index 000000000..e6e945bbd --- /dev/null +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import stringWidth from 'string-width'; +import { theme } from '../../semantic-colors.js'; +import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; + +interface StatusMessageProps { + text: string; + prefix: string; + prefixColor: string; + textColor: string; + children?: React.ReactNode; +} + +interface StatusTextProps { + text: string; +} + +/** + * Shared renderer for status-like history messages (info/warning/error/retry). + * Keeps prefix spacing and wrapping behavior consistent across variants. + */ +export const StatusMessage: React.FC = ({ + text, + prefix, + prefixColor, + textColor, + children, +}) => { + if (!text || text.trim() === '') { + return null; + } + + const prefixWidth = stringWidth(prefix) + 1; + + return ( + + + {prefix} + + + + + {children} + + + + ); +}; + +export const InfoMessage: React.FC = ({ text }) => ( + +); + +export const SuccessMessage: React.FC = ({ text }) => ( + +); + +export const WarningMessage: React.FC = ({ text }) => ( + +); + +export const ErrorMessage: React.FC = ({ + text, + hint, +}) => ( + + {hint && ({hint})} + +); + +export const RetryCountdownMessage: React.FC = ({ text }) => ( + +); diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx deleted file mode 100644 index 5cc2b965c..000000000 --- a/packages/cli/src/ui/components/messages/UserMessage.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js'; -import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js'; - -interface UserMessageProps { - text: string; -} - -export const UserMessage: React.FC = ({ text }) => { - const prefix = '> '; - const prefixWidth = prefix.length; - const isSlashCommand = checkIsSlashCommand(text); - - const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary; - - return ( - - - - {prefix} - - - - - {text} - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/UserShellMessage.tsx b/packages/cli/src/ui/components/messages/UserShellMessage.tsx deleted file mode 100644 index 3b7bc7724..000000000 --- a/packages/cli/src/ui/components/messages/UserShellMessage.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface UserShellMessageProps { - text: string; -} - -export const UserShellMessage: React.FC = ({ text }) => { - // Remove leading '!' if present, as App.tsx adds it for the processor. - const commandToDisplay = text.startsWith('!') ? text.substring(1) : text; - - return ( - - $ - {commandToDisplay} - - ); -}; diff --git a/packages/cli/src/ui/components/messages/WarningMessage.tsx b/packages/cli/src/ui/components/messages/WarningMessage.tsx deleted file mode 100644 index 589ca4b07..000000000 --- a/packages/cli/src/ui/components/messages/WarningMessage.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { Colors } from '../../colors.js'; -import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; -import { theme } from '../../semantic-colors.js'; - -interface WarningMessageProps { - text: string; -} - -export const WarningMessage: React.FC = ({ text }) => { - const prefix = '⚠ '; - const prefixWidth = 3; - - return ( - - - {prefix} - - - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx b/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx index c36c72f52..70c0e0671 100644 --- a/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx +++ b/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx @@ -120,45 +120,6 @@ export function AgentCreationWizard({ ); }, [state.currentStep, state.generationMethod]); - const renderDebugContent = useCallback(() => { - if (process.env['NODE_ENV'] !== 'development') { - return null; - } - - return ( - - - - Debug Info: - - Step: {state.currentStep} - - Can Proceed: {state.canProceed ? 'Yes' : 'No'} - - - Generating: {state.isGenerating ? 'Yes' : 'No'} - - Location: {state.location} - - Method: {state.generationMethod} - - {state.validationErrors.length > 0 && ( - - Errors: {state.validationErrors.join(', ')} - - )} - - - ); - }, [ - state.currentStep, - state.canProceed, - state.isGenerating, - state.location, - state.generationMethod, - state.validationErrors, - ]); - const renderStepFooter = useCallback(() => { const getNavigationInstructions = () => { // Special case: During generation in description input step, only show cancel option @@ -331,7 +292,6 @@ export function AgentCreationWizard({ > {renderStepHeader()} {renderStepContent()} - {renderDebugContent()} {renderStepFooter()} diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index c28cd9525..edf25bead 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -1335,6 +1335,139 @@ describe('KeypressContext - Kitty Protocol', () => { ); }); + describe('Printable CSI-u keys', () => { + it('parses kitty CSI-u space as a space key with literal sequence', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[32u`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'space', + sequence: ' ', + kittyProtocol: true, + }), + ); + }); + + it('parses kitty CSI-u printable letters as literal input', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[100u`)); // 'd' + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'd', + sequence: 'd', + kittyProtocol: true, + }), + ); + }); + }); + + describe('Kitty keypad private-use keys', () => { + it.each([ + { keyCode: 57399, digit: '0' }, + { keyCode: 57400, digit: '1' }, + { keyCode: 57401, digit: '2' }, + { keyCode: 57402, digit: '3' }, + { keyCode: 57403, digit: '4' }, + { keyCode: 57404, digit: '5' }, + { keyCode: 57405, digit: '6' }, + { keyCode: 57406, digit: '7' }, + { keyCode: 57407, digit: '8' }, + { keyCode: 57408, digit: '9' }, + ])( + 'parses kitty keypad digit keyCode $keyCode as "$digit"', + ({ keyCode, digit }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: digit, + sequence: digit, + kittyProtocol: true, + }), + ); + }, + ); + + it.each([ + { keyCode: 57409, char: '.' }, + { keyCode: 57410, char: '/' }, + { keyCode: 57411, char: '*' }, + { keyCode: 57412, char: '-' }, + { keyCode: 57413, char: '+' }, + { keyCode: 57415, char: '=' }, + { keyCode: 57416, char: ',' }, + ])( + 'parses kitty keypad printable keyCode $keyCode as "$char"', + ({ keyCode, char }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: char, + sequence: char, + kittyProtocol: true, + }), + ); + }, + ); + + it.each([ + { keyCode: 57417, name: 'left' }, + { keyCode: 57418, name: 'right' }, + { keyCode: 57419, name: 'up' }, + { keyCode: 57420, name: 'down' }, + { keyCode: 57421, name: 'pageup' }, + { keyCode: 57422, name: 'pagedown' }, + { keyCode: 57423, name: 'home' }, + { keyCode: 57424, name: 'end' }, + { keyCode: 57425, name: 'insert' }, + { keyCode: 57426, name: 'delete' }, + ])( + 'parses kitty keypad functional keyCode $keyCode as $name', + ({ keyCode, name }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[${keyCode};5u`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name, + ctrl: true, + kittyProtocol: true, + }), + ); + }, + ); + + it('does not emit a placeholder for unmapped private-use keyCodes', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[57398u`)); + + expect(keyHandler).not.toHaveBeenCalled(); + }); + }); + describe('Shift+Tab forms', () => { it.each([ { sequence: `\x1b[Z`, description: 'legacy reverse Tab' }, diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index c4e192609..791602f6a 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -47,6 +47,42 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m export const SINGLE_QUOTE = "'"; export const DOUBLE_QUOTE = '"'; +// Kitty keypad private-use keycodes (0xE000-0xE026) +// Reference: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions +const KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR: Record = { + 57399: '0', + 57400: '1', + 57401: '2', + 57402: '3', + 57403: '4', + 57404: '5', + 57405: '6', + 57406: '7', + 57407: '8', + 57408: '9', + 57409: '.', + 57410: '/', + 57411: '*', + 57412: '-', + 57413: '+', + // 57414 is keypad Enter - handled separately via CSI~ sequence + 57415: '=', + 57416: ',', +}; + +const KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME: Record = { + 57417: 'left', + 57418: 'right', + 57419: 'up', + 57420: 'down', + 57421: 'pageup', + 57422: 'pagedown', + 57423: 'home', + 57424: 'end', + 57425: 'insert', + 57426: 'delete', +}; + export interface Key { name: string; ctrl: boolean; @@ -332,6 +368,74 @@ export function KeypressProvider({ }; } + if (!ctrl) { + const keypadChar = KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR[keyCode]; + if (keypadChar) { + return { + key: { + name: keypadChar, + ctrl: false, + meta: alt, + shift, + paste: false, + sequence: keypadChar, + kittyProtocol: true, + }, + length: m[0].length, + }; + } + } + + const keypadName = KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME[keyCode]; + if (keypadName) { + return { + key: { + name: keypadName, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // Printable CSI-u keys (including space) should behave like regular + // character input so downstream text inputs receive the literal char. + // Kitty uses the Unicode private use area for some functional keys + // such as keypad events, so exclude that range from generic printable + // conversion and handle mapped keys explicitly above. + if ( + terminator === 'u' && + !ctrl && + keyCode >= 32 && + keyCode !== 127 && + keyCode <= 0x10ffff && + !(keyCode >= 0xe000 && keyCode <= 0xf8ff) + ) { + const char = String.fromCodePoint(keyCode); + const printableName = + char === ' ' + ? 'space' + : /^[A-Za-z]$/.test(char) + ? char.toLowerCase() + : char; + return { + key: { + name: printableName, + ctrl: false, + meta: alt, + shift, + paste: false, + sequence: char, + kittyProtocol: true, + }, + length: m[0].length, + }; + } + // Ctrl+letters if ( ctrl && diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index af15e72b6..19464cccc 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -74,6 +74,10 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // Extensions manager dialog + closeExtensionsManagerDialog: () => void; + // MCP dialog + closeMcpDialog: () => void; // Resume session dialog openResumeDialog: () => void; closeResumeDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 9d1a21e83..0d461e70c 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -125,6 +125,10 @@ export interface UIState { // Subagent dialogs isSubagentCreateDialogOpen: boolean; isAgentsManagerDialogOpen: boolean; + // Extensions manager dialog + isExtensionsManagerDialogOpen: boolean; + // MCP dialog + isMcpDialogOpen: boolean; // Feedback dialog isFeedbackDialogOpen: boolean; } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 80c6bec35..11686bf2d 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -78,6 +78,8 @@ interface SlashCommandProcessorActions { addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; openAgentsManagerDialog: () => void; + openExtensionsManagerDialog: () => void; + openMcpDialog: () => void; } /** @@ -476,12 +478,18 @@ export const useSlashCommandProcessor = ( case 'subagent_list': actions.openAgentsManagerDialog(); return { type: 'handled' }; + case 'mcp': + actions.openMcpDialog(); + return { type: 'handled' }; case 'approval-mode': actions.openApprovalModeDialog(); return { type: 'handled' }; case 'resume': actions.openResumeDialog(); return { type: 'handled' }; + case 'extensions_manage': + actions.openExtensionsManagerDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index bcb5bce33..7f8be6a69 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -481,6 +481,111 @@ describe('useCodingPlanUpdates', () => { ).toBe(true); }); + it('should show "model preserved" message when current model exists in new template', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'qwen3.5-plus', + baseUrl: chinaConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }, + ], + }; + // Simulate the user's current model being one that exists in the new template + mockConfig.getModel.mockReturnValue('qwen3.5-plus'); + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Should show plain success message without "switched" + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining('updated successfully'), + }), + expect.any(Number), + ); + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining('switched'), + }), + expect.any(Number), + ); + + // Reset mock + mockConfig.getModel.mockReturnValue('qwen-max'); + }); + + it('should show "model switched" message when current model is not in new template', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'removed-model', + baseUrl: chinaConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }, + ], + }; + // The user's current model no longer exists in the new template + mockConfig.getModel.mockReturnValue('removed-model'); + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Should show "model switched" message + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining('switched'), + }), + expect.any(Number), + ); + + // Reset mock + mockConfig.getModel.mockReturnValue('qwen-max'); + }); + it('should handle update errors gracefully', async () => { mockSettings.merged.codingPlan = { region: CodingPlanRegion.CHINA, diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index 138498abf..1d341b31f 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -42,6 +42,7 @@ export function useCodingPlanUpdates( /** * Execute the Coding Plan configuration update. * Removes old Coding Plan configs and replaces them with new ones from the template. + * Preserves the user's current model selection if it still exists in the new template. * Uses the region from settings.codingPlan.region (defaults to CHINA). */ const executeUpdate = useCallback( @@ -82,6 +83,12 @@ export function useCodingPlanUpdates( ...(nonCodingPlanConfigs as Array>), ] as Array>; + // Record the user's current model before the update + const previousModel = config.getModel(); + const previousModelStillAvailable = newConfigs.some( + (cfg) => cfg.id === previousModel, + ); + // Hot-reload model providers configuration first (in-memory only) const updatedModelProviders = { ...(settings.merged.modelProviders as @@ -112,12 +119,34 @@ export function useCodingPlanUpdates( const activeModel = config.getModel(); + if (previousModelStillAvailable && activeModel === previousModel) { + addItem( + { + type: 'info', + text: t('{{region}} configuration updated successfully.', { + region: t('Alibaba Cloud Coding Plan'), + }), + }, + Date.now(), + ); + } else { + addItem( + { + type: 'info', + text: t( + '{{region}} configuration updated successfully. Model switched to "{{model}}".', + { region: t('Alibaba Cloud Coding Plan'), model: activeModel }, + ), + }, + Date.now(), + ); + } + addItem( { type: 'info', text: t( - '{{region}} configuration updated successfully. Model switched to "{{model}}".', - { region: t('Alibaba Cloud Coding Plan'), model: activeModel }, + 'Tip: Use /model to switch between available Coding Plan models.', ), }, Date.now(), diff --git a/packages/cli/src/ui/hooks/useExtensionsManagerDialog.ts b/packages/cli/src/ui/hooks/useExtensionsManagerDialog.ts new file mode 100644 index 000000000..db6c82054 --- /dev/null +++ b/packages/cli/src/ui/hooks/useExtensionsManagerDialog.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +interface UseExtensionsManagerDialogReturn { + isExtensionsManagerDialogOpen: boolean; + openExtensionsManagerDialog: () => void; + closeExtensionsManagerDialog: () => void; +} + +export const useExtensionsManagerDialog = + (): UseExtensionsManagerDialogReturn => { + const [isExtensionsManagerDialogOpen, setIsExtensionsManagerDialogOpen] = + useState(false); + + const openExtensionsManagerDialog = useCallback(() => { + setIsExtensionsManagerDialogOpen(true); + }, []); + + const closeExtensionsManagerDialog = useCallback(() => { + setIsExtensionsManagerDialogOpen(false); + }, []); + + return { + isExtensionsManagerDialogOpen, + openExtensionsManagerDialog, + closeExtensionsManagerDialog, + }; + }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0e5f29216..173065f41 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -59,6 +59,7 @@ import { type TrackedToolCall, type TrackedCompletedToolCall, type TrackedCancelledToolCall, + type TrackedExecutingToolCall, type TrackedWaitingToolCall, } from './useReactToolScheduler.js'; import { promises as fs } from 'node:fs'; @@ -358,6 +359,23 @@ export const useGeminiStream = ( if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) { return StreamingState.WaitingForConfirmation; } + // Check if any executing subagent task has a pending confirmation + if ( + toolCalls.some((tc) => { + if (tc.status !== 'executing') return false; + const liveOutput = (tc as TrackedExecutingToolCall).liveOutput; + return ( + typeof liveOutput === 'object' && + liveOutput !== null && + 'type' in liveOutput && + liveOutput.type === 'task_execution' && + 'pendingConfirmation' in liveOutput && + liveOutput.pendingConfirmation != null + ); + }) + ) { + return StreamingState.WaitingForConfirmation; + } if ( isResponding || toolCalls.some( @@ -1020,6 +1038,15 @@ export const useGeminiStream = ( clearRetryCountdown(); } break; + case ServerGeminiEventType.HookSystemMessage: + // Display system message from hooks (e.g., Ralph Loop iteration info) + // This is handled as a content event to show in the UI + geminiMessageBuffer = handleContentEvent( + event.value + '\n', + geminiMessageBuffer, + userMessageTimestamp, + ); + break; default: { // enforces exhaustive switch-case const unreachable: never = event; diff --git a/packages/cli/src/ui/hooks/useMcpDialog.ts b/packages/cli/src/ui/hooks/useMcpDialog.ts new file mode 100644 index 000000000..3b444297f --- /dev/null +++ b/packages/cli/src/ui/hooks/useMcpDialog.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +export interface UseMcpDialogReturn { + isMcpDialogOpen: boolean; + openMcpDialog: () => void; + closeMcpDialog: () => void; +} + +export const useMcpDialog = (): UseMcpDialogReturn => { + const [isMcpDialogOpen, setIsMcpDialogOpen] = useState(false); + + const openMcpDialog = useCallback(() => { + setIsMcpDialogOpen(true); + }, []); + + const closeMcpDialog = useCallback(() => { + setIsMcpDialogOpen(false); + }, []); + + return { + isMcpDialogOpen, + openMcpDialog, + closeMcpDialog, + }; +}; diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts index 3d5b4d4e7..c3a7cbce4 100644 --- a/packages/cli/src/ui/themes/no-color.ts +++ b/packages/cli/src/ui/themes/no-color.ts @@ -33,6 +33,7 @@ const noColorSemanticColors: SemanticColors = { secondary: '', link: '', accent: '', + code: '', }, background: { primary: '', diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts index 2aa27a09c..d3047f0f0 100644 --- a/packages/cli/src/ui/themes/semantic-tokens.ts +++ b/packages/cli/src/ui/themes/semantic-tokens.ts @@ -12,6 +12,7 @@ export interface SemanticColors { secondary: string; link: string; accent: string; + code: string; }; background: { primary: string; @@ -45,6 +46,7 @@ export const lightSemanticColors: SemanticColors = { secondary: lightTheme.Gray, link: lightTheme.AccentBlue, accent: lightTheme.AccentPurple, + code: lightTheme.LightBlue, }, background: { primary: lightTheme.Background, @@ -77,6 +79,7 @@ export const darkSemanticColors: SemanticColors = { secondary: darkTheme.Gray, link: darkTheme.AccentBlue, accent: darkTheme.AccentPurple, + code: darkTheme.LightBlue, }, background: { primary: darkTheme.Background, @@ -109,6 +112,7 @@ export const ansiSemanticColors: SemanticColors = { secondary: ansiTheme.Gray, link: ansiTheme.AccentBlue, accent: ansiTheme.AccentPurple, + code: ansiTheme.LightBlue, }, background: { primary: ansiTheme.Background, diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts index 3ae3bbead..5fee07729 100644 --- a/packages/cli/src/ui/themes/theme.ts +++ b/packages/cli/src/ui/themes/theme.ts @@ -40,6 +40,7 @@ export interface CustomTheme { secondary?: string; link?: string; accent?: string; + code?: string; }; background?: { primary?: string; @@ -174,6 +175,7 @@ export class Theme { secondary: this.colors.Gray, link: this.colors.AccentBlue, accent: this.colors.AccentPurple, + code: this.colors.LightBlue, }, background: { primary: this.colors.Background, @@ -269,7 +271,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { type: 'custom', Background: customTheme.background?.primary ?? customTheme.Background ?? '', Foreground: customTheme.text?.primary ?? customTheme.Foreground ?? '', - LightBlue: customTheme.text?.link ?? customTheme.LightBlue ?? '', + LightBlue: customTheme.text?.code ?? customTheme.LightBlue ?? '', AccentBlue: customTheme.text?.link ?? customTheme.AccentBlue ?? '', AccentPurple: customTheme.text?.accent ?? customTheme.AccentPurple ?? '', AccentCyan: customTheme.text?.link ?? customTheme.AccentCyan ?? '', @@ -433,6 +435,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { secondary: customTheme.text?.secondary ?? colors.Gray, link: customTheme.text?.link ?? colors.AccentBlue, accent: customTheme.text?.accent ?? colors.AccentPurple, + code: customTheme.text?.code ?? colors.LightBlue, }, background: { primary: customTheme.background?.primary ?? colors.Background, diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index 30943eee9..112f38c7f 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto'; import type { Config, ChatRecord } from '@qwen-code/qwen-code-core'; import type { SessionContext } from '../../../acp-integration/session/types.js'; -import type * as acp from '../../../acp-integration/acp.js'; +import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk'; import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js'; import type { ExportMessage, ExportSessionData } from './types.js'; @@ -34,7 +34,7 @@ class ExportSessionContext implements SessionContext { this.config = config; } - async sendUpdate(update: acp.SessionUpdate): Promise { + async sendUpdate(update: SessionUpdate): Promise { switch (update.sessionUpdate) { case 'user_message_chunk': this.handleMessageChunk('user', update.content); @@ -108,7 +108,7 @@ class ExportSessionContext implements SessionContext { } } - private handleToolCallStart(update: acp.ToolCall): void { + private handleToolCallStart(update: ToolCall): void { const toolCall: ExportMessage['toolCall'] = { toolCallId: update.toolCallId, kind: update.kind || 'other', diff --git a/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb index 70e1150c9..43f17062c 100644 --- a/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb +++ b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb @@ -29,7 +29,7 @@ (allow network-inbound (local ip "localhost:9229")) ;; deny all outbound network traffic EXCEPT through proxy on localhost:8877 -;; set `GEMINI_SANDBOX_PROXY_COMMAND=` to run proxy alongside sandbox +;; set `QWEN_SANDBOX_PROXY_COMMAND=` to run proxy alongside sandbox ;; proxy must listen on :::8877 (see docs/examples/proxy-script.md) (deny network-outbound) (allow network-outbound (remote tcp "localhost:8877")) diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb index 8affc94dc..d6a450c25 100644 --- a/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb +++ b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb @@ -93,6 +93,6 @@ (allow network-inbound (local ip "localhost:9229")) ;; allow outbound network traffic through proxy on localhost:8877 -;; set `GEMINI_SANDBOX_PROXY_COMMAND=` to run proxy alongside sandbox +;; set `QWEN_SANDBOX_PROXY_COMMAND=` to run proxy alongside sandbox ;; proxy must listen on :::8877 (see docs/examples/proxy-script.md) (allow network-outbound (remote tcp "localhost:8877")) diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 849a0dbd4..930ea41a0 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -263,8 +263,8 @@ export async function start_sandbox( ...finalArgv.map((arg) => quote([arg])), ].join(' '), ); - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set - const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; + // start and set up proxy if QWEN_SANDBOX_PROXY_COMMAND is set + const proxyCommand = process.env['QWEN_SANDBOX_PROXY_COMMAND']; let proxyProcess: ChildProcess | undefined = undefined; let sandboxProcess: ChildProcess | undefined = undefined; const sandboxEnv = { ...process.env }; @@ -378,7 +378,7 @@ export async function start_sandbox( stdio: 'inherit', env: { ...process.env, - GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) + QWEN_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) }, }, ); @@ -498,8 +498,8 @@ export async function start_sandbox( // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME // copy as both upper-case and lower-case as is required by some utilities - // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set - const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; + // QWEN_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set + const proxyCommand = process.env['QWEN_SANDBOX_PROXY_COMMAND']; if (proxyCommand) { let proxy = @@ -563,11 +563,11 @@ export async function start_sandbox( } args.push('--name', containerName, '--hostname', containerName); - // copy GEMINI_CLI_TEST_VAR for integration tests - if (process.env['GEMINI_CLI_TEST_VAR']) { + // copy QWEN_CODE_TEST_VAR for integration tests + if (process.env['QWEN_CODE_TEST_VAR']) { args.push( '--env', - `GEMINI_CLI_TEST_VAR=${process.env['GEMINI_CLI_TEST_VAR']}`, + `QWEN_CODE_TEST_VAR=${process.env['QWEN_CODE_TEST_VAR']}`, ); } @@ -773,7 +773,7 @@ export async function start_sandbox( // push container entrypoint (including args) args.push(...finalEntrypoint); - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + // start and set up proxy if QWEN_SANDBOX_PROXY_COMMAND is set let proxyProcess: ChildProcess | undefined = undefined; let sandboxProcess: ChildProcess | undefined = undefined; diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/utils/systemInfo.ts index 564c5a08a..4ea281210 100644 --- a/packages/cli/src/utils/systemInfo.ts +++ b/packages/cli/src/utils/systemInfo.ts @@ -38,6 +38,7 @@ export interface SystemInfo { export interface ExtendedSystemInfo extends SystemInfo { memoryUsage: string; baseUrl?: string; + apiKeyEnvKey?: string; gitCommit?: string; proxy?: string; } @@ -154,12 +155,14 @@ export async function getExtendedSystemInfo( // For bug reports, use sandbox name without prefix const sandboxEnv = getSandboxEnv(true); - // Get base URL if using OpenAI auth - const baseUrl = + // Get base URL and apiKeyEnvKey if using OpenAI or Anthropic auth + const contentGeneratorConfig = baseInfo.selectedAuthType === AuthType.USE_OPENAI || baseInfo.selectedAuthType === AuthType.USE_ANTHROPIC - ? context.services.config?.getContentGeneratorConfig()?.baseUrl + ? context.services.config?.getContentGeneratorConfig() : undefined; + const baseUrl = contentGeneratorConfig?.baseUrl; + const apiKeyEnvKey = contentGeneratorConfig?.apiKeyEnvKey; // Get git commit info const gitCommit = @@ -172,6 +175,7 @@ export async function getExtendedSystemInfo( sandboxEnv, memoryUsage, baseUrl, + apiKeyEnvKey, gitCommit, }; } diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/utils/systemInfoFields.ts index ed43431f2..17062b66a 100644 --- a/packages/cli/src/utils/systemInfoFields.ts +++ b/packages/cli/src/utils/systemInfoFields.ts @@ -6,6 +6,7 @@ import type { ExtendedSystemInfo } from './systemInfo.js'; import { t } from '../i18n/index.js'; +import { isCodingPlanConfig } from '../constants/codingPlan.js'; /** * Field configuration for system information display @@ -30,6 +31,7 @@ export function getSystemInfoFields( addField(fields, t('IDE Client'), info.ideClient); addField(fields, t('OS'), formatOs(info)); addField(fields, t('Auth'), formatAuth(info)); + addField(fields, t('Base URL'), formatBaseUrl(info)); addField(fields, t('Model'), info.modelVersion); addField(fields, t('Session ID'), info.sessionId); addField(fields, t('Sandbox'), info.sandboxEnv); @@ -86,15 +88,34 @@ function formatAuth(info: ExtendedSystemInfo): string { if (!info.selectedAuthType) { return ''; } - const authType = formatAuthType(info.selectedAuthType); - if (!info.baseUrl) { - return authType; + + if (isCodingPlanConfig(info.baseUrl, info.apiKeyEnvKey)) { + return t('Alibaba Cloud Coding Plan'); } - return `${authType} (${info.baseUrl})`; + + if ( + info.selectedAuthType.startsWith('oauth') || + info.selectedAuthType === 'qwen-oauth' + ) { + return 'Qwen OAuth'; + } + + return `API Key - ${info.selectedAuthType}`; } -function formatAuthType(authType: string): string { - return authType.startsWith('oauth') ? 'OAuth' : authType; +function formatBaseUrl(info: ExtendedSystemInfo): string { + if (!info.selectedAuthType || !info.baseUrl) { + return ''; + } + + if ( + info.selectedAuthType.startsWith('oauth') || + info.selectedAuthType === 'qwen-oauth' + ) { + return ''; + } + + return info.baseUrl; } function formatProxy(proxy?: string): string { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 2be01125f..828ef9c3e 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -118,6 +118,7 @@ vi.mock('../tools/memoryTool', () => ({ MemoryTool: createToolMock('save_memory'), setGeminiMdFilename: vi.fn(), getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename + getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), DEFAULT_CONTEXT_FILENAME: 'QWEN.md', QWEN_CONFIG_DIR: '.qwen', })); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 98b72c9c2..d8af76fea 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -84,6 +84,13 @@ import { ExtensionManager, type Extension, } from '../extension/extensionManager.js'; +import { HookSystem } from '../hooks/index.js'; +import { MessageBus } from '../confirmation-bus/message-bus.js'; +import { + MessageBusType, + type HookExecutionRequest, + type HookExecutionResponse, +} from '../confirmation-bus/types.js'; // Utils import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; @@ -377,6 +384,12 @@ export interface ConfigParameters { channel?: string; /** Model providers configuration grouped by authType */ modelProvidersConfig?: ModelProvidersConfig; + /** Enable hook system for lifecycle events */ + enableHooks?: boolean; + /** Hooks configuration from settings */ + hooks?: Record; + /** Hooks config settings (enabled, disabled list) */ + hooksConfig?: Record; /** Warnings generated during configuration resolution */ warnings?: string[]; } @@ -446,7 +459,7 @@ export class Config { private readonly lspEnabled: boolean; private lspClient?: LspClient; private readonly allowedMcpServers?: string[]; - private readonly excludedMcpServers?: string[]; + private excludedMcpServers?: string[]; private sessionSubagents: SubagentConfig[]; private userMemory: string; private sdkMode: boolean; @@ -519,6 +532,11 @@ export class Config { private readonly eventEmitter?: EventEmitter; private readonly channel: string | undefined; private readonly defaultFileEncoding: FileEncodingType; + private readonly enableHooks: boolean; + private readonly hooks?: Record; + private readonly hooksConfig?: Record; + private hookSystem?: HookSystem; + private messageBus?: MessageBus; constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); @@ -617,7 +635,7 @@ export class Config { this.webSearch = params.webSearch; this.useRipgrep = params.useRipgrep ?? true; this.useBuiltinRipgrep = params.useBuiltinRipgrep ?? true; - this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; + this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? true; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.shellExecutionConfig = { terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80, @@ -673,6 +691,9 @@ export class Config { enabledExtensionOverrides: this.overrideExtensions, isWorkspaceTrusted: this.isTrustedFolder(), }); + this.enableHooks = params.enableHooks ?? false; + this.hooks = params.hooks; + this.hooksConfig = params.hooksConfig; } /** @@ -696,6 +717,75 @@ export class Config { await this.extensionManager.refreshCache(); this.debugLogger.debug('Extension manager initialized'); + // Initialize hook system if enabled + if (this.enableHooks) { + this.hookSystem = new HookSystem(this); + await this.hookSystem.initialize(); + this.debugLogger.debug('Hook system initialized'); + + // Initialize MessageBus for hook execution + this.messageBus = new MessageBus(); + + // Subscribe to HOOK_EXECUTION_REQUEST to execute hooks + this.messageBus.subscribe( + MessageBusType.HOOK_EXECUTION_REQUEST, + async (request: HookExecutionRequest) => { + try { + const hookSystem = this.hookSystem; + if (!hookSystem) { + this.messageBus?.publish({ + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: request.correlationId, + success: false, + error: new Error('Hook system not initialized'), + } as HookExecutionResponse); + return; + } + + // Execute the appropriate hook based on eventName + let result; + const input = request.input || {}; + switch (request.eventName) { + case 'UserPromptSubmit': + result = await hookSystem.fireUserPromptSubmitEvent( + (input['prompt'] as string) || '', + ); + break; + case 'Stop': + result = await hookSystem.fireStopEvent( + (input['stop_hook_active'] as boolean) || false, + (input['last_assistant_message'] as string) || '', + ); + break; + default: + this.debugLogger.warn( + `Unknown hook event: ${request.eventName}`, + ); + result = undefined; + } + + // Send response + this.messageBus?.publish({ + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: request.correlationId, + success: true, + output: result, + } as HookExecutionResponse); + } catch (error) { + this.debugLogger.warn(`Hook execution failed: ${error}`); + this.messageBus?.publish({ + type: MessageBusType.HOOK_EXECUTION_RESPONSE, + correlationId: request.correlationId, + success: false, + error: error instanceof Error ? error : new Error(String(error)), + } as HookExecutionResponse); + } + }, + ); + + this.debugLogger.debug('MessageBus initialized with hook subscription'); + } + this.subagentManager = new SubagentManager(this); this.skillManager = new SkillManager(this); await this.skillManager.startWatching(); @@ -1162,17 +1252,25 @@ export class Config { ); } - if (this.excludedMcpServers) { - mcpServers = Object.fromEntries( - Object.entries(mcpServers).filter( - ([key]) => !this.excludedMcpServers?.includes(key), - ), - ); - } + // Note: We no longer filter out excluded servers here. + // The UI layer should check isMcpServerDisabled() to determine + // whether to show a server as disabled. return mcpServers; } + getExcludedMcpServers(): string[] | undefined { + return this.excludedMcpServers; + } + + setExcludedMcpServers(excluded: string[]): void { + this.excludedMcpServers = excluded; + } + + isMcpServerDisabled(serverName: string): boolean { + return this.excludedMcpServers?.includes(serverName) ?? false; + } + addMcpServers(servers: Record): void { if (this.initialized) { throw new Error('Cannot modify mcpServers after initialization'); @@ -1384,6 +1482,66 @@ export class Config { return this.extensionManager; } + /** + * Get the hook system instance if hooks are enabled. + * Returns undefined if hooks are not enabled. + */ + getHookSystem(): HookSystem | undefined { + return this.hookSystem; + } + + /** + * Check if hooks are enabled. + */ + getEnableHooks(): boolean { + return this.enableHooks; + } + + /** + * Get the message bus instance. + * Returns undefined if not set. + */ + getMessageBus(): MessageBus | undefined { + return this.messageBus; + } + + /** + * Set the message bus instance. + * This is called by the CLI layer to inject the MessageBus. + */ + setMessageBus(messageBus: MessageBus): void { + this.messageBus = messageBus; + } + + /** + * Get the list of disabled hook names. + * This is used by the HookRegistry to filter out disabled hooks. + */ + getDisabledHooks(): string[] { + const hooksConfig = this.hooksConfig; + if (!hooksConfig) return []; + const disabled = hooksConfig['disabled']; + return Array.isArray(disabled) ? (disabled as string[]) : []; + } + + /** + * Get project-level hooks configuration. + * This is used by the HookRegistry to load project-specific hooks. + */ + getProjectHooks(): Record | undefined { + // This will be populated from settings by the CLI layer + // The core Config doesn't have direct access to settings + return undefined; + } + + /** + * Get all hooks configuration (merged from all sources). + * This is used by the HookRegistry to load hooks. + */ + getHooks(): Record | undefined { + return this.hooks; + } + getExtensions(): Extension[] { const extensions = this.extensionManager.getLoadedExtensions(); if (this.overrideExtensions) { @@ -1620,6 +1778,21 @@ export class Config { return this.chatRecordingService; } + /** + * Returns the transcript file path for the current session. + * This is the path to the JSONL file where the conversation is recorded. + * Returns empty string if chat recording is disabled. + */ + getTranscriptPath(): string { + if (!this.chatRecordingEnabled) { + return ''; + } + const projectDir = this.storage.getProjectDir(); + const sessionId = this.getSessionId(); + const safeFilename = `${sessionId}.jsonl`; + return path.join(projectDir, 'chats', safeFilename); + } + /** * Gets or creates a SessionService for managing chat sessions. */ diff --git a/packages/core/src/confirmation-bus/message-bus.ts b/packages/core/src/confirmation-bus/message-bus.ts new file mode 100644 index 000000000..fcd2caab7 --- /dev/null +++ b/packages/core/src/confirmation-bus/message-bus.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { MessageBusType, type Message } from './types.js'; +import { safeJsonStringify } from '../utils/safeJsonStringify.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +export class MessageBus extends EventEmitter { + constructor(private readonly debug = false) { + super(); + this.debug = debug; + } + + private isValidMessage(message: Message): boolean { + if (!message || !message.type) { + return false; + } + + if ( + message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST && + !('correlationId' in message) + ) { + return false; + } + + return true; + } + + private emitMessage(message: Message): void { + this.emit(message.type, message); + } + + async publish(message: Message): Promise { + if (this.debug) { + debugLogger.debug(`[MESSAGE_BUS] publish: ${safeJsonStringify(message)}`); + } + try { + if (!this.isValidMessage(message)) { + throw new Error( + `Invalid message structure: ${safeJsonStringify(message)}`, + ); + } + + if (message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST) { + // Allow all tool confirmations by default (policy engine removed) + this.emitMessage({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: message.correlationId, + confirmed: true, + }); + } else if (message.type === MessageBusType.HOOK_EXECUTION_REQUEST) { + // Allow all hook executions by default (policy engine removed) + this.emitMessage(message); + } else { + // For all other message types, just emit them + this.emitMessage(message); + } + } catch (error) { + this.emit('error', error); + } + } + + subscribe( + type: T['type'], + listener: (message: T) => void, + ): void { + this.on(type, listener); + } + + unsubscribe( + type: T['type'], + listener: (message: T) => void, + ): void { + this.off(type, listener); + } + + /** + * Request-response pattern: Publish a message and wait for a correlated response + * This enables synchronous-style communication over the async MessageBus + * The correlation ID is generated internally and added to the request + */ + async request( + request: Omit, + responseType: TResponse['type'], + timeoutMs: number = 60000, + ): Promise { + const correlationId = randomUUID(); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + reject(new Error(`Request timed out waiting for ${responseType}`)); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeoutId); + this.unsubscribe(responseType, responseHandler); + }; + + const responseHandler = (response: TResponse) => { + // Check if this response matches our request + if ( + 'correlationId' in response && + response.correlationId === correlationId + ) { + cleanup(); + resolve(response); + } + }; + + // Subscribe to responses + this.subscribe(responseType, responseHandler); + + // Publish the request with correlation ID + this.publish({ ...request, correlationId } as TRequest); + }); + } +} diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts new file mode 100644 index 000000000..7a699bacb --- /dev/null +++ b/packages/core/src/confirmation-bus/types.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type FunctionCall } from '@google/genai'; +import type { + ToolConfirmationOutcome, + ToolConfirmationPayload, +} from '../tools/tools.js'; + +export enum MessageBusType { + TOOL_CONFIRMATION_REQUEST = 'tool-confirmation-request', + TOOL_CONFIRMATION_RESPONSE = 'tool-confirmation-response', + TOOL_EXECUTION_SUCCESS = 'tool-execution-success', + TOOL_EXECUTION_FAILURE = 'tool-execution-failure', + HOOK_EXECUTION_REQUEST = 'hook-execution-request', + HOOK_EXECUTION_RESPONSE = 'hook-execution-response', +} + +export interface ToolConfirmationRequest { + type: MessageBusType.TOOL_CONFIRMATION_REQUEST; + toolCall: FunctionCall; + correlationId: string; + serverName?: string; + /** + * Optional rich details for the confirmation UI (diffs, counts, etc.) + */ + details?: SerializableConfirmationDetails; +} + +export interface ToolConfirmationResponse { + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE; + correlationId: string; + confirmed: boolean; + /** + * The specific outcome selected by the user. + * + * TODO: Make required after migration. + */ + outcome?: ToolConfirmationOutcome; + /** + * Optional payload (e.g., modified content for 'modify_with_editor'). + */ + payload?: ToolConfirmationPayload; + /** + * When true, indicates that policy decision was ASK_USER and the tool should + * show its legacy confirmation UI instead of auto-proceeding. + */ + requiresUserConfirmation?: boolean; +} + +/** + * Data-only versions of ToolCallConfirmationDetails for bus transmission. + */ +export type SerializableConfirmationDetails = + | { + type: 'info'; + title: string; + prompt: string; + urls?: string[]; + } + | { + type: 'edit'; + title: string; + fileName: string; + filePath: string; + fileDiff: string; + originalContent: string | null; + newContent: string; + isModifying?: boolean; + } + | { + type: 'exec'; + title: string; + command: string; + rootCommand: string; + rootCommands: string[]; + commands?: string[]; + } + | { + type: 'mcp'; + title: string; + serverName: string; + toolName: string; + toolDisplayName: string; + } + | { + type: 'exit_plan_mode'; + title: string; + planPath: string; + }; + +export interface ToolExecutionSuccess { + type: MessageBusType.TOOL_EXECUTION_SUCCESS; + toolCall: FunctionCall; + result: T; +} + +export interface ToolExecutionFailure { + type: MessageBusType.TOOL_EXECUTION_FAILURE; + toolCall: FunctionCall; + error: E; +} + +export interface HookExecutionRequest { + type: MessageBusType.HOOK_EXECUTION_REQUEST; + eventName: string; + input: Record; + correlationId: string; +} + +export interface HookExecutionResponse { + type: MessageBusType.HOOK_EXECUTION_RESPONSE; + correlationId: string; + success: boolean; + output?: Record; + error?: Error; +} + +export type Message = + | ToolConfirmationRequest + | ToolConfirmationResponse + | ToolExecutionSuccess + | ToolExecutionFailure + | HookExecutionRequest + | HookExecutionResponse; diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 804349932..7f3eb3053 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -743,6 +743,62 @@ describe('AnthropicContentConverter', () => { const result = await converter.convertGeminiToolsToAnthropic(tools); expect(result[0]?.input_schema?.type).toBe('object'); }); + + it('skips functions without name or description', async () => { + const tools = [ + { + functionDeclarations: [ + { + name: 'valid_tool', + description: 'A valid tool', + }, + { + name: 'missing_description', + // no description + }, + { + // no name + description: 'Missing name', + }, + { + // neither name nor description + parametersJsonSchema: { type: 'object' }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('valid_tool'); + }); + + it('skips functions with empty name or description', async () => { + const tools = [ + { + functionDeclarations: [ + { + name: 'valid_tool', + description: 'A valid tool', + }, + { + name: '', + description: 'Empty name', + }, + { + name: 'empty_description', + description: '', + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('valid_tool'); + }); }); describe('convertAnthropicResponseToGemini', () => { diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 7c774e2a0..ec1e24742 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -91,7 +91,8 @@ export class AnthropicContentConverter { } for (const func of actualTool.functionDeclarations) { - if (!func.name) continue; + // Skip functions without name or description (required by Anthropic API) + if (!func.name || !func.description) continue; let inputSchema: Record | undefined; if (func.parametersJsonSchema) { diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index b5234045e..8121e1464 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -356,6 +356,8 @@ describe('Gemini Client (client.ts)', () => { getSkipLoopDetection: vi.fn().mockReturnValue(false), getChatRecordingService: vi.fn().mockReturnValue(undefined), getResumedSessionData: vi.fn().mockReturnValue(undefined), + getEnableHooks: vi.fn().mockReturnValue(false), + getMessageBus: vi.fn().mockReturnValue(undefined), } as unknown as Config; client = new GeminiClient(mockConfig); @@ -2270,7 +2272,6 @@ Other open files: // Replace loop detector with spies const ldMock = { - turnStarted: vi.fn().mockResolvedValue(false), addAndCheck: vi.fn().mockReturnValue(false), reset: vi.fn(), }; @@ -2301,7 +2302,6 @@ Other open files: } // Assert - loop detection methods should not be called when skipLoopDetection is true - expect(ldMock.turnStarted).not.toHaveBeenCalled(); expect(ldMock.addAndCheck).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 9f3625c38..5c7cfb2a8 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -69,9 +69,19 @@ import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; import { flatMapTextParts } from '../utils/partUtils.js'; import { retryWithBackoff } from '../utils/retry.js'; +// Hook types and utilities +import { + MessageBusType, + type HookExecutionRequest, + type HookExecutionResponse, +} from '../confirmation-bus/types.js'; +import { partToString } from '../utils/partUtils.js'; +import { createHookOutput } from '../hooks/types.js'; + // IDE integration import { ideContextStore } from '../ide/ideContext.js'; import { type File, type IdeContext } from '../ide/types.js'; +import type { StopHookOutput } from '../hooks/types.js'; const MAX_TURNS = 100; @@ -407,6 +417,51 @@ export class GeminiClient { options?: { isContinuation: boolean }, turns: number = MAX_TURNS, ): AsyncGenerator { + // Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled) + const hooksEnabled = this.config.getEnableHooks(); + const messageBus = this.config.getMessageBus(); + if (hooksEnabled && messageBus) { + const promptText = partToString(request); + const response = await messageBus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'UserPromptSubmit', + input: { + prompt: promptText, + }, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + const hookOutput = response.output + ? createHookOutput('UserPromptSubmit', response.output) + : undefined; + + if ( + hookOutput?.isBlockingDecision() || + hookOutput?.shouldStopExecution() + ) { + yield { + type: GeminiEventType.Error, + value: { + error: new Error( + `UserPromptSubmit hook blocked processing: ${hookOutput.getEffectiveReason()}`, + ), + }, + }; + return new Turn(this.getChat(), prompt_id); + } + + // Add additional context from hooks to the request + const additionalContext = hookOutput?.getAdditionalContext(); + if (additionalContext) { + const requestArray = Array.isArray(request) ? request : [request]; + request = [...requestArray, { text: additionalContext }]; + } + } + if (!options?.isContinuation) { this.loopDetector.reset(prompt_id); this.lastPromptId = prompt_id; @@ -486,14 +541,6 @@ export class GeminiClient { const turn = new Turn(this.getChat(), prompt_id); - if (!this.config.getSkipLoopDetection()) { - const loopDetected = await this.loopDetector.turnStarted(signal); - if (loopDetected) { - yield { type: GeminiEventType.LoopDetected }; - return turn; - } - } - // append system reminders to the request let requestToSent = await flatMapTextParts(request, async (text) => [text]); if (!options?.isContinuation) { @@ -536,6 +583,65 @@ export class GeminiClient { return turn; } } + // Fire Stop hook through MessageBus (only if hooks are enabled) + // This must be done before any early returns to ensure hooks are always triggered + if (hooksEnabled && messageBus && !turn.pendingToolCalls.length) { + // Get response text from the chat history + const history = this.getHistory(); + const lastModelMessage = history + .filter((msg) => msg.role === 'model') + .pop(); + const responseText = + lastModelMessage?.parts + ?.filter((p): p is { text: string } => 'text' in p) + .map((p) => p.text) + .join('') || '[no response text]'; + + const response = await messageBus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'Stop', + input: { + stop_hook_active: true, + last_assistant_message: responseText, + }, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + const hookOutput = response.output + ? createHookOutput('Stop', response.output) + : undefined; + + const stopOutput = hookOutput as StopHookOutput | undefined; + + // For Stop hooks, blocking/stop execution should force continuation + if ( + stopOutput?.isBlockingDecision() || + stopOutput?.shouldStopExecution() + ) { + // Emit system message if provided (e.g., "🔄 Ralph iteration 5") + if (stopOutput.systemMessage) { + yield { + type: GeminiEventType.HookSystemMessage, + value: stopOutput.systemMessage, + }; + } + + const continueReason = stopOutput.getEffectiveReason(); + const continueRequest = [{ text: continueReason }]; + return yield* this.sendMessageStream( + continueRequest, + signal, + prompt_id, + { isContinuation: true }, + boundedTurns - 1, + ); + } + } + if (!turn.pendingToolCalls.length && signal && !signal.aborted) { if (this.config.getSkipNextSpeakerCheck()) { return turn; @@ -557,9 +663,9 @@ export class GeminiClient { ); if (nextSpeakerCheck?.next_speaker === 'model') { const nextRequest = [{ text: 'Please continue.' }]; - // This recursive call's events will be yielded out, but the final - // turn object will be from the top-level call. - yield* this.sendMessageStream( + // This recursive call's events will be yielded out, and the final + // turn object from the recursive call will be returned. + return yield* this.sendMessageStream( nextRequest, signal, prompt_id, @@ -568,6 +674,7 @@ export class GeminiClient { ); } } + return turn; } diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 99eb983de..08f379d68 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -64,6 +64,7 @@ export enum GeminiEventType { LoopDetected = 'loop_detected', Citation = 'citation', Retry = 'retry', + HookSystemMessage = 'hook_system_message', } export type ServerGeminiRetryEvent = { @@ -202,6 +203,11 @@ export type ServerGeminiCitationEvent = { value: string; }; +export type ServerGeminiHookSystemMessageEvent = { + type: GeminiEventType.HookSystemMessage; + value: string; +}; + // The original union type, now composed of the individual types export type ServerGeminiStreamEvent = | ServerGeminiChatCompressedEvent @@ -209,6 +215,7 @@ export type ServerGeminiStreamEvent = | ServerGeminiContentEvent | ServerGeminiErrorEvent | ServerGeminiFinishedEvent + | ServerGeminiHookSystemMessageEvent | ServerGeminiLoopDetectedEvent | ServerGeminiMaxSessionTurnsEvent | ServerGeminiThoughtEvent diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index b4d16c8f4..502e8196e 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -368,4 +368,69 @@ describe('convertClaudePluginPackage', () => { // Clean up converted directory fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + + it('should successfully convert agent files with Windows CRLF endings', async () => { + // Setup: Create a plugin with a source agents folder containing a CRLF agent + const pluginSourceDir = path.join(testDir, 'plugin-crlf-agents'); + fs.mkdirSync(pluginSourceDir, { recursive: true }); + + // Create source agents directory (renamed to src-agents to avoid skip-logic bug) + const agentsDir = path.join(pluginSourceDir, 'src-agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + + // Write a .md file with CRLF endings + const crlfAgentContent = `---\r\nname: cool-agent\r\ndescription: A cool agent\r\n---\r\n\r\nSystem prompt body\r\n`; + fs.writeFileSync( + path.join(agentsDir, 'agent.md'), + crlfAgentContent, + 'utf-8', + ); + + // Create marketplace.json specifying to load this agent + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + + const marketplaceConfig: ClaudeMarketplaceConfig = { + name: 'test-marketplace', + owner: { name: 'Test Owner', email: 'test@example.com' }, + plugins: [ + { + name: 'crlf-agents-plugin', + version: '1.0.0', + source: './', + strict: false, + agents: ['./src-agents/agent.md'], + }, + ], + }; + + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig, null, 2), + 'utf-8', + ); + + // Act: Convert + const result = await convertClaudePluginPackage( + pluginSourceDir, + 'crlf-agents-plugin', + ); + + // Verify: agent file was properly parsed and converted into .qwen/agents folder structure + const convertedAgentsDir = path.join(result.convertedDir, 'agents'); + expect(fs.existsSync(convertedAgentsDir)).toBe(true); + + const convertedFiles = fs.readdirSync(convertedAgentsDir); + expect(convertedFiles).toContain('agent.md'); // The filename is preserved from source + + // Verify it was actually parsed by checking the converted content format + const convertedContent = fs.readFileSync( + path.join(convertedAgentsDir, 'agent.md'), + 'utf-8', + ); + expect(convertedContent).toContain('name: cool-agent'); + + // Clean up + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); }); diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 0829c1635..98639b197 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -24,6 +24,7 @@ import { stringify as stringifyYaml, } from '../utils/yaml-parser.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { normalizeContent } from '../utils/textUtils.js'; const debugLogger = createDebugLogger('CLAUDE_CONVERTER'); @@ -226,10 +227,11 @@ async function convertAgentFiles(agentsDir: string): Promise { try { const content = await fs.promises.readFile(filePath, 'utf-8'); + const normalizedContent = normalizeContent(content); // Parse frontmatter const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; - const match = content.match(frontmatterRegex); + const match = normalizedContent.match(frontmatterRegex); if (!match) { // No frontmatter, skip this file diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 5ef49d35b..4fe830e45 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -172,6 +172,7 @@ export async function checkForExtensionUpdate( } if ( !installMetadata || + installMetadata.originSource === 'Claude' || (installMetadata.type !== 'git' && installMetadata.type !== 'github-release') ) { diff --git a/packages/core/src/hooks/hookAggregator.test.ts b/packages/core/src/hooks/hookAggregator.test.ts new file mode 100644 index 000000000..129713b66 --- /dev/null +++ b/packages/core/src/hooks/hookAggregator.test.ts @@ -0,0 +1,618 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { HookAggregator } from './hookAggregator.js'; +import { HookEventName, HookType, createHookOutput } from './types.js'; +import type { + HookExecutionResult, + HookOutput, + PermissionRequestHookOutput, +} from './types.js'; + +describe('HookAggregator', () => { + const aggregator = new HookAggregator(); + + describe('aggregateResults', () => { + it('should return undefined finalOutput when no results', () => { + const result = aggregator.aggregateResults([], HookEventName.PreToolUse); + expect(result.success).toBe(true); + expect(result.finalOutput).toBeUndefined(); + expect(result.allOutputs).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it('should aggregate successful results', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output: { continue: true }, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.success).toBe(true); + expect(result.finalOutput).toBeDefined(); + }); + + it('should set success false when there are errors', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: false, + error: new Error('Hook failed'), + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + }); + + it('should calculate total duration', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo 1' }, + eventName: HookEventName.PreToolUse, + success: true, + duration: 100, + }, + { + hookConfig: { type: HookType.Command, command: 'echo 2' }, + eventName: HookEventName.PreToolUse, + success: true, + duration: 200, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.totalDuration).toBe(300); + }); + }); + + describe('mergeWithOrLogic - PreToolUse', () => { + it('should concatenate reasons', () => { + const outputs: HookOutput[] = [ + { reason: 'first reason', decision: 'allow' }, + { reason: 'second reason', decision: 'allow' }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.reason).toBe('first reason\nsecond reason'); + }); + + it('should block when any hook blocks', () => { + const outputs: HookOutput[] = [ + { reason: 'allowed', decision: 'allow' }, + { reason: 'blocked', decision: 'block' }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.decision).toBe('block'); + }); + + it('should use last stopReason', () => { + const outputs: HookOutput[] = [ + { continue: false, stopReason: 'first stop' }, + { continue: false, stopReason: 'second stop' }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Stop, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults(results, HookEventName.Stop); + expect(result.finalOutput?.stopReason).toBe('second stop'); + }); + + it('should concatenate additionalContext', () => { + const outputs: HookOutput[] = [ + { hookSpecificOutput: { additionalContext: 'context 1' } }, + { hookSpecificOutput: { additionalContext: 'context 2' } }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect( + result.finalOutput?.hookSpecificOutput?.['additionalContext'], + ).toBe('context 1\ncontext 2'); + }); + + it('should preserve other hookSpecificOutput fields', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + additionalContext: 'ctx', + tailToolCallRequest: { name: 'A' }, + }, + }, + { hookSpecificOutput: { additionalContext: 'ctx2' } }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PostToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PostToolUse, + ); + expect( + result.finalOutput?.hookSpecificOutput?.['tailToolCallRequest'], + ).toEqual({ name: 'A' }); + expect( + result.finalOutput?.hookSpecificOutput?.['additionalContext'], + ).toBe('ctx\nctx2'); + }); + }); + + describe('mergePermissionRequestOutputs', () => { + it('should prioritize deny over allow', () => { + const outputs: HookOutput[] = [ + { hookSpecificOutput: { decision: { behavior: 'allow' } } }, + { hookSpecificOutput: { decision: { behavior: 'deny' } } }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + // Use accessor to verify - this ensures output is consumable by PermissionRequestHookOutput + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.isPermissionDenied()).toBe(true); + }); + + it('should concatenate messages', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { message: 'msg1', behavior: 'allow' }, + }, + }, + { + hookSpecificOutput: { + decision: { message: 'msg2', behavior: 'allow' }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.getDenyMessage()).toBe('msg1\nmsg2'); + }); + + it('should use last updatedInput', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { updatedInput: { arg: '1' }, behavior: 'allow' }, + }, + }, + { + hookSpecificOutput: { + decision: { updatedInput: { arg: '2' }, behavior: 'allow' }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.getUpdatedToolInput()).toEqual({ arg: '2' }); + }); + + it('should concatenate updatedPermissions', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { + updatedPermissions: [{ type: 'read' }], + behavior: 'allow', + }, + }, + }, + { + hookSpecificOutput: { + decision: { + updatedPermissions: [{ type: 'write' }], + behavior: 'allow', + }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.getUpdatedPermissions()).toEqual([ + { type: 'read' }, + { type: 'write' }, + ]); + }); + + it('should set interrupt true if any hook sets it', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { behavior: 'deny', interrupt: false }, + }, + }, + { + hookSpecificOutput: { + decision: { behavior: 'deny', interrupt: true }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + expect(hookOutput.shouldInterrupt()).toBe(true); + }); + + it('should produce output consumable by PermissionRequestHookOutput accessors', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + decision: { + behavior: 'allow', + message: 'first msg', + updatedInput: { arg: '1' }, + }, + }, + }, + { + hookSpecificOutput: { + decision: { + behavior: 'deny', + message: 'second msg', + updatedInput: { arg: '2' }, + }, + }, + }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + + // Verify the output can be consumed by PermissionRequestHookOutput accessors + const hookOutput = createHookOutput( + HookEventName.PermissionRequest, + result.finalOutput ?? {}, + ) as PermissionRequestHookOutput; + + expect(hookOutput.isPermissionDenied()).toBe(true); + expect(hookOutput.getUpdatedToolInput()).toEqual({ arg: '2' }); + expect(hookOutput.getDenyMessage()).toBe('first msg\nsecond msg'); + }); + }); + + describe('mergeSimple (default case)', () => { + it('should use later values for simple fields', () => { + const outputs: HookOutput[] = [ + { reason: 'first', continue: true }, + { reason: 'second', continue: false }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Notification, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.Notification, + ); + expect(result.finalOutput?.reason).toBe('second'); + expect(result.finalOutput?.continue).toBe(false); + }); + + it('should concatenate additionalContext from multiple hooks', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + additionalContext: 'ctx1', + otherField: 'value1', + }, + }, + { hookSpecificOutput: { additionalContext: 'ctx2' } }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Notification, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.Notification, + ); + // mergeSimple concatenates additionalContext with newlines + expect( + result.finalOutput?.hookSpecificOutput?.['additionalContext'], + ).toBe('ctx1\nctx2'); + // otherField is overwritten (later value wins since it's not special-cased) + expect( + result.finalOutput?.hookSpecificOutput?.['otherField'], + ).toBeUndefined(); + }); + }); + + describe('createSpecificHookOutput', () => { + it('should create PreToolUseHookOutput for PreToolUse', () => { + const output: HookOutput = { continue: true }; + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + // The finalOutput should be an instance of PreToolUseHookOutput + expect(result.finalOutput).toBeDefined(); + expect((result.finalOutput as { continue?: boolean }).continue).toBe( + true, + ); + }); + + it('should create StopHookOutput for Stop', () => { + const output: HookOutput = { stopReason: 'test' }; + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Stop, + success: true, + output, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults(results, HookEventName.Stop); + expect(result.finalOutput).toBeDefined(); + expect((result.finalOutput as { stopReason?: string }).stopReason).toBe( + 'test', + ); + }); + + it('should create PermissionRequestHookOutput for PermissionRequest', () => { + const output: HookOutput = { + hookSpecificOutput: { decision: { behavior: 'allow' } }, + }; + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PermissionRequest, + success: true, + output, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + expect(result.finalOutput).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('should handle empty outputs array', () => { + const results: HookExecutionResult[] = []; + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput).toBeUndefined(); + }); + + it('should handle single output', () => { + const output: HookOutput = { decision: 'allow', reason: 'single' }; + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.decision).toBe('allow'); + expect(result.finalOutput?.reason).toBe('single'); + }); + + it('should handle outputs without hookSpecificOutput', () => { + const outputs: HookOutput[] = [{ decision: 'allow' }, { reason: 'test' }]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.decision).toBe('allow'); + expect(result.finalOutput?.reason).toBe('test'); + }); + + it('should handle decision allow when no block', () => { + const outputs: HookOutput[] = [ + { decision: 'allow' }, + { decision: 'allow' }, + ]; + + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.decision).toBe('allow'); + }); + }); +}); diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts new file mode 100644 index 000000000..ea7cf2090 --- /dev/null +++ b/packages/core/src/hooks/hookAggregator.ts @@ -0,0 +1,369 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HookEventName, + DefaultHookOutput, + PreToolUseHookOutput, + StopHookOutput, + PermissionRequestHookOutput, +} from './types.js'; +import type { HookOutput, HookExecutionResult } from './types.js'; + +/** + * Aggregated result from multiple hook executions + */ +export interface AggregatedHookResult { + success: boolean; + allOutputs: HookOutput[]; + errors: Error[]; + totalDuration: number; + finalOutput?: HookOutput; +} + +/** + * HookAggregator merges multiple hook outputs using event-specific rules. + * + * Different events have different merging strategies: + * - PreToolUse/PostToolUse: OR logic for decisions, concatenation for messages + */ +export class HookAggregator { + /** + * Aggregate results from multiple hook executions + */ + aggregateResults( + results: HookExecutionResult[], + eventName: HookEventName, + ): AggregatedHookResult { + const allOutputs: HookOutput[] = []; + const errors: Error[] = []; + let totalDuration = 0; + + for (const result of results) { + totalDuration += result.duration; + + if (!result.success && result.error) { + errors.push(result.error); + } + + if (result.output) { + allOutputs.push(result.output); + } + } + + const success = errors.length === 0; + const finalOutput = this.mergeOutputs(allOutputs, eventName); + + return { + success, + allOutputs, + errors, + totalDuration, + finalOutput, + }; + } + + /** + * Merge multiple hook outputs based on event type + */ + private mergeOutputs( + outputs: HookOutput[], + eventName: HookEventName, + ): HookOutput | undefined { + if (outputs.length === 0) { + return undefined; + } + + if (outputs.length === 1) { + return this.createSpecificHookOutput(outputs[0], eventName); + } + + let merged: HookOutput; + + switch (eventName) { + case HookEventName.PreToolUse: + case HookEventName.PostToolUse: + case HookEventName.PostToolUseFailure: + case HookEventName.Stop: + case HookEventName.UserPromptSubmit: + merged = this.mergeWithOrLogic(outputs); + break; + case HookEventName.PermissionRequest: + merged = this.mergePermissionRequestOutputs(outputs); + break; + default: + merged = this.mergeSimple(outputs); + } + + return this.createSpecificHookOutput(merged, eventName); + } + + /** + * Merge outputs using OR logic for decisions and concatenation for messages. + * + * Rules: + * - Any "block" or "deny" decision results in blocking (most restrictive wins) + * - Reasons are concatenated with newlines + * - continue=false takes precedence over continue=true + * - Additional context is concatenated + */ + private mergeWithOrLogic(outputs: HookOutput[]): HookOutput { + const merged: HookOutput = {}; + const reasons: string[] = []; + const additionalContexts: string[] = []; + let hasBlock = false; + let hasContinueFalse = false; + let stopReason: string | undefined; + const otherHookSpecificFields: Record = {}; + + for (const output of outputs) { + // Check for blocking decisions + if (output.decision === 'block' || output.decision === 'deny') { + hasBlock = true; + } + + // Collect reasons + if (output.reason) { + reasons.push(output.reason); + } + + // Check continue flag + if (output.continue === false) { + hasContinueFalse = true; + if (output.stopReason) { + stopReason = output.stopReason; + } + } + + // Extract additional context + this.extractAdditionalContext(output, additionalContexts); + + // Collect other hookSpecificOutput fields (later values win) + if (output.hookSpecificOutput) { + for (const [key, value] of Object.entries(output.hookSpecificOutput)) { + if (key !== 'additionalContext') { + otherHookSpecificFields[key] = value; + } + } + } + + // Copy other fields (later values win for simple fields) + if (output.suppressOutput !== undefined) { + merged.suppressOutput = output.suppressOutput; + } + if (output.systemMessage !== undefined) { + merged.systemMessage = output.systemMessage; + } + } + + // Set merged decision + if (hasBlock) { + merged.decision = 'block'; + } else if (outputs.some((o) => o.decision === 'allow')) { + merged.decision = 'allow'; + } + + // Set merged reason + if (reasons.length > 0) { + merged.reason = reasons.join('\n'); + } + + // Set continue flag + if (hasContinueFalse) { + merged.continue = false; + if (stopReason) { + merged.stopReason = stopReason; + } + } + + // Build hookSpecificOutput + const hookSpecificOutput: Record = { + ...otherHookSpecificFields, + }; + if (additionalContexts.length > 0) { + hookSpecificOutput['additionalContext'] = additionalContexts.join('\n'); + } + + if (Object.keys(hookSpecificOutput).length > 0) { + merged.hookSpecificOutput = hookSpecificOutput; + } + + return merged; + } + + /** + * Merge outputs for mergePermissionRequestOutputs events. + * + * Rules: + * - behavior: deny wins over allow (security priority) + * - message: concatenated with newlines + * - updatedInput: later values win + * - updatedPermissions: concatenated + * - interrupt: true wins over false + */ + private mergePermissionRequestOutputs(outputs: HookOutput[]): HookOutput { + const merged: HookOutput = {}; + const messages: string[] = []; + let hasDeny = false; + let hasAllow = false; + let interrupt = false; + let updatedInput: Record | undefined; + const allUpdatedPermissions: Array<{ type: string; tool?: string }> = []; + + for (const output of outputs) { + const specific = output.hookSpecificOutput; + if (!specific) continue; + + const decision = specific['decision'] as + | { + behavior?: string; + message?: string; + updatedInput?: Record; + updatedPermissions?: Array<{ type: string; tool?: string }>; + interrupt?: boolean; + } + | undefined; + + if (!decision) continue; + + // Check behavior + if (decision['behavior'] === 'deny') { + hasDeny = true; + } else if (decision['behavior'] === 'allow') { + hasAllow = true; + } + + // Collect message + if (decision['message']) { + messages.push(decision['message'] as string); + } + + // Check interrupt - true wins + if (decision['interrupt'] === true) { + interrupt = true; + } + + // Collect updatedInput - use last non-empty + if (decision['updatedInput']) { + updatedInput = decision['updatedInput'] as Record; + } + + // Collect updatedPermissions + if (decision['updatedPermissions']) { + allUpdatedPermissions.push( + ...(decision['updatedPermissions'] as Array<{ + type: string; + tool?: string; + }>), + ); + } + + // Copy other fields + if (output.continue !== undefined) { + merged.continue = output.continue; + } + if (output.reason !== undefined) { + merged.reason = output.reason; + } + } + + // Build merged decision + const mergedDecision: Record = {}; + + if (hasDeny) { + mergedDecision['behavior'] = 'deny'; + } else if (hasAllow) { + mergedDecision['behavior'] = 'allow'; + } + + if (messages.length > 0) { + mergedDecision['message'] = messages.join('\n'); + } + + if (interrupt) { + mergedDecision['interrupt'] = true; + } + + if (updatedInput) { + mergedDecision['updatedInput'] = updatedInput; + } + + if (allUpdatedPermissions.length > 0) { + mergedDecision['updatedPermissions'] = allUpdatedPermissions; + } + + merged.hookSpecificOutput = { + ...merged.hookSpecificOutput, + decision: mergedDecision, + }; + + return merged; + } + + /** + * Simple merge for events without special logic + */ + private mergeSimple(outputs: HookOutput[]): HookOutput { + const additionalContexts: string[] = []; + let merged: HookOutput = {}; + + for (const output of outputs) { + // Collect additionalContext for concatenation + this.extractAdditionalContext(output, additionalContexts); + merged = { ...merged, ...output }; + } + + // Merge additionalContext with concatenation + if (additionalContexts.length > 0) { + merged.hookSpecificOutput = { + ...merged.hookSpecificOutput, + additionalContext: additionalContexts.join('\n'), + }; + } + + return merged; + } + + /** + * Create the appropriate specific hook output class based on event type + */ + private createSpecificHookOutput( + output: HookOutput, + eventName: HookEventName, + ): DefaultHookOutput { + switch (eventName) { + case HookEventName.PreToolUse: + return new PreToolUseHookOutput(output); + case HookEventName.Stop: + return new StopHookOutput(output); + case HookEventName.PermissionRequest: + return new PermissionRequestHookOutput(output); + default: + return new DefaultHookOutput(output); + } + } + + /** + * Extract additional context from hook-specific outputs + */ + private extractAdditionalContext( + output: HookOutput, + contexts: string[], + ): void { + const specific = output.hookSpecificOutput; + if (!specific) { + return; + } + + // Extract additionalContext from various hook types + if ( + 'additionalContext' in specific && + typeof specific['additionalContext'] === 'string' + ) { + contexts.push(specific['additionalContext']); + } + } +} diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts new file mode 100644 index 000000000..f556a8c30 --- /dev/null +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -0,0 +1,278 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { HookEventHandler } from './hookEventHandler.js'; +import { HookEventName, HookType, HooksConfigSource } from './types.js'; +import type { Config } from '../config/config.js'; +import type { + HookPlanner, + HookRunner, + HookAggregator, + AggregatedHookResult, +} from './index.js'; +import type { HookConfig, HookOutput } from './types.js'; + +describe('HookEventHandler', () => { + let mockConfig: Config; + let mockHookPlanner: HookPlanner; + let mockHookRunner: HookRunner; + let mockHookAggregator: HookAggregator; + let hookEventHandler: HookEventHandler; + + beforeEach(() => { + mockConfig = { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'), + getWorkingDir: vi.fn().mockReturnValue('/test/cwd'), + } as unknown as Config; + + mockHookPlanner = { + createExecutionPlan: vi.fn(), + } as unknown as HookPlanner; + + mockHookRunner = { + executeHooksSequential: vi.fn(), + executeHooksParallel: vi.fn(), + } as unknown as HookRunner; + + mockHookAggregator = { + aggregateResults: vi.fn(), + } as unknown as HookAggregator; + + hookEventHandler = new HookEventHandler( + mockConfig, + mockHookPlanner, + mockHookRunner, + mockHookAggregator, + ); + }); + + const createMockExecutionPlan = ( + hookConfigs: HookConfig[] = [], + sequential: boolean = false, + ) => ({ + hookConfigs, + sequential, + eventName: HookEventName.PreToolUse, + }); + + const createMockAggregatedResult = ( + success: boolean = true, + finalOutput?: HookOutput, + ): AggregatedHookResult => ({ + success, + allOutputs: [], + errors: [], + totalDuration: 100, + finalOutput, + }); + + describe('fireUserPromptSubmitEvent', () => { + it('should execute hooks for UserPromptSubmit event', async () => { + const mockPlan = createMockExecutionPlan([]); + const mockAggregated = createMockAggregatedResult(true); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + mockAggregated, + ); + + const result = + await hookEventHandler.fireUserPromptSubmitEvent('test prompt'); + + expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith( + HookEventName.UserPromptSubmit, + undefined, + ); + expect(result.success).toBe(true); + }); + + it('should include prompt in the hook input', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireUserPromptSubmitEvent('my test prompt'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { prompt: string }; + expect(input.prompt).toBe('my test prompt'); + }); + }); + + describe('fireStopEvent', () => { + it('should execute hooks for Stop event', async () => { + const mockPlan = createMockExecutionPlan([]); + const mockAggregated = createMockAggregatedResult(true); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + mockAggregated, + ); + + const result = await hookEventHandler.fireStopEvent(true, 'last message'); + + expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith( + HookEventName.Stop, + undefined, + ); + expect(result.success).toBe(true); + }); + + it('should include stop parameters in hook input', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireStopEvent(true, 'last assistant message'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { + stop_hook_active: boolean; + last_assistant_message: string; + }; + expect(input.stop_hook_active).toBe(true); + expect(input.last_assistant_message).toBe('last assistant message'); + }); + + it('should handle continue=false in final output', async () => { + const mockPlan = createMockExecutionPlan([]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true, { + continue: false, + stopReason: 'test stop', + }), + ); + + await hookEventHandler.fireStopEvent(); + + expect(true).toBe(true); + }); + + it('should handle missing finalOutput gracefully', async () => { + const mockPlan = createMockExecutionPlan([]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true, undefined), + ); + + const result = await hookEventHandler.fireStopEvent(); + + expect(result.success).toBe(true); + expect(result.finalOutput).toBeUndefined(); + }); + }); + + describe('sequential vs parallel execution', () => { + it('should execute hooks sequentially when plan.sequential is true', async () => { + const mockPlan = createMockExecutionPlan( + [ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ], + true, + ); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksSequential).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireUserPromptSubmitEvent('test'); + + expect(mockHookRunner.executeHooksSequential).toHaveBeenCalled(); + expect(mockHookRunner.executeHooksParallel).not.toHaveBeenCalled(); + }); + + it('should execute hooks in parallel when plan.sequential is false', async () => { + const mockPlan = createMockExecutionPlan( + [ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ], + false, + ); + + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireUserPromptSubmitEvent('test'); + + expect(mockHookRunner.executeHooksParallel).toHaveBeenCalled(); + expect(mockHookRunner.executeHooksSequential).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should return error result when hook execution throws', async () => { + vi.mocked(mockHookPlanner.createExecutionPlan).mockImplementation(() => { + throw new Error('Planner error'); + }); + + const result = await hookEventHandler.fireUserPromptSubmitEvent('test'); + + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Planner error'); + }); + + it('should return error result when hook runner throws', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockRejectedValue( + new Error('Runner error'), + ); + + const result = await hookEventHandler.fireUserPromptSubmitEvent('test'); + + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Runner error'); + }); + }); +}); diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts new file mode 100644 index 000000000..2fd5f2892 --- /dev/null +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import type { HookPlanner, HookEventContext } from './hookPlanner.js'; +import type { HookRunner } from './hookRunner.js'; +import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js'; +import { HookEventName } from './types.js'; +import type { + HookConfig, + HookInput, + HookExecutionResult, + UserPromptSubmitInput, + StopInput, +} from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +/** + * Hook event bus that coordinates hook execution across the system + */ +export class HookEventHandler { + private readonly config: Config; + private readonly hookPlanner: HookPlanner; + private readonly hookRunner: HookRunner; + private readonly hookAggregator: HookAggregator; + + constructor( + config: Config, + hookPlanner: HookPlanner, + hookRunner: HookRunner, + hookAggregator: HookAggregator, + ) { + this.config = config; + this.hookPlanner = hookPlanner; + this.hookRunner = hookRunner; + this.hookAggregator = hookAggregator; + } + + /** + * Fire a UserPromptSubmit event + * Called by handleHookExecutionRequest - executes hooks directly + */ + async fireUserPromptSubmitEvent( + prompt: string, + ): Promise { + const input: UserPromptSubmitInput = { + ...this.createBaseInput(HookEventName.UserPromptSubmit), + prompt, + }; + + return this.executeHooks(HookEventName.UserPromptSubmit, input); + } + + /** + * Fire a Stop event + * Called by handleHookExecutionRequest - executes hooks directly + */ + async fireStopEvent( + stopHookActive: boolean = false, + lastAssistantMessage: string = '', + ): Promise { + const input: StopInput = { + ...this.createBaseInput(HookEventName.Stop), + stop_hook_active: stopHookActive, + last_assistant_message: lastAssistantMessage, + }; + + return this.executeHooks(HookEventName.Stop, input); + } + + /** + * Execute hooks for a specific event (direct execution without MessageBus) + * Used as fallback when MessageBus is not available + */ + private async executeHooks( + eventName: HookEventName, + input: HookInput, + context?: HookEventContext, + ): Promise { + try { + // Create execution plan + const plan = this.hookPlanner.createExecutionPlan(eventName, context); + + if (!plan || plan.hookConfigs.length === 0) { + return { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + }; + } + + const onHookStart = (_config: HookConfig, _index: number) => { + // Hook start event (telemetry removed) + }; + + const onHookEnd = (_config: HookConfig, _result: HookExecutionResult) => { + // Hook end event (telemetry removed) + }; + + // Execute hooks according to the plan's strategy + const results = plan.sequential + ? await this.hookRunner.executeHooksSequential( + plan.hookConfigs, + eventName, + input, + onHookStart, + onHookEnd, + ) + : await this.hookRunner.executeHooksParallel( + plan.hookConfigs, + eventName, + input, + onHookStart, + onHookEnd, + ); + + // Aggregate results + const aggregated = this.hookAggregator.aggregateResults( + results, + eventName, + ); + + // Process common hook output fields centrally + this.processCommonHookOutputFields(aggregated); + + return aggregated; + } catch (error) { + debugLogger.error(`Hook event bus error for ${eventName}: ${error}`); + + return { + success: false, + allOutputs: [], + errors: [error instanceof Error ? error : new Error(String(error))], + totalDuration: 0, + }; + } + } + + /** + * Create base hook input with common fields + */ + private createBaseInput(eventName: HookEventName): HookInput { + // Get the transcript path from the Config + const transcriptPath = this.config.getTranscriptPath(); + + return { + session_id: this.config.getSessionId(), + transcript_path: transcriptPath, + cwd: this.config.getWorkingDir(), + hook_event_name: eventName, + timestamp: new Date().toISOString(), + }; + } + + /** + * Process common hook output fields centrally + */ + private processCommonHookOutputFields( + aggregated: AggregatedHookResult, + ): void { + if (!aggregated.finalOutput) { + return; + } + + // Handle systemMessage - show to user in transcript mode (not to agent) + const systemMessage = aggregated.finalOutput.systemMessage; + if (systemMessage && !aggregated.finalOutput.suppressOutput) { + debugLogger.warn(`Hook system message: ${systemMessage}`); + } + + // Handle suppressOutput - already handled by not logging above when true + + // Handle continue=false - this should stop the entire agent execution + if (aggregated.finalOutput.continue === false) { + const stopReason = + aggregated.finalOutput.stopReason || + aggregated.finalOutput.reason || + 'No reason provided'; + debugLogger.debug(`Hook requested to stop execution: ${stopReason}`); + + // Note: The actual stopping of execution must be handled by integration points + // as they need to interpret this signal in the context of their specific workflow + // This is just logging the request centrally + } + } +} diff --git a/packages/core/src/hooks/hookPlanner.test.ts b/packages/core/src/hooks/hookPlanner.test.ts new file mode 100644 index 000000000..e3bb99076 --- /dev/null +++ b/packages/core/src/hooks/hookPlanner.test.ts @@ -0,0 +1,366 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js'; +import { HookPlanner } from './hookPlanner.js'; +import { HookEventName, HookType, HooksConfigSource } from './types.js'; + +describe('HookPlanner', () => { + let mockRegistry: HookRegistry; + let planner: HookPlanner; + + beforeEach(() => { + mockRegistry = { + getHooksForEvent: vi.fn(), + } as unknown as HookRegistry; + planner = new HookPlanner(mockRegistry); + }); + + describe('createExecutionPlan', () => { + it('should return null when no hooks for event', () => { + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result).toBeNull(); + }); + + it('should return null when no hooks match context', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'glob', + }); + + expect(result).toBeNull(); + }); + + it('should create plan with matching hooks', () => { + const entry: HookRegistryEntry = { + config: { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result).not.toBeNull(); + expect(result!.eventName).toBe(HookEventName.PreToolUse); + expect(result!.hookConfigs).toHaveLength(1); + expect(result!.sequential).toBe(false); + }); + + it('should set sequential to true when any hook has sequential=true', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + sequential: true, + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result!.sequential).toBe(true); + }); + + it('should deduplicate hooks with same config', () => { + const config = { type: HookType.Command, command: 'echo test' }; + const entry1: HookRegistryEntry = { + config, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }; + const entry2: HookRegistryEntry = { + config, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([ + entry1, + entry2, + ]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result!.hookConfigs).toHaveLength(1); + }); + }); + + describe('matchesContext', () => { + it('should match all when no matcher', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should match all when no context', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse); + + expect(result).not.toBeNull(); + }); + + it('should match empty string as wildcard', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should match asterisk as wildcard', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '*', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should match tool name with exact string', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should not match tool name with different exact string', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'glob', + }); + + expect(result).toBeNull(); + }); + + it('should match tool name with regex', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '^bash.*', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should match tool name with regex wildcard', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '.*', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'any-tool', + }); + + expect(result).not.toBeNull(); + }); + + it('should match trigger with exact string', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.SessionStart, + matcher: 'user', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.SessionStart, { + trigger: 'user', + }); + + expect(result).not.toBeNull(); + }); + + it('should not match trigger with different string', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.SessionStart, + matcher: 'user', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.SessionStart, { + trigger: 'api', + }); + + expect(result).toBeNull(); + }); + + it('should match when context has both toolName and trigger (prefers toolName)', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: 'bash', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + trigger: 'api', + }); + + expect(result).not.toBeNull(); + }); + + it('should match with trimmed matcher', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: ' bash ', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).not.toBeNull(); + }); + + it('should fallback to exact match when regex is invalid', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '[invalid(regex', // Invalid regex + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + // Should fallback to exact match - should NOT match 'bash' + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).toBeNull(); + }); + + it('should match using fallback exact match when regex is invalid', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '[invalid(regex', // Invalid regex + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + // Should fallback to exact match - should match '[invalid(regex' + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: '[invalid(regex', + }); + + expect(result).not.toBeNull(); + }); + + it('should handle complex invalid regex gracefully', () => { + const entry: HookRegistryEntry = { + config: { type: HookType.Command, command: 'echo test' }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + matcher: '(unclosed', + enabled: true, + }; + vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]); + + const result = planner.createExecutionPlan(HookEventName.PreToolUse, { + toolName: 'bash', + }); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/core/src/hooks/hookPlanner.ts b/packages/core/src/hooks/hookPlanner.ts new file mode 100644 index 000000000..3eef01543 --- /dev/null +++ b/packages/core/src/hooks/hookPlanner.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js'; +import type { HookExecutionPlan } from './types.js'; +import { getHookKey, type HookEventName } from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +/** + * Hook planner that selects matching hooks and creates execution plans + */ +export class HookPlanner { + private readonly hookRegistry: HookRegistry; + + constructor(hookRegistry: HookRegistry) { + this.hookRegistry = hookRegistry; + } + + /** + * Create execution plan for a hook event + */ + createExecutionPlan( + eventName: HookEventName, + context?: HookEventContext, + ): HookExecutionPlan | null { + const hookEntries = this.hookRegistry.getHooksForEvent(eventName); + + if (hookEntries.length === 0) { + return null; + } + + // Filter hooks by matcher + const matchingEntries = hookEntries.filter((entry) => + this.matchesContext(entry, context), + ); + + if (matchingEntries.length === 0) { + return null; + } + + // Deduplicate identical hooks + const deduplicatedEntries = this.deduplicateHooks(matchingEntries); + + // Extract hook configs + const hookConfigs = deduplicatedEntries.map((entry) => entry.config); + + // Determine execution strategy - if ANY hook definition has sequential=true, run all sequentially + const sequential = deduplicatedEntries.some( + (entry) => entry.sequential === true, + ); + + const plan: HookExecutionPlan = { + eventName, + hookConfigs, + sequential, + }; + + return plan; + } + + /** + * Check if a hook entry matches the given context + */ + private matchesContext( + entry: HookRegistryEntry, + context?: HookEventContext, + ): boolean { + if (!entry.matcher || !context) { + return true; // No matcher means match all + } + + const matcher = entry.matcher.trim(); + + if (matcher === '' || matcher === '*') { + return true; // Empty string or wildcard matches all + } + + // For tool events, match against tool name + if (context.toolName) { + return this.matchesToolName(matcher, context.toolName); + } + + // For other events, match against trigger/source + if (context.trigger) { + return this.matchesTrigger(matcher, context.trigger); + } + + return true; + } + + /** + * Match tool name against matcher pattern + */ + private matchesToolName(matcher: string, toolName: string): boolean { + try { + // Attempt to treat the matcher as a regular expression. + const regex = new RegExp(matcher); + return regex.test(toolName); + } catch (error) { + // If it's not a valid regex, treat it as a literal string for an exact match. + debugLogger.warn( + `Invalid regex in hook matcher "${matcher}" for tool "${toolName}", falling back to exact match: ${error}`, + ); + return matcher === toolName; + } + } + + /** + * Match trigger/source against matcher pattern + */ + private matchesTrigger(matcher: string, trigger: string): boolean { + return matcher === trigger; + } + + /** + * Deduplicate identical hook configurations + */ + private deduplicateHooks(entries: HookRegistryEntry[]): HookRegistryEntry[] { + const seen = new Set(); + const deduplicated: HookRegistryEntry[] = []; + + for (const entry of entries) { + const key = getHookKey(entry.config); + + if (!seen.has(key)) { + seen.add(key); + deduplicated.push(entry); + } + } + + return deduplicated; + } +} + +/** + * Context information for hook event matching + */ +export interface HookEventContext { + toolName?: string; + trigger?: string; +} diff --git a/packages/core/src/hooks/hookRegistry.test.ts b/packages/core/src/hooks/hookRegistry.test.ts new file mode 100644 index 000000000..a9e79f5fa --- /dev/null +++ b/packages/core/src/hooks/hookRegistry.test.ts @@ -0,0 +1,636 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { HookRegistryConfig, FeedbackEmitter } from './hookRegistry.js'; +import { HookRegistry } from './hookRegistry.js'; +import { HookEventName, HooksConfigSource, HookType } from './types.js'; +import type { HookConfig } from './types.js'; + +// Mock TrustedHooksManager +vi.mock('./trustedHooks.js', () => ({ + TrustedHooksManager: vi.fn().mockImplementation(() => ({ + getUntrustedHooks: vi.fn().mockReturnValue([]), + trustHooks: vi.fn(), + })), +})); + +describe('HookRegistry', () => { + let mockConfig: HookRegistryConfig; + let mockFeedbackEmitter: FeedbackEmitter; + + beforeEach(() => { + mockConfig = { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + isTrustedFolder: vi.fn().mockReturnValue(true), + getHooks: vi.fn().mockReturnValue(undefined), + getProjectHooks: vi.fn().mockReturnValue(undefined), + getDisabledHooks: vi.fn().mockReturnValue([]), + getExtensions: vi.fn().mockReturnValue([]), + }; + mockFeedbackEmitter = { + emitFeedback: vi.fn(), + }; + vi.clearAllMocks(); + }); + + describe('initialize', () => { + it('should initialize with empty hooks when no config provided', async () => { + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should process project hooks from config', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const allHooks = registry.getAllHooks(); + expect(allHooks).toHaveLength(1); + expect(allHooks[0].eventName).toBe(HookEventName.PreToolUse); + expect(allHooks[0].source).toBe(HooksConfigSource.Project); + }); + + it('should not process project hooks in untrusted folder', async () => { + mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false); + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [{ type: HookType.Command, command: 'echo test' }], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + }); + + describe('getHooksForEvent', () => { + it('should return hooks for specific event', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { type: HookType.Command, command: 'echo pre', name: 'pre-hook' }, + ], + }, + ], + [HookEventName.PostToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo post', + name: 'post-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const preHooks = registry.getHooksForEvent(HookEventName.PreToolUse); + expect(preHooks).toHaveLength(1); + expect(preHooks[0].config.name).toBe('pre-hook'); + + const postHooks = registry.getHooksForEvent(HookEventName.PostToolUse); + expect(postHooks).toHaveLength(1); + expect(postHooks[0].config.name).toBe('post-hook'); + }); + + it('should filter out disabled hooks', async () => { + mockConfig.getDisabledHooks = vi.fn().mockReturnValue(['disabled-hook']); + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo enabled', + name: 'enabled-hook', + }, + { + type: HookType.Command, + command: 'echo disabled', + name: 'disabled-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); + expect(hooks).toHaveLength(1); + expect(hooks[0].config.name).toBe('enabled-hook'); + }); + + it('should sort hooks by source priority', async () => { + // This test requires multiple sources, which would need getUserHooks + // For now, we test with extensions which are processed after project hooks + const projectHooks = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo project', + name: 'project-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(projectHooks); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); + expect(hooks).toHaveLength(1); + expect(hooks[0].source).toBe(HooksConfigSource.Project); + }); + }); + + describe('setHookEnabled', () => { + it('should enable a disabled hook', async () => { + mockConfig.getDisabledHooks = vi.fn().mockReturnValue(['test-hook']); + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 0, + ); + + registry.setHookEnabled('test-hook', true); + + const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); + expect(hooks).toHaveLength(1); + expect(hooks[0].enabled).toBe(true); + }); + + it('should disable an enabled hook', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 1, + ); + + registry.setHookEnabled('test-hook', false); + + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 0, + ); + }); + + it('should update all hooks with matching name', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { type: HookType.Command, command: 'echo 1', name: 'same-name' }, + ], + }, + ], + [HookEventName.PostToolUse]: [ + { + hooks: [ + { type: HookType.Command, command: 'echo 2', name: 'same-name' }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(2); + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 1, + ); + expect(registry.getHooksForEvent(HookEventName.PostToolUse)).toHaveLength( + 1, + ); + + registry.setHookEnabled('same-name', false); + + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 0, + ); + expect(registry.getHooksForEvent(HookEventName.PostToolUse)).toHaveLength( + 0, + ); + }); + }); + + describe('hook validation', () => { + it('should discard hooks with invalid type', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: 'invalid-type', + command: 'echo test', + } as unknown as HookConfig, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should discard command hooks without command field', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [{ type: HookType.Command } as HookConfig], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should skip invalid event names', async () => { + const hooksConfig = { + InvalidEventName: [ + { + hooks: [{ type: HookType.Command, command: 'echo test' }], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig, mockFeedbackEmitter); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + expect(mockFeedbackEmitter.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('Invalid hook event name'), + ); + }); + + it('should skip hooks config fields like enabled and disabled', async () => { + const hooksConfig = { + enabled: ['hook1'], + disabled: ['hook2'], + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'valid-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(1); + expect(registry.getAllHooks()[0].config.name).toBe('valid-hook'); + }); + }); + + describe('duplicate detection', () => { + it('should skip duplicate hooks with same name+source+event+matcher+sequential', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + matcher: '*.ts', + sequential: true, + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'dup-hook', + }, + { + type: HookType.Command, + command: 'echo test', + name: 'dup-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(1); + }); + + it('should allow hooks with same name but different matcher', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + matcher: '*.ts', + hooks: [ + { type: HookType.Command, command: 'echo ts', name: 'my-hook' }, + ], + }, + { + matcher: '*.js', + hooks: [ + { type: HookType.Command, command: 'echo js', name: 'my-hook' }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(2); + }); + + it('should allow hooks with same name but different sequential', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + sequential: true, + hooks: [ + { type: HookType.Command, command: 'echo seq', name: 'my-hook' }, + ], + }, + { + sequential: false, + hooks: [ + { type: HookType.Command, command: 'echo par', name: 'my-hook' }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(2); + }); + }); + + describe('extension hooks', () => { + it('should process hooks from active extensions', async () => { + const extensionHooks = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { type: HookType.Command, command: 'echo ext', name: 'ext-hook' }, + ], + }, + ], + }; + mockConfig.getExtensions = vi + .fn() + .mockReturnValue([{ isActive: true, hooks: extensionHooks }]); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const allHooks = registry.getAllHooks(); + expect(allHooks).toHaveLength(1); + expect(allHooks[0].source).toBe(HooksConfigSource.Extensions); + expect(allHooks[0].config.name).toBe('ext-hook'); + }); + + it('should skip hooks from inactive extensions', async () => { + const extensionHooks = { + [HookEventName.PreToolUse]: [ + { + hooks: [{ type: HookType.Command, command: 'echo ext' }], + }, + ], + }; + mockConfig.getExtensions = vi + .fn() + .mockReturnValue([{ isActive: false, hooks: extensionHooks }]); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(0); + }); + + it('should process multiple extensions', async () => { + mockConfig.getExtensions = vi.fn().mockReturnValue([ + { + isActive: true, + hooks: { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo ext1', + name: 'ext1-hook', + }, + ], + }, + ], + }, + }, + { + isActive: true, + hooks: { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo ext2', + name: 'ext2-hook', + }, + ], + }, + ], + }, + }, + ]); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + expect(registry.getAllHooks()).toHaveLength(2); + }); + }); + + describe('hook metadata', () => { + it('should preserve matcher in registry entry', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + matcher: 'ReadFileTool', + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'matcher-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getAllHooks(); + expect(hooks[0].matcher).toBe('ReadFileTool'); + }); + + it('should preserve sequential flag in registry entry', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + sequential: true, + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'seq-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getAllHooks(); + expect(hooks[0].sequential).toBe(true); + }); + + it('should add source to hook config', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'source-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks = registry.getAllHooks(); + expect(hooks[0].config.source).toBe(HooksConfigSource.Project); + }); + }); + + describe('getAllHooks', () => { + it('should return a copy of entries array', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + const hooks1 = registry.getAllHooks(); + const hooks2 = registry.getAllHooks(); + + expect(hooks1).toEqual(hooks2); + expect(hooks1).not.toBe(hooks2); // Different array reference + }); + }); +}); diff --git a/packages/core/src/hooks/hookRegistry.ts b/packages/core/src/hooks/hookRegistry.ts new file mode 100644 index 000000000..54251c495 --- /dev/null +++ b/packages/core/src/hooks/hookRegistry.ts @@ -0,0 +1,353 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HookDefinition, HookConfig } from './types.js'; +import { + HookEventName, + HooksConfigSource, + HOOKS_CONFIG_FIELDS, +} from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { TrustedHooksManager } from './trustedHooks.js'; + +const debugLogger = createDebugLogger('HOOK_REGISTRY'); + +/** + * Extension with hooks support + */ +export interface ExtensionWithHooks { + isActive: boolean; + hooks?: { [K in HookEventName]?: HookDefinition[] }; +} + +/** + * Configuration interface for HookRegistry + * This abstracts the Config dependency to make the registry more flexible + */ +export interface HookRegistryConfig { + getProjectRoot(): string; + isTrustedFolder(): boolean; + getHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined; + getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined; + getDisabledHooks(): string[]; + getExtensions(): ExtensionWithHooks[]; +} + +/** + * Feedback emitter interface for warning/info messages + */ +export interface FeedbackEmitter { + emitFeedback(type: 'warning' | 'info' | 'error', message: string): void; +} + +/** + * Hook registry entry with source information + */ +export interface HookRegistryEntry { + config: HookConfig; + source: HooksConfigSource; + eventName: HookEventName; + matcher?: string; + sequential?: boolean; + enabled: boolean; +} + +/** + * Hook registry that loads and validates hook definitions from multiple sources + */ +export class HookRegistry { + private readonly config: HookRegistryConfig; + private readonly feedbackEmitter?: FeedbackEmitter; + private entries: HookRegistryEntry[] = []; + + constructor(config: HookRegistryConfig, feedbackEmitter?: FeedbackEmitter) { + this.config = config; + this.feedbackEmitter = feedbackEmitter; + } + + /** + * Initialize the registry by processing hooks from config + */ + async initialize(): Promise { + this.entries = []; + this.processHooksFromConfig(); + + debugLogger.debug( + `Hook registry initialized with ${this.entries.length} hook entries`, + ); + } + + /** + * Get all hook entries for a specific event + */ + getHooksForEvent(eventName: HookEventName): HookRegistryEntry[] { + return this.entries + .filter((entry) => entry.eventName === eventName && entry.enabled) + .sort( + (a, b) => + this.getSourcePriority(a.source) - this.getSourcePriority(b.source), + ); + } + + /** + * Get all registered hooks + */ + getAllHooks(): HookRegistryEntry[] { + return [...this.entries]; + } + + /** + * Enable or disable a specific hook + */ + setHookEnabled(hookName: string, enabled: boolean): void { + const updated = this.entries.filter((entry) => { + const name = this.getHookName(entry); + if (name === hookName) { + entry.enabled = enabled; + return true; + } + return false; + }); + + if (updated.length > 0) { + debugLogger.info( + `${enabled ? 'Enabled' : 'Disabled'} ${updated.length} hook(s) matching "${hookName}"`, + ); + } else { + debugLogger.warn(`No hooks found matching "${hookName}"`); + } + } + + /** + * Get hook name for identification and display purposes + */ + private getHookName( + entry: HookRegistryEntry | { config: HookConfig }, + ): string { + return entry.config.name || entry.config.command || 'unknown-command'; + } + + /** + * Check for untrusted project hooks and warn the user + */ + private checkProjectHooksTrust(): void { + const projectHooks = this.config.getProjectHooks(); + if (!projectHooks) return; + + try { + const trustedHooksManager = new TrustedHooksManager(); + const untrusted = trustedHooksManager.getUntrustedHooks( + this.config.getProjectRoot(), + projectHooks, + ); + + if (untrusted.length > 0) { + const message = `WARNING: The following project-level hooks have been detected in this workspace: +${untrusted.map((h: string) => ` - ${h}`).join('\n')} + +These hooks will be executed. If you did not configure these hooks or do not trust this project, +please review the project settings (.qwen/settings.json) and remove them.`; + this.feedbackEmitter?.emitFeedback('warning', message); + + // Trust them so we don't warn again + trustedHooksManager.trustHooks( + this.config.getProjectRoot(), + projectHooks, + ); + } + } catch { + debugLogger.warn('Failed to check project hooks trust'); + } + } + + /** + * Process hooks from the config that was already loaded by the CLI + */ + private processHooksFromConfig(): void { + if (this.config.isTrustedFolder()) { + this.checkProjectHooksTrust(); + } + + // Get hooks from the main config (this comes from the merged settings) + const configHooks = this.config.getHooks(); + if (configHooks) { + if (this.config.isTrustedFolder()) { + this.processHooksConfiguration(configHooks, HooksConfigSource.Project); + } else { + debugLogger.warn( + 'Project hooks disabled because the folder is not trusted.', + ); + } + } + + // Get hooks from extensions + const extensions = this.config.getExtensions() || []; + for (const extension of extensions) { + if (extension.isActive && extension.hooks) { + this.processHooksConfiguration( + extension.hooks, + HooksConfigSource.Extensions, + ); + } + } + } + + /** + * Process hooks configuration and add entries + */ + private processHooksConfiguration( + hooksConfig: { [K in HookEventName]?: HookDefinition[] }, + source: HooksConfigSource, + ): void { + for (const [eventName, definitions] of Object.entries(hooksConfig)) { + if (HOOKS_CONFIG_FIELDS.includes(eventName)) { + continue; + } + + if (!this.isValidEventName(eventName)) { + this.feedbackEmitter?.emitFeedback( + 'warning', + `Invalid hook event name: "${eventName}" from ${source} config. Skipping.`, + ); + continue; + } + + const typedEventName = eventName; + + if (!Array.isArray(definitions)) { + debugLogger.warn( + `Hook definitions for event "${eventName}" from source "${source}" is not an array. Skipping.`, + ); + continue; + } + + for (const definition of definitions) { + this.processHookDefinition(definition, typedEventName, source); + } + } + } + + /** + * Process a single hook definition + */ + private processHookDefinition( + definition: HookDefinition, + eventName: HookEventName, + source: HooksConfigSource, + ): void { + if ( + !definition || + typeof definition !== 'object' || + !Array.isArray(definition.hooks) + ) { + debugLogger.warn( + `Discarding invalid hook definition for ${eventName} from ${source}:`, + definition, + ); + return; + } + + // Get disabled hooks list from settings + const disabledHooks = this.config.getDisabledHooks(); + + for (const hookConfig of definition.hooks) { + if ( + hookConfig && + typeof hookConfig === 'object' && + this.validateHookConfig(hookConfig, eventName, source) + ) { + // Check if this hook is in the disabled list + const hookName = this.getHookName({ config: hookConfig }); + const isDisabled = disabledHooks.includes(hookName); + + // Check for duplicate hooks (same name+command+source+eventName+matcher+sequential) + const isDuplicate = this.entries.some( + (existing) => + existing.eventName === eventName && + existing.source === source && + this.getHookName(existing) === hookName && + existing.matcher === definition.matcher && + existing.sequential === definition.sequential, + ); + if (isDuplicate) { + debugLogger.debug( + `Skipping duplicate hook "${hookName}" for ${eventName} from ${source}`, + ); + continue; + } + + // Add source to hook config + hookConfig.source = source; + + this.entries.push({ + config: hookConfig, + source, + eventName, + matcher: definition.matcher, + sequential: definition.sequential, + enabled: !isDisabled, + }); + } else { + // Invalid hooks are logged and discarded here, they won't reach HookRunner + debugLogger.warn( + `Discarding invalid hook configuration for ${eventName} from ${source}:`, + hookConfig, + ); + } + } + } + + /** + * Validate a hook configuration + */ + private validateHookConfig( + config: HookConfig, + eventName: HookEventName, + source: HooksConfigSource, + ): boolean { + if (!config.type || !['command', 'plugin'].includes(config.type)) { + debugLogger.warn( + `Invalid hook ${eventName} from ${source} type: ${config.type}`, + ); + return false; + } + + if (config.type === 'command' && !config.command) { + debugLogger.warn( + `Command hook ${eventName} from ${source} missing command field`, + ); + return false; + } + + return true; + } + + /** + * Check if an event name is valid + */ + private isValidEventName(eventName: string): eventName is HookEventName { + const validEventNames: string[] = Object.values(HookEventName); + return validEventNames.includes(eventName); + } + + /** + * Get source priority (lower number = higher priority) + */ + private getSourcePriority(source: HooksConfigSource): number { + switch (source) { + case HooksConfigSource.Project: + return 1; + case HooksConfigSource.User: + return 2; + case HooksConfigSource.System: + return 3; + case HooksConfigSource.Extensions: + return 4; + default: + return 999; + } + } +} diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts new file mode 100644 index 000000000..6be326ef0 --- /dev/null +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -0,0 +1,684 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HookRunner } from './hookRunner.js'; +import { HookEventName, HookType, HooksConfigSource } from './types.js'; +import type { HookConfig, HookInput } from './types.js'; + +// Hoisted mock +const mockSpawn = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + spawn: mockSpawn, + }; +}); + +describe('HookRunner', () => { + let hookRunner: HookRunner; + + beforeEach(() => { + hookRunner = new HookRunner(); + vi.clearAllMocks(); + }); + + const createMockInput = (overrides: Partial = {}): HookInput => ({ + session_id: 'test-session', + transcript_path: '/test/transcript', + cwd: '/test', + hook_event_name: 'test-event', + timestamp: '2024-01-01T00:00:00Z', + ...overrides, + }); + + const createMockProcess = ( + exitCode: number = 0, + stdout: string = '', + stderr: string = '', + ) => { + const mockProcess = { + stdin: { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + }, + stdout: { + on: vi.fn((event: string, callback: (data: Buffer) => void) => { + if (event === 'data' && stdout) { + setTimeout(() => callback(Buffer.from(stdout)), 0); + } + }), + }, + stderr: { + on: vi.fn((event: string, callback: (data: Buffer) => void) => { + if (event === 'data' && stderr) { + setTimeout(() => callback(Buffer.from(stderr)), 0); + } + }), + }, + on: vi.fn((event: string, callback: (code: number) => void) => { + if (event === 'close') { + setTimeout(() => callback(exitCode), 0); + } + }), + kill: vi.fn(), + }; + return mockProcess; + }; + + describe('executeHook', () => { + it('should return error when hook command is missing', async () => { + const hookConfig: HookConfig = { + type: HookType.Command, + command: '', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.error?.message).toBe('Command hook missing command'); + }); + + it('should execute hook and return success for exit code 0', async () => { + const mockProcess = createMockProcess(0, 'hello'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo hello', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('hello'); + expect(mockSpawn).toHaveBeenCalled(); + }); + + it('should return failure for non-zero exit code', async () => { + const mockProcess = createMockProcess(1, '', 'error'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'exit 1', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + }); + + it('should parse JSON output from stdout', async () => { + const output = JSON.stringify({ + decision: 'allow', + systemMessage: 'test', + }); + const mockProcess = createMockProcess(0, output); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo json', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.output?.decision).toBe('allow'); + expect(result.output?.systemMessage).toBe('test'); + }); + + it('should convert plain text to allow output on success', async () => { + const mockProcess = createMockProcess(0, 'some text output'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo text', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.output?.decision).toBe('allow'); + expect(result.output?.systemMessage).toBe('some text output'); + }); + + it('should convert plain text to deny output on exit code 2', async () => { + const mockProcess = createMockProcess(2, '', 'error message'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo error && exit 2', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('deny'); + expect(result.output?.reason).toBe('error message'); + }); + + it('should ignore stdout on exit code 2 and use stderr only', async () => { + // Exit code 2 should ignore stdout and use stderr as the error message + const mockProcess = createMockProcess( + 2, + 'stdout should be ignored', + 'stderr error message', + ); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo stdout && echo stderr >&2 && exit 2', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('deny'); + expect(result.output?.reason).toBe('stderr error message'); + }); + + it('should not parse JSON on exit code 2', async () => { + // Exit code 2 should ignore JSON in stdout + const mockProcess = createMockProcess( + 2, + '{"decision":"allow"}', + 'blocking error', + ); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo json && exit 2', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + // Should NOT parse JSON, should use stderr as reason + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('deny'); + expect(result.output?.reason).toBe('blocking error'); + }); + + it('should handle exit code 1 as non-blocking warning', async () => { + const mockProcess = createMockProcess(1, '', 'warning'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'exit 1', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('allow'); + expect(result.output?.systemMessage).toBe('Warning: warning'); + }); + + it('should include duration in result', async () => { + const mockProcess = createMockProcess(0, 'test'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.duration).toBeGreaterThanOrEqual(0); + }); + + it('should handle process error', async () => { + const mockProcess = { + stdin: { on: vi.fn(), write: vi.fn(), end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, callback: (error: Error) => void) => { + if (event === 'error') { + callback(new Error('spawn error')); + } + }), + kill: vi.fn(), + }; + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('executeHooksParallel', () => { + it('should execute multiple hooks in parallel', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfigs: HookConfig[] = [ + { + type: HookType.Command, + command: 'echo hook1', + source: HooksConfigSource.Project, + }, + { + type: HookType.Command, + command: 'echo hook2', + source: HooksConfigSource.Project, + }, + ]; + const input = createMockInput(); + + const results = await hookRunner.executeHooksParallel( + hookConfigs, + HookEventName.PreToolUse, + input, + ); + + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(true); + }); + + it('should call onHookStart and onHookEnd callbacks', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfigs: HookConfig[] = [ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]; + const input = createMockInput(); + const onHookStart = vi.fn(); + const onHookEnd = vi.fn(); + + await hookRunner.executeHooksParallel( + hookConfigs, + HookEventName.PreToolUse, + input, + onHookStart, + onHookEnd, + ); + + expect(onHookStart).toHaveBeenCalledTimes(1); + expect(onHookEnd).toHaveBeenCalledTimes(1); + }); + }); + + describe('executeHooksSequential', () => { + it('should execute hooks sequentially', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfigs: HookConfig[] = [ + { + type: HookType.Command, + command: 'echo first', + source: HooksConfigSource.Project, + }, + { + type: HookType.Command, + command: 'echo second', + source: HooksConfigSource.Project, + }, + ]; + const input = createMockInput(); + + const results = await hookRunner.executeHooksSequential( + hookConfigs, + HookEventName.PreToolUse, + input, + ); + + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(true); + }); + + it('should call onHookStart and onHookEnd callbacks', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfigs: HookConfig[] = [ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]; + const input = createMockInput(); + const onHookStart = vi.fn(); + const onHookEnd = vi.fn(); + + await hookRunner.executeHooksSequential( + hookConfigs, + HookEventName.PreToolUse, + input, + onHookStart, + onHookEnd, + ); + + expect(onHookStart).toHaveBeenCalledTimes(1); + expect(onHookEnd).toHaveBeenCalledTimes(1); + }); + }); + + describe('output truncation', () => { + it('should truncate stdout when exceeding MAX_OUTPUT_LENGTH', async () => { + // Create a process that outputs more than 1MB of data + const largeOutput = 'x'.repeat(2 * 1024 * 1024); // 2MB + const mockProcess = createMockProcess(0, largeOutput); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo large', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + // stdout should be truncated to MAX_OUTPUT_LENGTH (1MB) + expect(result.stdout?.length).toBeLessThanOrEqual(1024 * 1024); + }); + + it('should truncate stderr when exceeding MAX_OUTPUT_LENGTH', async () => { + const largeOutput = 'x'.repeat(2 * 1024 * 1024); // 2MB + const mockProcess = createMockProcess(0, '', largeOutput); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo large', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + // stderr should be truncated to MAX_OUTPUT_LENGTH (1MB) + expect(result.stderr?.length).toBeLessThanOrEqual(1024 * 1024); + }); + + it('should handle partial truncation gracefully', async () => { + // Output exactly at the limit + const exactOutput = 'x'.repeat(1024 * 1024); // 1MB exactly + const mockProcess = createMockProcess(0, exactOutput); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo exact', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.stdout?.length).toBe(1024 * 1024); + }); + }); + + describe('expandCommand', () => { + it('should expand GEMINI_PROJECT_DIR placeholder', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo $GEMINI_PROJECT_DIR', + source: HooksConfigSource.Project, + }; + const input = createMockInput({ cwd: '/test/project' }); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + // Verify spawn was called with expanded command + const spawnCall = mockSpawn.mock.calls[0]; + const command = spawnCall[1][spawnCall[1].length - 1]; // Last arg is the command + expect(command).toContain('/test/project'); + }); + + it('should expand CLAUDE_PROJECT_DIR placeholder for compatibility', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo $CLAUDE_PROJECT_DIR', + source: HooksConfigSource.Project, + }; + const input = createMockInput({ cwd: '/test/project' }); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + const spawnCall = mockSpawn.mock.calls[0]; + const command = spawnCall[1][spawnCall[1].length - 1]; // Last arg is the command + expect(command).toContain('/test/project'); + }); + + it('should not modify command without placeholders', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo hello', + source: HooksConfigSource.Project, + }; + const input = createMockInput({ cwd: '/test/project' }); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + const spawnCall = mockSpawn.mock.calls[0]; + const command = spawnCall[1][spawnCall[1].length - 1]; // Last arg is the command + expect(command).toBe('echo hello'); + }); + }); + + describe('convertPlainTextToHookOutput', () => { + it('should convert plain text to allow output on success', async () => { + const mockProcess = createMockProcess(0, 'plain text response'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo text', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.output?.decision).toBe('allow'); + expect(result.output?.systemMessage).toBe('plain text response'); + }); + + it('should convert non-zero exit code to deny output', async () => { + const mockProcess = createMockProcess(3, '', 'error message'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'exit 3', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(false); + expect(result.output?.decision).toBe('deny'); + expect(result.output?.reason).toBe('error message'); + }); + + it('should use stderr when stdout is empty on success', async () => { + const mockProcess = createMockProcess(0, '', 'stderr output'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.output?.systemMessage).toBe('stderr output'); + }); + + it('should handle empty output gracefully', async () => { + const mockProcess = createMockProcess(0, '', ''); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.output).toBeUndefined(); + }); + + it('should parse nested JSON strings', async () => { + const nestedJson = JSON.stringify(JSON.stringify({ decision: 'allow' })); + const mockProcess = createMockProcess(0, nestedJson); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo json', + source: HooksConfigSource.Project, + }; + const input = createMockInput(); + + const result = await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + input, + ); + + expect(result.output?.decision).toBe('allow'); + }); + }); +}); diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts new file mode 100644 index 000000000..c688e4324 --- /dev/null +++ b/packages/core/src/hooks/hookRunner.ts @@ -0,0 +1,427 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { HookEventName } from './types.js'; +import type { + HookConfig, + HookInput, + HookOutput, + HookExecutionResult, + PreToolUseInput, + UserPromptSubmitInput, +} from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { + escapeShellArg, + getShellConfiguration, + type ShellType, +} from '../utils/shell-utils.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +/** + * Default timeout for hook execution (60 seconds) + */ +const DEFAULT_HOOK_TIMEOUT = 60000; + +/** + * Maximum length for stdout/stderr output (1MB) + * Prevents memory issues from unbounded output + */ +const MAX_OUTPUT_LENGTH = 1024 * 1024; + +/** + * Exit code constants for hook execution + */ +const EXIT_CODE_SUCCESS = 0; +const EXIT_CODE_NON_BLOCKING_ERROR = 1; + +/** + * Hook runner that executes command hooks + */ +export class HookRunner { + /** + * Execute a single hook + */ + async executeHook( + hookConfig: HookConfig, + eventName: HookEventName, + input: HookInput, + ): Promise { + const startTime = Date.now(); + + try { + return await this.executeCommandHook( + hookConfig, + eventName, + input, + startTime, + ); + } catch (error) { + const duration = Date.now() - startTime; + const hookId = hookConfig.name || hookConfig.command || 'unknown'; + const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`; + debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`); + + return { + hookConfig, + eventName, + success: false, + error: error instanceof Error ? error : new Error(errorMessage), + duration, + }; + } + } + + /** + * Execute multiple hooks in parallel + */ + async executeHooksParallel( + hookConfigs: HookConfig[], + eventName: HookEventName, + input: HookInput, + onHookStart?: (config: HookConfig, index: number) => void, + onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void, + ): Promise { + const promises = hookConfigs.map(async (config, index) => { + onHookStart?.(config, index); + const result = await this.executeHook(config, eventName, input); + onHookEnd?.(config, result); + return result; + }); + + return Promise.all(promises); + } + + /** + * Execute multiple hooks sequentially + */ + async executeHooksSequential( + hookConfigs: HookConfig[], + eventName: HookEventName, + input: HookInput, + onHookStart?: (config: HookConfig, index: number) => void, + onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void, + ): Promise { + const results: HookExecutionResult[] = []; + let currentInput = input; + + for (let i = 0; i < hookConfigs.length; i++) { + const config = hookConfigs[i]; + onHookStart?.(config, i); + const result = await this.executeHook(config, eventName, currentInput); + onHookEnd?.(config, result); + results.push(result); + + // If the hook succeeded and has output, use it to modify the input for the next hook + if (result.success && result.output) { + currentInput = this.applyHookOutputToInput( + currentInput, + result.output, + eventName, + ); + } + } + + return results; + } + + /** + * Apply hook output to modify input for the next hook in sequential execution + */ + private applyHookOutputToInput( + originalInput: HookInput, + hookOutput: HookOutput, + eventName: HookEventName, + ): HookInput { + // Create a copy of the original input + const modifiedInput = { ...originalInput }; + + // Apply modifications based on hook output and event type + if (hookOutput.hookSpecificOutput) { + switch (eventName) { + case HookEventName.UserPromptSubmit: + if ('additionalContext' in hookOutput.hookSpecificOutput) { + // For UserPromptSubmit, we could modify the prompt with additional context + const additionalContext = + hookOutput.hookSpecificOutput['additionalContext']; + if ( + typeof additionalContext === 'string' && + 'prompt' in modifiedInput + ) { + (modifiedInput as UserPromptSubmitInput).prompt += + '\n\n' + additionalContext; + } + } + break; + + case HookEventName.PreToolUse: + if ('tool_input' in hookOutput.hookSpecificOutput) { + const newToolInput = hookOutput.hookSpecificOutput[ + 'tool_input' + ] as Record; + if (newToolInput && 'tool_input' in modifiedInput) { + (modifiedInput as PreToolUseInput).tool_input = { + ...(modifiedInput as PreToolUseInput).tool_input, + ...newToolInput, + }; + } + } + break; + + default: + // For other events, no special input modification is needed + break; + } + } + + return modifiedInput; + } + + /** + * Execute a command hook + */ + private async executeCommandHook( + hookConfig: HookConfig, + eventName: HookEventName, + input: HookInput, + startTime: number, + ): Promise { + const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT; + + return new Promise((resolve) => { + if (!hookConfig.command) { + const errorMessage = 'Command hook missing command'; + debugLogger.warn( + `Hook configuration error (non-fatal): ${errorMessage}`, + ); + resolve({ + hookConfig, + eventName, + success: false, + error: new Error(errorMessage), + duration: Date.now() - startTime, + }); + return; + } + + let stdout = ''; + let stderr = ''; + let timedOut = false; + + const shellConfig = getShellConfiguration(); + const command = this.expandCommand( + hookConfig.command, + input, + shellConfig.shell, + ); + + const env = { + ...process.env, + GEMINI_PROJECT_DIR: input.cwd, + CLAUDE_PROJECT_DIR: input.cwd, // For compatibility + QWEN_PROJECT_DIR: input.cwd, // For Qwen Code compatibility + ...hookConfig.env, + }; + + const child = spawn( + shellConfig.executable, + [...shellConfig.argsPrefix, command], + { + env, + cwd: input.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }, + ); + + // Set up timeout + const timeoutHandle = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + + // Force kill after 5 seconds + setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, 5000); + }, timeout); + + // Send input to stdin + if (child.stdin) { + child.stdin.on('error', (err: NodeJS.ErrnoException) => { + // Ignore EPIPE errors which happen when the child process closes stdin early + if (err.code !== 'EPIPE') { + debugLogger.debug(`Hook stdin error: ${err}`); + } + }); + + // Wrap write operations in try-catch to handle synchronous EPIPE errors + // that occur when the child process exits before we finish writing + try { + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + } catch (err) { + // Ignore EPIPE errors which happen when the child process closes stdin early + if (err instanceof Error && 'code' in err && err.code !== 'EPIPE') { + debugLogger.debug(`Hook stdin write error: ${err}`); + } + } + } + + // Collect stdout + child.stdout?.on('data', (data: Buffer) => { + if (stdout.length < MAX_OUTPUT_LENGTH) { + const remaining = MAX_OUTPUT_LENGTH - stdout.length; + stdout += data.slice(0, remaining).toString(); + if (data.length > remaining) { + debugLogger.warn( + `Hook stdout exceeded max length (${MAX_OUTPUT_LENGTH} bytes), truncating`, + ); + } + } + }); + + // Collect stderr + child.stderr?.on('data', (data: Buffer) => { + if (stderr.length < MAX_OUTPUT_LENGTH) { + const remaining = MAX_OUTPUT_LENGTH - stderr.length; + stderr += data.slice(0, remaining).toString(); + if (data.length > remaining) { + debugLogger.warn( + `Hook stderr exceeded max length (${MAX_OUTPUT_LENGTH} bytes), truncating`, + ); + } + } + }); + + // Handle process exit + child.on('close', (exitCode) => { + clearTimeout(timeoutHandle); + const duration = Date.now() - startTime; + + if (timedOut) { + resolve({ + hookConfig, + eventName, + success: false, + error: new Error(`Hook timed out after ${timeout}ms`), + stdout, + stderr, + duration, + }); + return; + } + + // Parse output + // Exit code 2 is a blocking error - ignore stdout, use stderr only + let output: HookOutput | undefined; + const isBlockingError = exitCode === 2; + + // For exit code 2, only use stderr (ignore stdout) + const textToParse = isBlockingError + ? stderr.trim() + : stdout.trim() || stderr.trim(); + + if (textToParse) { + // Only parse JSON on exit 0 + if (!isBlockingError) { + try { + let parsed = JSON.parse(textToParse); + if (typeof parsed === 'string') { + parsed = JSON.parse(parsed); + } + if (parsed && typeof parsed === 'object') { + output = parsed as HookOutput; + } + } catch { + // Not JSON, convert plain text to structured output + output = this.convertPlainTextToHookOutput( + textToParse, + exitCode || EXIT_CODE_SUCCESS, + ); + } + } else { + // Exit code 2: blocking error, use stderr as reason + output = this.convertPlainTextToHookOutput(textToParse, exitCode); + } + } + + resolve({ + hookConfig, + eventName, + success: exitCode === EXIT_CODE_SUCCESS, + output, + stdout, + stderr, + exitCode: exitCode || EXIT_CODE_SUCCESS, + duration, + }); + }); + + // Handle process errors + child.on('error', (error) => { + clearTimeout(timeoutHandle); + const duration = Date.now() - startTime; + + resolve({ + hookConfig, + eventName, + success: false, + error, + stdout, + stderr, + duration, + }); + }); + }); + } + + /** + * Expand command with environment variables and input context + */ + private expandCommand( + command: string, + input: HookInput, + shellType: ShellType, + ): string { + debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); + const escapedCwd = escapeShellArg(input.cwd, shellType); + return command + .replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd) + .replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility + } + + /** + * Convert plain text output to structured HookOutput + */ + private convertPlainTextToHookOutput( + text: string, + exitCode: number, + ): HookOutput { + if (exitCode === EXIT_CODE_SUCCESS) { + // Success - treat as system message or additional context + return { + decision: 'allow', + systemMessage: text, + }; + } else if (exitCode === EXIT_CODE_NON_BLOCKING_ERROR) { + // Non-blocking error (EXIT_CODE_NON_BLOCKING_ERROR = 1) + return { + decision: 'allow', + systemMessage: `Warning: ${text}`, + }; + } else { + // All other non-zero exit codes (including 2) are blocking + return { + decision: 'deny', + reason: text, + }; + } + } +} diff --git a/packages/core/src/hooks/hookSystem.test.ts b/packages/core/src/hooks/hookSystem.test.ts new file mode 100644 index 000000000..51f2d3050 --- /dev/null +++ b/packages/core/src/hooks/hookSystem.test.ts @@ -0,0 +1,328 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HookSystem } from './hookSystem.js'; +import { HookRegistry } from './hookRegistry.js'; +import { HookRunner } from './hookRunner.js'; +import { HookAggregator } from './hookAggregator.js'; +import { HookPlanner } from './hookPlanner.js'; +import { HookEventHandler } from './hookEventHandler.js'; +import { + HookType, + HooksConfigSource, + HookEventName, + type HookDecision, +} from './types.js'; +import type { Config } from '../config/config.js'; + +vi.mock('./hookRegistry.js'); +vi.mock('./hookRunner.js'); +vi.mock('./hookAggregator.js'); +vi.mock('./hookPlanner.js'); +vi.mock('./hookEventHandler.js'); + +describe('HookSystem', () => { + let mockConfig: Config; + let mockHookRegistry: HookRegistry; + let mockHookRunner: HookRunner; + let mockHookAggregator: HookAggregator; + let mockHookPlanner: HookPlanner; + let mockHookEventHandler: HookEventHandler; + let hookSystem: HookSystem; + + beforeEach(() => { + mockConfig = { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'), + getWorkingDir: vi.fn().mockReturnValue('/test/cwd'), + } as unknown as Config; + + mockHookRegistry = { + initialize: vi.fn().mockResolvedValue(undefined), + setHookEnabled: vi.fn(), + getAllHooks: vi.fn().mockReturnValue([]), + } as unknown as HookRegistry; + + mockHookRunner = { + executeHooksSequential: vi.fn(), + executeHooksParallel: vi.fn(), + } as unknown as HookRunner; + + mockHookAggregator = { + aggregateResults: vi.fn(), + } as unknown as HookAggregator; + + mockHookPlanner = { + createExecutionPlan: vi.fn(), + } as unknown as HookPlanner; + + mockHookEventHandler = { + fireUserPromptSubmitEvent: vi.fn(), + fireStopEvent: vi.fn(), + } as unknown as HookEventHandler; + + vi.mocked(HookRegistry).mockImplementation(() => mockHookRegistry); + vi.mocked(HookRunner).mockImplementation(() => mockHookRunner); + vi.mocked(HookAggregator).mockImplementation(() => mockHookAggregator); + vi.mocked(HookPlanner).mockImplementation(() => mockHookPlanner); + vi.mocked(HookEventHandler).mockImplementation(() => mockHookEventHandler); + + hookSystem = new HookSystem(mockConfig); + }); + + describe('constructor', () => { + it('should create instance with all dependencies', () => { + expect(HookRegistry).toHaveBeenCalledWith(mockConfig); + expect(HookRunner).toHaveBeenCalled(); + expect(HookAggregator).toHaveBeenCalled(); + expect(HookPlanner).toHaveBeenCalledWith(mockHookRegistry); + expect(HookEventHandler).toHaveBeenCalledWith( + mockConfig, + mockHookPlanner, + mockHookRunner, + mockHookAggregator, + ); + }); + }); + + describe('initialize', () => { + it('should initialize hook registry', async () => { + await hookSystem.initialize(); + + expect(mockHookRegistry.initialize).toHaveBeenCalled(); + }); + }); + + describe('getEventHandler', () => { + it('should return the hook event handler', () => { + const eventHandler = hookSystem.getEventHandler(); + + expect(eventHandler).toBe(mockHookEventHandler); + }); + }); + + describe('getRegistry', () => { + it('should return the hook registry', () => { + const registry = hookSystem.getRegistry(); + + expect(registry).toBe(mockHookRegistry); + }); + }); + + describe('setHookEnabled', () => { + it('should enable a hook', () => { + hookSystem.setHookEnabled('test-hook', true); + + expect(mockHookRegistry.setHookEnabled).toHaveBeenCalledWith( + 'test-hook', + true, + ); + }); + + it('should disable a hook', () => { + hookSystem.setHookEnabled('test-hook', false); + + expect(mockHookRegistry.setHookEnabled).toHaveBeenCalledWith( + 'test-hook', + false, + ); + }); + }); + + describe('getAllHooks', () => { + it('should return all registered hooks', () => { + const mockHooks = [ + { + config: { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + source: HooksConfigSource.Project, + eventName: HookEventName.PreToolUse, + enabled: true, + }, + ]; + vi.mocked(mockHookRegistry.getAllHooks).mockReturnValue(mockHooks); + + const hooks = hookSystem.getAllHooks(); + + expect(hooks).toEqual(mockHooks); + expect(mockHookRegistry.getAllHooks).toHaveBeenCalled(); + }); + }); + + describe('fireStopEvent', () => { + it('should fire stop event and return output', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: { + continue: false, + stopReason: 'user_stop', + }, + }; + vi.mocked(mockHookEventHandler.fireStopEvent).mockResolvedValue( + mockResult, + ); + + const result = await hookSystem.fireStopEvent(true, 'last message'); + + expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith( + true, + 'last message', + ); + expect(result).toBeDefined(); + }); + + it('should use default parameters when not provided', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + finalOutput: undefined, + }; + vi.mocked(mockHookEventHandler.fireStopEvent).mockResolvedValue( + mockResult, + ); + + await hookSystem.fireStopEvent(); + + expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith( + false, + '', + ); + }); + + it('should return undefined when no final output', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + finalOutput: undefined, + }; + vi.mocked(mockHookEventHandler.fireStopEvent).mockResolvedValue( + mockResult, + ); + + const result = await hookSystem.fireStopEvent(); + + expect(result).toBeUndefined(); + }); + }); + + describe('fireUserPromptSubmitEvent', () => { + it('should fire UserPromptSubmit event and return output', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: { + continue: true, + decision: 'allow' as HookDecision, + }, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + const result = await hookSystem.fireUserPromptSubmitEvent('test prompt'); + + expect( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).toHaveBeenCalledWith('test prompt'); + expect(result).toBeDefined(); + }); + + it('should pass prompt to event handler', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + finalOutput: { + decision: 'allow' as HookDecision, + }, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + await hookSystem.fireUserPromptSubmitEvent('my custom prompt'); + + expect( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).toHaveBeenCalledWith('my custom prompt'); + }); + + it('should return undefined when no final output', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 0, + finalOutput: undefined, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + const result = await hookSystem.fireUserPromptSubmitEvent('test'); + + expect(result).toBeUndefined(); + }); + + it('should return DefaultHookOutput with blocking decision', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: { + decision: 'block' as HookDecision, + reason: 'Blocked by policy', + }, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + const result = await hookSystem.fireUserPromptSubmitEvent('test'); + + expect(result).toBeDefined(); + expect(result?.isBlockingDecision()).toBe(true); + }); + + it('should return DefaultHookOutput with additional context', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: { + decision: 'allow' as HookDecision, + hookSpecificOutput: { + additionalContext: 'Some additional context', + }, + }, + }; + vi.mocked( + mockHookEventHandler.fireUserPromptSubmitEvent, + ).mockResolvedValue(mockResult); + + const result = await hookSystem.fireUserPromptSubmitEvent('test'); + + expect(result).toBeDefined(); + expect(result?.getAdditionalContext()).toBe('Some additional context'); + }); + }); +}); diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts new file mode 100644 index 000000000..8a40cbd9e --- /dev/null +++ b/packages/core/src/hooks/hookSystem.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { HookRegistry } from './hookRegistry.js'; +import { HookRunner } from './hookRunner.js'; +import { HookAggregator } from './hookAggregator.js'; +import { HookPlanner } from './hookPlanner.js'; +import { HookEventHandler } from './hookEventHandler.js'; +import type { HookRegistryEntry } from './hookRegistry.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import type { DefaultHookOutput } from './types.js'; +import { createHookOutput } from './types.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +/** + * Main hook system that coordinates all hook-related functionality + */ + +export class HookSystem { + private readonly hookRegistry: HookRegistry; + private readonly hookRunner: HookRunner; + private readonly hookAggregator: HookAggregator; + private readonly hookPlanner: HookPlanner; + private readonly hookEventHandler: HookEventHandler; + + constructor(config: Config) { + // Initialize components + this.hookRegistry = new HookRegistry(config); + this.hookRunner = new HookRunner(); + this.hookAggregator = new HookAggregator(); + this.hookPlanner = new HookPlanner(this.hookRegistry); + this.hookEventHandler = new HookEventHandler( + config, + this.hookPlanner, + this.hookRunner, + this.hookAggregator, + ); + } + + /** + * Initialize the hook system + */ + async initialize(): Promise { + await this.hookRegistry.initialize(); + debugLogger.debug('Hook system initialized successfully'); + } + + /** + * Get the hook event bus for firing events + */ + getEventHandler(): HookEventHandler { + return this.hookEventHandler; + } + + /** + * Get hook registry for management operations + */ + getRegistry(): HookRegistry { + return this.hookRegistry; + } + + /** + * Enable or disable a hook + */ + setHookEnabled(hookName: string, enabled: boolean): void { + this.hookRegistry.setHookEnabled(hookName, enabled); + } + + /** + * Get all registered hooks for display/management + */ + getAllHooks(): HookRegistryEntry[] { + return this.hookRegistry.getAllHooks(); + } + + async fireUserPromptSubmitEvent( + prompt: string, + ): Promise { + const result = + await this.hookEventHandler.fireUserPromptSubmitEvent(prompt); + return result.finalOutput + ? createHookOutput('UserPromptSubmit', result.finalOutput) + : undefined; + } + + async fireStopEvent( + stopHookActive: boolean = false, + lastAssistantMessage: string = '', + ): Promise { + const result = await this.hookEventHandler.fireStopEvent( + stopHookActive, + lastAssistantMessage, + ); + return result.finalOutput + ? createHookOutput('Stop', result.finalOutput) + : undefined; + } +} diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts new file mode 100644 index 000000000..779f3b332 --- /dev/null +++ b/packages/core/src/hooks/index.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Export types +export * from './types.js'; + +// Export core components +export { HookSystem } from './hookSystem.js'; +export { HookRegistry } from './hookRegistry.js'; +export { HookRunner } from './hookRunner.js'; +export { HookAggregator } from './hookAggregator.js'; +export { HookPlanner } from './hookPlanner.js'; +export { HookEventHandler } from './hookEventHandler.js'; + +// Export interfaces and enums +export type { HookRegistryEntry } from './hookRegistry.js'; +export { HooksConfigSource as ConfigSource } from './types.js'; +export type { AggregatedHookResult } from './hookAggregator.js'; +export type { HookEventContext } from './hookPlanner.js'; diff --git a/packages/core/src/hooks/trustedHooks.ts b/packages/core/src/hooks/trustedHooks.ts new file mode 100644 index 000000000..135fcc5b2 --- /dev/null +++ b/packages/core/src/hooks/trustedHooks.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { + getHookKey, + type HookDefinition, + type HookEventName, +} from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TRUSTED_HOOKS'); + +interface TrustedHooksConfig { + [projectPath: string]: string[]; // Array of trusted hook keys (name:command) +} + +export class TrustedHooksManager { + private configPath: string; + private trustedHooks: TrustedHooksConfig = {}; + + constructor() { + this.configPath = path.join( + Storage.getGlobalQwenDir(), + 'trusted_hooks.json', + ); + this.load(); + } + + private load(): void { + try { + if (fs.existsSync(this.configPath)) { + const content = fs.readFileSync(this.configPath, 'utf-8'); + this.trustedHooks = JSON.parse(content); + } + } catch (error) { + debugLogger.warn('Failed to load trusted hooks config', error); + this.trustedHooks = {}; + } + } + + private save(): void { + try { + const dir = path.dirname(this.configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync( + this.configPath, + JSON.stringify(this.trustedHooks, null, 2), + ); + } catch (error) { + debugLogger.warn('Failed to save trusted hooks config', error); + } + } + + /** + * Get untrusted hooks for a project + * @param projectPath Absolute path to the project root + * @param hooks The hooks configuration to check + * @returns List of untrusted hook commands/names + */ + getUntrustedHooks( + projectPath: string, + hooks: { [K in HookEventName]?: HookDefinition[] }, + ): string[] { + const trustedKeys = new Set(this.trustedHooks[projectPath] || []); + const untrusted: string[] = []; + + for (const eventName of Object.keys(hooks)) { + const definitions = hooks[eventName as HookEventName]; + if (!Array.isArray(definitions)) continue; + + for (const def of definitions) { + if (!def || !Array.isArray(def.hooks)) continue; + for (const hook of def.hooks) { + const key = getHookKey(hook); + if (!trustedKeys.has(key)) { + // Return friendly name or command + untrusted.push(hook.name || hook.command || 'unknown-hook'); + } + } + } + } + + return Array.from(new Set(untrusted)); // Deduplicate + } + + /** + * Trust all provided hooks for a project + */ + trustHooks( + projectPath: string, + hooks: { [K in HookEventName]?: HookDefinition[] }, + ): void { + const currentTrusted = new Set(this.trustedHooks[projectPath] || []); + + for (const eventName of Object.keys(hooks)) { + const definitions = hooks[eventName as HookEventName]; + if (!Array.isArray(definitions)) continue; + + for (const def of definitions) { + if (!def || !Array.isArray(def.hooks)) continue; + for (const hook of def.hooks) { + currentTrusted.add(getHookKey(hook)); + } + } + } + + this.trustedHooks[projectPath] = Array.from(currentTrusted); + this.save(); + } +} diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts new file mode 100644 index 000000000..49ac7a5ef --- /dev/null +++ b/packages/core/src/hooks/types.ts @@ -0,0 +1,678 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum HooksConfigSource { + Project = 'project', + User = 'user', + System = 'system', + Extensions = 'extensions', +} + +/** + * Event names for the hook system + */ +export enum HookEventName { + // PreToolUse - Before tool execution + PreToolUse = 'PreToolUse', + // PostToolUse - After tool execution + PostToolUse = 'PostToolUse', + // PostToolUseFailure - After tool execution fails + PostToolUseFailure = 'PostToolUseFailure', + // Notification - When notifications are sent + Notification = 'Notification', + // UserPromptSubmit - When the user submits a prompt + UserPromptSubmit = 'UserPromptSubmit', + // SessionStart - When a new session is started + SessionStart = 'SessionStart', + // Stop - Right before Claude concludes its response + Stop = 'Stop', + // SubagentStart - When a subagent (Task tool call) is started + SubagentStart = 'SubagentStart', + // SubagentStop - Right before a subagent (Task tool call) concludes its response + SubagentStop = 'SubagentStop', + // PreCompact - Before conversation compaction + PreCompact = 'PreCompact', + // SessionEnd - When a session is ending + SessionEnd = 'SessionEnd', + // When a permission dialog is displayed + PermissionRequest = 'PermissionRequest', +} + +/** + * Fields in the hooks configuration that are not hook event names + */ +export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications']; + +/** + * Hook configuration entry + */ +export interface CommandHookConfig { + type: HookType.Command; + command: string; + name?: string; + description?: string; + timeout?: number; + source?: HooksConfigSource; + env?: Record; +} + +export type HookConfig = CommandHookConfig; + +/** + * Hook definition with matcher + */ +export interface HookDefinition { + matcher?: string; + sequential?: boolean; + hooks: HookConfig[]; +} + +/** + * Hook implementation types + */ +export enum HookType { + Command = 'command', +} + +/** + * Generate a unique key for a hook configuration + */ +export function getHookKey(hook: HookConfig): string { + const name = hook.name ?? ''; + return name ? `${name}:${hook.command}` : hook.command; +} + +/** + * Decision types for hook outputs + */ +export type HookDecision = 'ask' | 'block' | 'deny' | 'approve' | 'allow'; + +/** + * Base hook input - common fields for all events + */ +export interface HookInput { + session_id: string; + transcript_path: string; + cwd: string; + hook_event_name: string; + timestamp: string; +} + +/** + * Base hook output - common fields for all events + */ +export interface HookOutput { + continue?: boolean; + stopReason?: string; + suppressOutput?: boolean; + systemMessage?: string; + decision?: HookDecision; + reason?: string; + hookSpecificOutput?: Record; +} + +/** + * Factory function to create the appropriate hook output class based on event name + * Returns specialized HookOutput subclasses for events with specific methods + */ +export function createHookOutput( + eventName: string, + data: Partial, +): DefaultHookOutput { + switch (eventName) { + case HookEventName.PreToolUse: + return new PreToolUseHookOutput(data); + case HookEventName.Stop: + return new StopHookOutput(data); + case HookEventName.PermissionRequest: + return new PermissionRequestHookOutput(data); + default: + return new DefaultHookOutput(data); + } +} + +/** + * Default implementation of HookOutput with utility methods + */ +export class DefaultHookOutput implements HookOutput { + continue?: boolean; + stopReason?: string; + suppressOutput?: boolean; + systemMessage?: string; + decision?: HookDecision; + reason?: string; + hookSpecificOutput?: Record; + + constructor(data: Partial = {}) { + this.continue = data.continue; + this.stopReason = data.stopReason; + this.suppressOutput = data.suppressOutput; + this.systemMessage = data.systemMessage; + this.decision = data.decision; + this.reason = data.reason; + this.hookSpecificOutput = data.hookSpecificOutput; + } + + /** + * Check if this output represents a blocking decision + */ + isBlockingDecision(): boolean { + return this.decision === 'block' || this.decision === 'deny'; + } + + /** + * Check if this output requests to stop execution + */ + shouldStopExecution(): boolean { + return this.continue === false; + } + + /** + * Get the effective reason for blocking or stopping + */ + getEffectiveReason(): string { + return this.stopReason || this.reason || 'No reason provided'; + } + + /** + * Get sanitized additional context for adding to responses. + */ + getAdditionalContext(): string | undefined { + if ( + this.hookSpecificOutput && + 'additionalContext' in this.hookSpecificOutput + ) { + const context = this.hookSpecificOutput['additionalContext']; + if (typeof context !== 'string') { + return undefined; + } + + // Sanitize by escaping < and > to prevent tag injection + return context.replace(//g, '>'); + } + return undefined; + } + + /** + * Check if execution should be blocked and return error info + */ + getBlockingError(): { blocked: boolean; reason: string } { + if (this.isBlockingDecision()) { + return { + blocked: true, + reason: this.getEffectiveReason(), + }; + } + return { blocked: false, reason: '' }; + } + + /** + * Check if context clearing was requested by hook. + */ + shouldClearContext(): boolean { + return false; + } +} + +/** + * Specific hook output class for PreToolUse events. + */ +export class PreToolUseHookOutput extends DefaultHookOutput { + /** + * Get modified tool input if provided by hook + */ + getModifiedToolInput(): Record | undefined { + if (this.hookSpecificOutput && 'tool_input' in this.hookSpecificOutput) { + const input = this.hookSpecificOutput['tool_input']; + if ( + typeof input === 'object' && + input !== null && + !Array.isArray(input) + ) { + return input as Record; + } + } + return undefined; + } +} + +/** + * Specific hook output class for Stop events. + */ +export class StopHookOutput extends DefaultHookOutput { + override stopReason?: string; + + constructor(data: Partial = {}) { + super(data); + this.stopReason = data.stopReason; + } + + /** + * Get the stop reason if provided + */ + getStopReason(): string | undefined { + if (!this.stopReason) { + return undefined; + } + return `Stop hook feedback:\n${this.stopReason}`; + } +} + +/** + * Permission suggestion type + */ +export interface PermissionSuggestion { + type: string; + tool?: string; +} + +/** + * Input for PermissionRequest hook events + */ +export interface PermissionRequestInput extends HookInput { + permission_mode: PermissionMode; + tool_name: string; + tool_input: Record; + permission_suggestions?: PermissionSuggestion[]; +} + +/** + * Decision object for PermissionRequest hooks + */ +export interface PermissionRequestDecision { + behavior: 'allow' | 'deny'; + updatedInput?: Record; + updatedPermissions?: PermissionSuggestion[]; + message?: string; + interrupt?: boolean; +} + +/** + * Specific hook output class for PermissionRequest events. + */ +export class PermissionRequestHookOutput extends DefaultHookOutput { + /** + * Get the permission decision if provided by hook + */ + getPermissionDecision(): PermissionRequestDecision | undefined { + if (this.hookSpecificOutput && 'decision' in this.hookSpecificOutput) { + const decision = this.hookSpecificOutput['decision']; + if ( + typeof decision === 'object' && + decision !== null && + !Array.isArray(decision) + ) { + return decision as PermissionRequestDecision; + } + } + return undefined; + } + + /** + * Check if the permission was denied + */ + isPermissionDenied(): boolean { + const decision = this.getPermissionDecision(); + return decision?.behavior === 'deny'; + } + + /** + * Get the deny message if permission was denied + */ + getDenyMessage(): string | undefined { + const decision = this.getPermissionDecision(); + return decision?.message; + } + + /** + * Check if execution should be interrupted after denial + */ + shouldInterrupt(): boolean { + const decision = this.getPermissionDecision(); + return decision?.interrupt === true; + } + + /** + * Get updated tool input if permission was allowed with modifications + */ + getUpdatedToolInput(): Record | undefined { + const decision = this.getPermissionDecision(); + return decision?.updatedInput; + } + + /** + * Get updated permissions if permission was allowed with permission updates + */ + getUpdatedPermissions(): PermissionSuggestion[] | undefined { + const decision = this.getPermissionDecision(); + return decision?.updatedPermissions; + } +} + +/** + * Context for MCP tool executions. + * Contains non-sensitive connection information about the MCP server + * identity. Since server_name is user controlled and arbitrary, we + * also include connection information (e.g., command or url) to + * help identify the MCP server. + * + * NOTE: In the future, consider defining a shared sanitized interface + * from MCPServerConfig to avoid duplication and ensure consistency. + */ +export interface McpToolContext { + server_name: string; + tool_name: string; // Original tool name from the MCP server + + // Connection info (mutually exclusive based on transport type) + command?: string; // For stdio transport + args?: string[]; // For stdio transport + cwd?: string; // For stdio transport + + url?: string; // For SSE/HTTP transport + + tcp?: string; // For WebSocket transport +} + +export interface PreToolUseInput extends HookInput { + permission_mode?: PermissionMode; + tool_name: string; + tool_input: Record; + mcp_context?: McpToolContext; + original_request_name?: string; +} + +/** + * PreToolUse hook output + */ +export interface PreToolUseOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'PreToolUse'; + tool_input?: Record; + }; +} + +/** + * PostToolUse hook input + */ +export interface PostToolUseInput extends HookInput { + tool_name: string; + tool_input: Record; + tool_response: Record; + mcp_context?: McpToolContext; + original_request_name?: string; +} + +/** + * PostToolUse hook output + */ +export interface PostToolUseOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'PostToolUse'; + additionalContext?: string; + + /** + * Optional request to execute another tool immediately after this one. + * The result of this tail call will replace the original tool's response. + */ + tailToolCallRequest?: { + name: string; + args: Record; + }; + }; +} + +/** + * PostToolUseFailure hook input + * Fired when a tool execution fails + */ +export interface PostToolUseFailureInput extends HookInput { + tool_use_id: string; // Unique identifier for the tool use + tool_name: string; + tool_input: Record; + error: string; // Error message describing the failure + error_type?: string; // Type of error (e.g., 'timeout', 'network', 'permission', etc.) + is_interrupt?: boolean; // Whether the failure was caused by user interruption +} + +/** + * PostToolUseFailure hook output + * Supports all three hook types: command, prompt, and agent + */ +export interface PostToolUseFailureOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'PostToolUseFailure'; + additionalContext?: string; + }; +} + +/** + * UserPromptSubmit hook input + */ +export interface UserPromptSubmitInput extends HookInput { + prompt: string; +} + +/** + * UserPromptSubmit hook output + */ +export interface UserPromptSubmitOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'UserPromptSubmit'; + additionalContext?: string; + }; +} + +/** + * Notification types + */ +export enum NotificationType { + ToolPermission = 'ToolPermission', +} + +/** + * Notification hook input + */ +export interface NotificationInput extends HookInput { + permission_mode?: PermissionMode; + notification_type: NotificationType; + message: string; + title?: string; + details: Record; +} + +/** + * Notification hook output + */ +export interface NotificationOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'Notification'; + additionalContext?: string; + }; +} + +/** + * Stop hook input + */ +export interface StopInput extends HookInput { + stop_hook_active: boolean; + last_assistant_message: string; +} + +/** + * Stop hook output + */ +export interface StopOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'Stop'; + additionalContext?: string; + }; +} + +/** + * SessionStart source types + */ +export enum SessionStartSource { + Startup = 'startup', + Resume = 'resume', + Clear = 'clear', + Compact = 'compact', +} + +export enum PermissionMode { + Default = 'default', + Plan = 'plan', + AcceptEdit = 'accept_edit', + DontAsk = 'dont_ask', + BypassPermissions = 'bypass_permissions', +} + +/** + * SessionStart hook input + */ +export interface SessionStartInput extends HookInput { + permission_mode?: PermissionMode; + source: SessionStartSource; + model?: string; +} + +/** + * SessionStart hook output + */ +export interface SessionStartOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'SessionStart'; + additionalContext?: string; + }; +} + +/** + * SessionEnd reason types + */ +export enum SessionEndReason { + Clear = 'clear', + Logout = 'logout', + PromptInputExit = 'prompt_input_exit', + Bypass_permissions_disabled = 'bypass_permissions_disabled', + Other = 'other', +} + +/** + * SessionEnd hook input + */ +export interface SessionEndInput extends HookInput { + reason: SessionEndReason; +} + +/** + * SessionEnd hook output + */ +export interface SessionEndOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'SessionEnd'; + additionalContext?: string; + }; +} + +/** + * PreCompress trigger types + */ +export enum PreCompactTrigger { + Manual = 'manual', + Auto = 'auto', +} + +/** + * PreCompress hook input + */ +export interface PreCompactInput extends HookInput { + trigger: PreCompactTrigger; + custom_instructions?: string; +} + +/** + * PreCompress hook output + */ +export interface PreCompactOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'PreCompact'; + additionalContext?: string; + }; +} + +export enum AgentType { + Bash = 'Bash', + Explorer = 'Explorer', + Plan = 'Plan', + Custom = 'Custom', +} + +/** + * SubagentStart hook input + * Fired when a subagent (Task tool call) is started + */ +export interface SubagentStartInput extends HookInput { + permission_mode?: PermissionMode; + agent_id: string; + agent_type: AgentType; +} + +/** + * SubagentStart hook output + */ +export interface SubagentStartOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'SubagentStart'; + additionalContext?: string; + }; +} + +/** + * SubagentStop hook input + * Fired right before a subagent (Task tool call) concludes its response + */ +export interface SubagentStopInput extends HookInput { + permission_mode?: PermissionMode; + stop_hook_active: boolean; + agent_id: string; + agent_type: AgentType; + agent_transcript_path: string; + last_assistant_message: string; +} + +/** + * SubagentStop hook output + * Supports all three hook types: command, prompt, and agent + */ +export interface SubagentStopOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'SubagentStop'; + additionalContext?: string; + }; +} + +/** + * Hook execution result + */ +export interface HookExecutionResult { + hookConfig: HookConfig; + eventName: HookEventName; + success: boolean; + output?: HookOutput; + stdout?: string; + stderr?: string; + exitCode?: number; + duration: number; + error?: Error; +} + +/** + * Hook execution plan for an event + */ +export interface HookExecutionPlan { + eventName: HookEventName; + hookConfigs: HookConfig[]; + sequential: boolean; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2800e20f6..d0dcce945 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,6 +74,7 @@ export * from './utils/paths.js'; export * from './utils/schemaValidator.js'; export * from './utils/errors.js'; export * from './utils/debugLogger.js'; +export * from './utils/symlink.js'; export * from './utils/getFolderStructure.js'; export * from './utils/memoryDiscovery.js'; export * from './utils/gitIgnoreParser.js'; @@ -287,6 +288,7 @@ export * from './utils/tool-utils.js'; export * from './utils/workspaceContext.js'; export * from './utils/yaml-parser.js'; export * from './utils/jsonl-utils.js'; +export * from './utils/symlink.js'; // ============================================================================ // OAuth & Authentication @@ -300,3 +302,8 @@ export * from './qwen/qwenOAuth2.js'; export { makeFakeConfig } from './test-utils/config.js'; export * from './test-utils/index.js'; + +// Export hook types and components +export * from './hooks/types.js'; +export { HookSystem, HookRegistry } from './hooks/index.js'; +export type { HookRegistryEntry } from './hooks/index.js'; diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index c7629e134..31a8699dc 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -4,10 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import type { GeminiClient } from '../core/client.js'; -import type { BaseLlmClient } from '../core/baseLlmClient.js'; import type { ServerGeminiContentEvent, ServerGeminiStreamEvent, @@ -15,7 +13,6 @@ import type { } from '../core/turn.js'; import { GeminiEventType } from '../core/turn.js'; import * as loggers from '../telemetry/loggers.js'; -import { LoopType } from '../telemetry/types.js'; import { LoopDetectionService } from './loopDetectionService.js'; vi.mock('../telemetry/loggers.js', () => ({ @@ -623,142 +620,3 @@ describe('LoopDetectionService', () => { }); }); }); - -describe('LoopDetectionService LLM Checks', () => { - let service: LoopDetectionService; - let mockConfig: Config; - let mockGeminiClient: GeminiClient; - let mockBaseLlmClient: BaseLlmClient; - let abortController: AbortController; - - beforeEach(() => { - mockGeminiClient = { - getHistory: vi.fn().mockReturnValue([]), - } as unknown as GeminiClient; - - mockBaseLlmClient = { - generateJson: vi.fn(), - } as unknown as BaseLlmClient; - - mockConfig = { - getGeminiClient: () => mockGeminiClient, - getBaseLlmClient: () => mockBaseLlmClient, - getDebugMode: () => false, - getDebugLogger: () => ({ - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }), - getTelemetryEnabled: () => true, - getModel: () => 'test-model', - } as unknown as Config; - - service = new LoopDetectionService(mockConfig); - abortController = new AbortController(); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - const advanceTurns = async (count: number) => { - for (let i = 0; i < count; i++) { - await service.turnStarted(abortController.signal); - } - }; - - it('should not trigger LLM check before LLM_CHECK_AFTER_TURNS', async () => { - await advanceTurns(29); - expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled(); - }); - - it('should trigger LLM check on the 30th turn', async () => { - mockBaseLlmClient.generateJson = vi - .fn() - .mockResolvedValue({ confidence: 0.1 }); - await advanceTurns(30); - expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1); - expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith( - expect.objectContaining({ - systemInstruction: expect.any(String), - contents: expect.any(Array), - model: expect.any(String), - schema: expect.any(Object), - promptId: expect.any(String), - }), - ); - }); - - it('should detect a cognitive loop when confidence is high', async () => { - // First check at turn 30 - mockBaseLlmClient.generateJson = vi - .fn() - .mockResolvedValue({ confidence: 0.85, reasoning: 'Repetitive actions' }); - await advanceTurns(30); - expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1); - - // The confidence of 0.85 will result in a low interval. - // The interval will be: 5 + (15 - 5) * (1 - 0.85) = 5 + 10 * 0.15 = 6.5 -> rounded to 7 - await advanceTurns(6); // advance to turn 36 - - mockBaseLlmClient.generateJson = vi - .fn() - .mockResolvedValue({ confidence: 0.95, reasoning: 'Repetitive actions' }); - const finalResult = await service.turnStarted(abortController.signal); // This is turn 37 - - expect(finalResult).toBe(true); - expect(loggers.logLoopDetected).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - 'event.name': 'loop_detected', - loop_type: LoopType.LLM_DETECTED_LOOP, - }), - ); - }); - - it('should not detect a loop when confidence is low', async () => { - mockBaseLlmClient.generateJson = vi - .fn() - .mockResolvedValue({ confidence: 0.5, reasoning: 'Looks okay' }); - await advanceTurns(30); - const result = await service.turnStarted(abortController.signal); - expect(result).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - - it('should adjust the check interval based on confidence', async () => { - // Confidence is 0.0, so interval should be MAX_LLM_CHECK_INTERVAL (15) - mockBaseLlmClient.generateJson = vi - .fn() - .mockResolvedValue({ confidence: 0.0 }); - await advanceTurns(30); // First check at turn 30 - expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1); - - await advanceTurns(14); // Advance to turn 44 - expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1); - - await service.turnStarted(abortController.signal); // Turn 45 - expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(2); - }); - - it('should handle errors from generateJson gracefully', async () => { - mockBaseLlmClient.generateJson = vi - .fn() - .mockRejectedValue(new Error('API error')); - await advanceTurns(30); - const result = await service.turnStarted(abortController.signal); - expect(result).toBe(false); - expect(loggers.logLoopDetected).not.toHaveBeenCalled(); - }); - - it('should not trigger LLM check when disabled for session', async () => { - service.disableForSession(); - expect(loggers.logLoopDetectionDisabled).toHaveBeenCalledTimes(1); - await advanceTurns(30); - const result = await service.turnStarted(abortController.signal); - expect(result).toBe(false); - expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 9117d0120..d14e4223e 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Content } from '@google/genai'; import { createHash } from 'node:crypto'; import type { ServerGeminiStreamEvent } from '../core/turn.js'; import { GeminiEventType } from '../core/turn.js'; @@ -18,59 +17,12 @@ import { LoopType, } from '../telemetry/types.js'; import type { Config } from '../config/config.js'; -import { - isFunctionCall, - isFunctionResponse, -} from '../utils/messageInspectors.js'; -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; -import { createDebugLogger } from '../utils/debugLogger.js'; - -const debugLogger = createDebugLogger('LOOP_DETECTION'); const TOOL_CALL_LOOP_THRESHOLD = 5; const CONTENT_LOOP_THRESHOLD = 10; const CONTENT_CHUNK_SIZE = 50; const MAX_HISTORY_LENGTH = 1000; -/** - * The number of recent conversation turns to include in the history when asking the LLM to check for a loop. - */ -const LLM_LOOP_CHECK_HISTORY_COUNT = 20; - -/** - * The number of turns that must pass in a single prompt before the LLM-based loop check is activated. - */ -const LLM_CHECK_AFTER_TURNS = 30; - -/** - * The default interval, in number of turns, at which the LLM-based loop check is performed. - * This value is adjusted dynamically based on the LLM's confidence. - */ -const DEFAULT_LLM_CHECK_INTERVAL = 3; - -/** - * The minimum interval for LLM-based loop checks. - * This is used when the confidence of a loop is high, to check more frequently. - */ -const MIN_LLM_CHECK_INTERVAL = 5; - -/** - * The maximum interval for LLM-based loop checks. - * This is used when the confidence of a loop is low, to check less frequently. - */ -const MAX_LLM_CHECK_INTERVAL = 15; - -const LOOP_DETECTION_SYSTEM_PROMPT = `You are a sophisticated AI diagnostic agent specializing in identifying when a conversational AI is stuck in an unproductive state. Your task is to analyze the provided conversation history and determine if the assistant has ceased to make meaningful progress. - -An unproductive state is characterized by one or more of the following patterns over the last 5 or more assistant turns: - -Repetitive Actions: The assistant repeats the same tool calls or conversational responses a decent number of times. This includes simple loops (e.g., tool_A, tool_A, tool_A) and alternating patterns (e.g., tool_A, tool_B, tool_A, tool_B, ...). - -Cognitive Loop: The assistant seems unable to determine the next logical step. It might express confusion, repeatedly ask the same questions, or generate responses that don't logically follow from the previous turns, indicating it's stuck and not advancing the task. - -Crucially, differentiate between a true unproductive state and legitimate, incremental progress. -For example, a series of 'tool_A' or 'tool_B' tool calls that make small, distinct changes to the same file (like adding docstrings to functions one by one) is considered forward progress and is NOT a loop. A loop would be repeatedly replacing the same text with the same content, or cycling between a small set of files with no net change.`; - /** * Service for detecting and preventing infinite loops in AI responses. * Monitors tool call repetitions and content sentence repetitions. @@ -90,11 +42,6 @@ export class LoopDetectionService { private loopDetected = false; private inCodeBlock = false; - // LLM loop track tracking - private turnsInCurrentPrompt = 0; - private llmCheckInterval = DEFAULT_LLM_CHECK_INTERVAL; - private lastCheckTurn = 0; - // Session-level disable flag private disabledForSession = false; @@ -145,33 +92,6 @@ export class LoopDetectionService { return this.loopDetected; } - /** - * Signals the start of a new turn in the conversation. - * - * This method increments the turn counter and, if specific conditions are met, - * triggers an LLM-based check to detect potential conversation loops. The check - * is performed periodically based on the `llmCheckInterval`. - * - * @param signal - An AbortSignal to allow for cancellation of the asynchronous LLM check. - * @returns A promise that resolves to `true` if a loop is detected, and `false` otherwise. - */ - async turnStarted(signal: AbortSignal) { - if (this.disabledForSession) { - return false; - } - this.turnsInCurrentPrompt++; - - if ( - this.turnsInCurrentPrompt >= LLM_CHECK_AFTER_TURNS && - this.turnsInCurrentPrompt - this.lastCheckTurn >= this.llmCheckInterval - ) { - this.lastCheckTurn = this.turnsInCurrentPrompt; - return await this.checkForLoopWithLLM(signal); - } - - return false; - } - private checkToolCallLoop(toolCall: { name: string; args: object }): boolean { const key = this.getToolCallKey(toolCall); if (this.lastToolCallKey === key) { @@ -371,94 +291,6 @@ export class LoopDetectionService { return originalChunk === currentChunk; } - private trimRecentHistory(recentHistory: Content[]): Content[] { - // A function response must be preceded by a function call. - // Continuously removes dangling function calls from the end of the history - // until the last turn is not a function call. - while ( - recentHistory.length > 0 && - isFunctionCall(recentHistory[recentHistory.length - 1]) - ) { - recentHistory.pop(); - } - - // A function response should follow a function call. - // Continuously removes leading function responses from the beginning of history - // until the first turn is not a function response. - while (recentHistory.length > 0 && isFunctionResponse(recentHistory[0])) { - recentHistory.shift(); - } - - return recentHistory; - } - - private async checkForLoopWithLLM(signal: AbortSignal) { - const recentHistory = this.config - .getGeminiClient() - .getHistory() - .slice(-LLM_LOOP_CHECK_HISTORY_COUNT); - - const trimmedHistory = this.trimRecentHistory(recentHistory); - - const taskPrompt = `Please analyze the conversation history to determine the possibility that the conversation is stuck in a repetitive, non-productive state. Provide your response in the requested JSON format.`; - - const contents = [ - ...trimmedHistory, - { role: 'user', parts: [{ text: taskPrompt }] }, - ]; - const schema: Record = { - type: 'object', - properties: { - reasoning: { - type: 'string', - description: - 'Your reasoning on if the conversation is looping without forward progress.', - }, - confidence: { - type: 'number', - description: - 'A number between 0.0 and 1.0 representing your confidence that the conversation is in an unproductive state.', - }, - }, - required: ['reasoning', 'confidence'], - }; - let result; - try { - result = await this.config.getBaseLlmClient().generateJson({ - contents, - schema, - model: this.config.getModel() || DEFAULT_QWEN_MODEL, - systemInstruction: LOOP_DETECTION_SYSTEM_PROMPT, - abortSignal: signal, - promptId: this.promptId, - }); - } catch (e) { - // Do nothing, treat it as a non-loop. - this.config.getDebugLogger().error(e); - return false; - } - - if (typeof result['confidence'] === 'number') { - if (result['confidence'] > 0.9) { - if (typeof result['reasoning'] === 'string' && result['reasoning']) { - debugLogger.warn(result['reasoning']); - } - logLoopDetected( - this.config, - new LoopDetectedEvent(LoopType.LLM_DETECTED_LOOP, this.promptId), - ); - return true; - } else { - this.llmCheckInterval = Math.round( - MIN_LLM_CHECK_INTERVAL + - (MAX_LLM_CHECK_INTERVAL - MIN_LLM_CHECK_INTERVAL) * - (1 - result['confidence']), - ); - } - } - return false; - } - /** * Resets all loop detection state. */ @@ -466,7 +298,6 @@ export class LoopDetectionService { this.promptId = promptId; this.resetToolCallCount(); this.resetContentTracking(); - this.resetLlmCheckTracking(); this.loopDetected = false; } @@ -482,10 +313,4 @@ export class LoopDetectionService { this.contentStats.clear(); this.lastContentIndex = 0; } - - private resetLlmCheckTracking(): void { - this.turnsInCurrentPrompt = 0; - this.llmCheckInterval = DEFAULT_LLM_CHECK_INTERVAL; - this.lastCheckTurn = 0; - } } diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 8c8e7bd4a..1e93076fd 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -19,6 +19,13 @@ const mockIsBinary = vi.hoisted(() => vi.fn()); const mockPlatform = vi.hoisted(() => vi.fn()); const mockGetPty = vi.hoisted(() => vi.fn()); const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn()); +const mockGetShellConfiguration = vi.hoisted(() => + vi.fn().mockReturnValue({ + executable: 'bash', + argsPrefix: ['-c'], + shell: 'bash', + }), +); // Top-level Mocks vi.mock('@lydell/node-pty', () => ({ @@ -54,6 +61,9 @@ vi.mock('../utils/getPty.js', () => ({ vi.mock('../utils/terminalSerializer.js', () => ({ serializeTerminalToObject: mockSerializeTerminalToObject, })); +vi.mock('../utils/shell-utils.js', () => ({ + getShellConfiguration: mockGetShellConfiguration, +})); const mockProcessKill = vi .spyOn(process, 'kill') @@ -410,15 +420,25 @@ describe('ShellExecutionService', () => { describe('Platform-Specific Behavior', () => { it('should use cmd.exe on Windows', async () => { mockPlatform.mockReturnValue('win32'); + mockGetShellConfiguration.mockReturnValue({ + executable: 'cmd.exe', + argsPrefix: ['/d', '/s', '/c'], + shell: 'cmd', + }); await simulateExecution('dir "foo bar"', (pty) => pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), ); expect(mockPtySpawn).toHaveBeenCalledWith( 'cmd.exe', - '/c dir "foo bar"', + ['/d', '/s', '/c', 'dir "foo bar"'], expect.any(Object), ); + mockGetShellConfiguration.mockReturnValue({ + executable: 'bash', + argsPrefix: ['-c'], + shell: 'bash', + }); }); it('should use bash on Linux', async () => { @@ -822,18 +842,28 @@ describe('ShellExecutionService child_process fallback', () => { describe('Platform-Specific Behavior', () => { it('should use cmd.exe and hide window on Windows', async () => { mockPlatform.mockReturnValue('win32'); + mockGetShellConfiguration.mockReturnValue({ + executable: 'cmd.exe', + argsPrefix: ['/d', '/s', '/c'], + shell: 'cmd', + }); await simulateExecution('dir "foo bar"', (cp) => cp.emit('exit', 0, null), ); expect(mockCpSpawn).toHaveBeenCalledWith( 'cmd.exe', - ['/c', 'dir "foo bar"'], + ['/d', '/s', '/c', 'dir "foo bar"'], expect.objectContaining({ detached: false, windowsHide: true, }), ); + mockGetShellConfiguration.mockReturnValue({ + executable: 'bash', + argsPrefix: ['-c'], + shell: 'bash', + }); }); it('should use bash and detached process group on Linux', async () => { diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 3d812d899..50cdc3a09 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -13,6 +13,7 @@ import os from 'node:os'; import type { IPty } from '@lydell/node-pty'; import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js'; import { isBinary } from '../utils/textUtils.js'; +import { getShellConfiguration } from '../utils/shell-utils.js'; import pkg from '@xterm/headless'; import { serializeTerminalToObject, @@ -223,14 +224,12 @@ export class ShellExecutionService { ): ShellExecutionHandle { try { const isWindows = os.platform() === 'win32'; - const shell = isWindows ? 'cmd.exe' : 'bash'; - const shellArgs = isWindows - ? ['/c', commandToExecute] - : ['-c', commandToExecute]; + const { executable, argsPrefix } = getShellConfiguration(); + const shellArgs = [...argsPrefix, commandToExecute]; // Note: CodeQL flags this as js/shell-command-injection-from-environment. // This is intentional - CLI tool executes user-provided shell commands. - const child = cpSpawn(shell, shellArgs, { + const child = cpSpawn(executable, shellArgs, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsVerbatimArguments: isWindows, @@ -419,13 +418,10 @@ export class ShellExecutionService { try { const cols = shellExecutionConfig.terminalWidth ?? 80; const rows = shellExecutionConfig.terminalHeight ?? 30; - const isWindows = os.platform() === 'win32'; - const shell = isWindows ? 'cmd.exe' : 'bash'; - const args = isWindows - ? `/c ${commandToExecute}` - : ['-c', commandToExecute]; + const { executable, argsPrefix } = getShellConfiguration(); + const args = [...argsPrefix, commandToExecute]; - const ptyProcess = ptyInfo.module.spawn(shell, args, { + const ptyProcess = ptyInfo.module.spawn(executable, args, { cwd, name: 'xterm', cols, @@ -435,6 +431,7 @@ export class ShellExecutionService { QWEN_CODE: '1', TERM: 'xterm-256color', PAGER: shellExecutionConfig.pager ?? 'cat', + GIT_PAGER: shellExecutionConfig.pager ?? 'cat', }, handleFlowControl: true, }); @@ -463,85 +460,107 @@ export class ShellExecutionService { let hasStartedOutput = false; let renderTimeout: NodeJS.Timeout | null = null; - const render = (finalRender = false) => { - if (renderTimeout) { - clearTimeout(renderTimeout); + const RENDER_THROTTLE_MS = 100; + + const renderFn = () => { + if (!isStreamingRawContent) { + return; } - const renderFn = () => { - if (!isStreamingRawContent) { - return; - } - - if (!shellExecutionConfig.disableDynamicLineTrimming) { - if (!hasStartedOutput) { - const bufferText = getFullBufferText(headlessTerminal); - if (bufferText.trim().length === 0) { - return; - } - hasStartedOutput = true; + if (!shellExecutionConfig.disableDynamicLineTrimming) { + if (!hasStartedOutput) { + const bufferText = getFullBufferText(headlessTerminal); + if (bufferText.trim().length === 0) { + return; } + hasStartedOutput = true; } + } - let newOutput: AnsiOutput; - if (shellExecutionConfig.showColor) { - newOutput = serializeTerminalToObject(headlessTerminal); - } else { - const buffer = headlessTerminal.buffer.active; - const lines: AnsiOutput = []; - for (let y = 0; y < headlessTerminal.rows; y++) { - const line = buffer.getLine(buffer.viewportY + y); - const lineContent = line ? line.translateToString(true) : ''; - lines.push([ - { - text: lineContent, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - fg: '', - bg: '', - }, - ]); - } - newOutput = lines; - } - - let lastNonEmptyLine = -1; - for (let i = newOutput.length - 1; i >= 0; i--) { - const line = newOutput[i]; - if ( - line - .map((segment) => segment.text) - .join('') - .trim().length > 0 - ) { - lastNonEmptyLine = i; - break; - } - } - - const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1); - - const finalOutput = shellExecutionConfig.disableDynamicLineTrimming - ? newOutput - : trimmedOutput; - - // Using stringify for a quick deep comparison. - if (JSON.stringify(output) !== JSON.stringify(finalOutput)) { - output = finalOutput; - onOutputEvent({ - type: 'data', - chunk: finalOutput, - }); - } - }; - - if (finalRender) { - renderFn(); + let newOutput: AnsiOutput; + if (shellExecutionConfig.showColor) { + newOutput = serializeTerminalToObject(headlessTerminal); } else { - renderTimeout = setTimeout(renderFn, 17); + const buffer = headlessTerminal.buffer.active; + const lines: AnsiOutput = []; + for (let y = 0; y < headlessTerminal.rows; y++) { + const line = buffer.getLine(buffer.viewportY + y); + const lineContent = line ? line.translateToString(true) : ''; + lines.push([ + { + text: lineContent, + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + ]); + } + newOutput = lines; + } + + let lastNonEmptyLine = -1; + for (let i = newOutput.length - 1; i >= 0; i--) { + const line = newOutput[i]; + if ( + line + .map((segment) => segment.text) + .join('') + .trim().length > 0 + ) { + lastNonEmptyLine = i; + break; + } + } + + const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1); + + const finalOutput = shellExecutionConfig.disableDynamicLineTrimming + ? newOutput + : trimmedOutput; + + // Using stringify for a quick deep comparison. + if (JSON.stringify(output) !== JSON.stringify(finalOutput)) { + output = finalOutput; + onOutputEvent({ + type: 'data', + chunk: finalOutput, + }); + } + }; + + // Throttle: render immediately on first call, then at most + // once per RENDER_THROTTLE_MS during continuous output. + // A trailing render is scheduled to ensure the final state + // is always displayed. + let pendingTrailingRender = false; + + const render = (finalRender = false) => { + if (finalRender) { + if (renderTimeout) { + clearTimeout(renderTimeout); + renderTimeout = null; + } + renderFn(); + return; + } + + if (!renderTimeout) { + // No active throttle — render now and start throttle window + renderFn(); + renderTimeout = setTimeout(() => { + renderTimeout = null; + if (pendingTrailingRender) { + pendingTrailingRender = false; + render(); + } + }, RENDER_THROTTLE_MS); + } else { + // Throttled — mark that we need a trailing render + pendingTrailingRender = true; } }; @@ -610,7 +629,7 @@ export class ShellExecutionService { abortSignal.removeEventListener('abort', abortHandler); this.activePtys.delete(ptyProcess.pid); - processingChain.then(() => { + const finalize = () => { render(true); const finalBuffer = Buffer.concat(outputChunks); @@ -626,6 +645,18 @@ export class ShellExecutionService { (ptyInfo?.name as 'node-pty' | 'lydell-node-pty') ?? 'node-pty', }); + }; + + // Always try to flush pending terminal writes before + // finalizing so result.output is as complete as possible. + // Race against abort or a short timeout to avoid hanging. + const processingComplete = processingChain.then(() => 'processed'); + const deadline = new Promise<'timeout'>((res) => + setTimeout(() => res('timeout'), SIGKILL_TIMEOUT_MS), + ); + + void Promise.race([processingComplete, deadline]).then(() => { + finalize(); }); }, ); @@ -636,11 +667,18 @@ export class ShellExecutionService { ptyProcess.kill(); } else { try { - // Kill the entire process group - process.kill(-ptyProcess.pid, 'SIGINT'); + // Send SIGTERM first to allow graceful shutdown + process.kill(-ptyProcess.pid, 'SIGTERM'); + await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); + if (!exited) { + // Escalate to SIGKILL if still running + process.kill(-ptyProcess.pid, 'SIGKILL'); + } } catch (_e) { // Fallback to killing just the process if the group kill fails - ptyProcess.kill('SIGINT'); + if (!exited) { + ptyProcess.kill(); + } } } } @@ -652,19 +690,28 @@ export class ShellExecutionService { return { pid: ptyProcess.pid, result }; } catch (e) { const error = e as Error; - return { - pid: undefined, - result: Promise.resolve({ - error, - rawOutput: Buffer.from(''), - output: '', - exitCode: 1, - signal: null, - aborted: false, + if (error.message.includes('posix_spawnp failed')) { + onOutputEvent({ + type: 'data', + chunk: + '[WARNING] PTY execution failed, falling back to child_process. This may be due to sandbox restrictions.\n', + }); + throw e; + } else { + return { pid: undefined, - executionMethod: 'none', - }), - }; + result: Promise.resolve({ + error, + rawOutput: Buffer.from(''), + output: '', + exitCode: 1, + signal: null, + aborted: false, + pid: undefined, + executionMethod: 'none', + }), + }; + } } } diff --git a/packages/core/src/skills/skill-load.ts b/packages/core/src/skills/skill-load.ts index dc6f2c616..639b85071 100644 --- a/packages/core/src/skills/skill-load.ts +++ b/packages/core/src/skills/skill-load.ts @@ -3,6 +3,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { parse as parseYaml } from '../utils/yaml-parser.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { normalizeContent } from '../utils/textUtils.js'; const debugLogger = createDebugLogger('SKILL_LOAD'); @@ -56,21 +57,6 @@ export async function loadSkillsFromDir( } } -/** - * Normalizes skill file content for consistent parsing across platforms. - * - Strips UTF-8 BOM to ensure frontmatter starts at the first character. - * - Normalizes line endings so skills authored on Windows (CRLF) parse correctly. - */ -function normalizeSkillFileContent(content: string): string { - // Strip UTF-8 BOM to ensure frontmatter starts at the first character. - let normalized = content.replace(/^\uFEFF/, ''); - - // Normalize line endings so skills authored on Windows (CRLF) parse correctly. - normalized = normalized.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - - return normalized; -} - export function parseSkillContent( content: string, filePath: string, @@ -78,7 +64,7 @@ export function parseSkillContent( debugLogger.debug(`Parsing skill content from: ${filePath}`); // Normalize content to handle BOM and CRLF line endings - const normalizedContent = normalizeSkillFileContent(content); + const normalizedContent = normalizeContent(content); // Split frontmatter and content // Use (?:\n|$) to allow frontmatter ending with or without trailing newline diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 8ee69e9a0..05eabdd5a 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -20,6 +20,7 @@ import { SkillError, SkillErrorCode } from './types.js'; import type { Config } from '../config/config.js'; import { validateConfig } from './skill-load.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { normalizeContent } from '../utils/textUtils.js'; const debugLogger = createDebugLogger('SKILL_MANAGER'); @@ -333,7 +334,7 @@ export class SkillManager { level: SkillLevel, ): SkillConfig { try { - const normalizedContent = normalizeSkillFileContent(content); + const normalizedContent = normalizeContent(content); // Split frontmatter and content const frontmatterRegex = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/; @@ -649,13 +650,3 @@ export class SkillManager { } } } - -function normalizeSkillFileContent(content: string): string { - // Strip UTF-8 BOM to ensure frontmatter starts at the first character. - let normalized = content.replace(/^\uFEFF/, ''); - - // Normalize line endings so skills authored on Windows (CRLF) parse correctly. - normalized = normalized.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - - return normalized; -} diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index e04964ea1..cf3afb4c8 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -193,6 +193,21 @@ You are a helpful assistant. expect(config.filePath).toBe(validConfig.filePath); }); + it('should parse valid markdown content with CRLF line endings', () => { + const markdownWithCRLF = `---\r\nname: test-agent\r\ndescription: A test subagent\r\n---\r\n\r\nYou are a helpful assistant.\r\n`; + const config = manager.parseSubagentContent( + markdownWithCRLF, + validConfig.filePath!, + 'project', + ); + + expect(config.name).toBe('test-agent'); + expect(config.description).toBe('A test subagent'); + // The system prompt logic applies .trim(), so the trailing \r is removed regardless, + // but the central test is that frontmatterRegex didn't throw an error. + expect(config.systemPrompt).toBe('You are a helpful assistant.'); + }); + it('should parse content with tools', () => { const markdownWithTools = `--- name: test-agent diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index fea33040c..0552fa60c 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -29,6 +29,7 @@ import { SubagentValidator } from './validation.js'; import { SubAgentScope } from './subagent.js'; import type { Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { normalizeContent } from '../utils/textUtils.js'; const debugLogger = createDebugLogger('SUBAGENT_MANAGER'); import { BuiltinAgentRegistry } from './builtin-agents.js'; @@ -908,9 +909,11 @@ function parseSubagentContent( validator: SubagentValidator, ): SubagentConfig { try { + const normalizedContent = normalizeContent(content); + // Split frontmatter and content const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; - const match = content.match(frontmatterRegex); + const match = normalizedContent.match(frontmatterRegex); if (!match) { throw new Error('Invalid format: missing YAML frontmatter'); diff --git a/packages/core/src/telemetry/config.test.ts b/packages/core/src/telemetry/config.test.ts index 1ded8d490..443282fd4 100644 --- a/packages/core/src/telemetry/config.test.ts +++ b/packages/core/src/telemetry/config.test.ts @@ -79,13 +79,13 @@ describe('telemetry/config helpers', () => { useCollector: false, }; const env = { - GEMINI_TELEMETRY_ENABLED: '1', - GEMINI_TELEMETRY_TARGET: 'gcp', - GEMINI_TELEMETRY_OTLP_ENDPOINT: 'http://env:4317', - GEMINI_TELEMETRY_OTLP_PROTOCOL: 'http', - GEMINI_TELEMETRY_LOG_PROMPTS: 'true', - GEMINI_TELEMETRY_OUTFILE: 'env.log', - GEMINI_TELEMETRY_USE_COLLECTOR: 'true', + QWEN_TELEMETRY_ENABLED: '1', + QWEN_TELEMETRY_TARGET: 'gcp', + QWEN_TELEMETRY_OTLP_ENDPOINT: 'http://env:4317', + QWEN_TELEMETRY_OTLP_PROTOCOL: 'http', + QWEN_TELEMETRY_LOG_PROMPTS: 'true', + QWEN_TELEMETRY_OUTFILE: 'env.log', + QWEN_TELEMETRY_USE_COLLECTOR: 'true', } as Record; const argv = { telemetry: false, @@ -133,7 +133,7 @@ describe('telemetry/config helpers', () => { }); it('throws on unknown protocol values', async () => { - const env = { GEMINI_TELEMETRY_OTLP_PROTOCOL: 'unknown' } as Record< + const env = { QWEN_TELEMETRY_OTLP_PROTOCOL: 'unknown' } as Record< string, string >; @@ -143,7 +143,7 @@ describe('telemetry/config helpers', () => { }); it('throws on unknown target values', async () => { - const env = { GEMINI_TELEMETRY_TARGET: 'unknown' } as Record< + const env = { QWEN_TELEMETRY_TARGET: 'unknown' } as Record< string, string >; diff --git a/packages/core/src/telemetry/config.ts b/packages/core/src/telemetry/config.ts index bfca365c8..f1037e742 100644 --- a/packages/core/src/telemetry/config.ts +++ b/packages/core/src/telemetry/config.ts @@ -57,12 +57,12 @@ export async function resolveTelemetrySettings(options: { const enabled = argv.telemetry ?? - parseBooleanEnvFlag(env['GEMINI_TELEMETRY_ENABLED']) ?? + parseBooleanEnvFlag(env['QWEN_TELEMETRY_ENABLED']) ?? settings.enabled; const rawTarget = (argv.telemetryTarget as string | TelemetryTarget | undefined) ?? - env['GEMINI_TELEMETRY_TARGET'] ?? + env['QWEN_TELEMETRY_TARGET'] ?? (settings.target as string | TelemetryTarget | undefined); const target = parseTelemetryTargetValue(rawTarget); if (rawTarget !== undefined && target === undefined) { @@ -75,13 +75,13 @@ export async function resolveTelemetrySettings(options: { const otlpEndpoint = argv.telemetryOtlpEndpoint ?? - env['GEMINI_TELEMETRY_OTLP_ENDPOINT'] ?? + env['QWEN_TELEMETRY_OTLP_ENDPOINT'] ?? env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? settings.otlpEndpoint; const rawProtocol = (argv.telemetryOtlpProtocol as string | undefined) ?? - env['GEMINI_TELEMETRY_OTLP_PROTOCOL'] ?? + env['QWEN_TELEMETRY_OTLP_PROTOCOL'] ?? settings.otlpProtocol; const otlpProtocol = (['grpc', 'http'] as const).find( (p) => p === rawProtocol, @@ -96,16 +96,14 @@ export async function resolveTelemetrySettings(options: { const logPrompts = argv.telemetryLogPrompts ?? - parseBooleanEnvFlag(env['GEMINI_TELEMETRY_LOG_PROMPTS']) ?? + parseBooleanEnvFlag(env['QWEN_TELEMETRY_LOG_PROMPTS']) ?? settings.logPrompts; const outfile = - argv.telemetryOutfile ?? - env['GEMINI_TELEMETRY_OUTFILE'] ?? - settings.outfile; + argv.telemetryOutfile ?? env['QWEN_TELEMETRY_OUTFILE'] ?? settings.outfile; const useCollector = - parseBooleanEnvFlag(env['GEMINI_TELEMETRY_USE_COLLECTOR']) ?? + parseBooleanEnvFlag(env['QWEN_TELEMETRY_USE_COLLECTOR']) ?? settings.useCollector; return { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 98c8d5cac..d9c6b535d 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -362,7 +362,6 @@ export class RipgrepFallbackEvent implements BaseTelemetryEvent { export enum LoopType { CONSECUTIVE_IDENTICAL_TOOL_CALLS = 'consecutive_identical_tool_calls', CHANTING_IDENTICAL_SENTENCES = 'chanting_identical_sentences', - LLM_DETECTED_LOOP = 'llm_detected_loop', } export class LoopDetectedEvent implements BaseTelemetryEvent { diff --git a/packages/core/src/tools/mcp-client-manager.test.ts b/packages/core/src/tools/mcp-client-manager.test.ts index 051c9d87a..140b78324 100644 --- a/packages/core/src/tools/mcp-client-manager.test.ts +++ b/packages/core/src/tools/mcp-client-manager.test.ts @@ -44,6 +44,7 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}), getWorkspaceContext: () => ({}), getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); await manager.discoverAllMcpTools(mockConfig); @@ -68,6 +69,7 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}), getWorkspaceContext: () => ({}), getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); await manager.discoverAllMcpTools(mockConfig); @@ -97,11 +99,13 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}) as PromptRegistry, getWorkspaceContext: () => ({}) as WorkspaceContext, getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); // First connect to create the clients await manager.discoverAllMcpTools({ isTrustedFolder: () => true, + isMcpServerDisabled: () => false, } as unknown as Config); // Clear the disconnect calls from initial stop() in discoverAllMcpTools @@ -131,10 +135,12 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}) as PromptRegistry, getWorkspaceContext: () => ({}) as WorkspaceContext, getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); await manager.discoverAllMcpTools({ isTrustedFolder: () => true, + isMcpServerDisabled: () => false, } as unknown as Config); // Call stop multiple times - should not throw diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 050875a88..ecc700739 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -21,6 +21,27 @@ import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; const debugLogger = createDebugLogger('MCP'); +/** + * Configuration for MCP health monitoring + */ +export interface MCPHealthMonitorConfig { + /** Health check interval in milliseconds (default: 30000ms) */ + checkIntervalMs: number; + /** Number of consecutive failures before marking as disconnected (default: 3) */ + maxConsecutiveFailures: number; + /** Enable automatic reconnection (default: true) */ + autoReconnect: boolean; + /** Delay before reconnection attempt in milliseconds (default: 5000ms) */ + reconnectDelayMs: number; +} + +const DEFAULT_HEALTH_CONFIG: MCPHealthMonitorConfig = { + checkIntervalMs: 30000, // 30 seconds + maxConsecutiveFailures: 3, + autoReconnect: true, + reconnectDelayMs: 5000, // 5 seconds +}; + /** * Manages the lifecycle of multiple MCP clients, including local child processes. * This class is responsible for starting, stopping, and discovering tools from @@ -33,18 +54,24 @@ export class McpClientManager { private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED; private readonly eventEmitter?: EventEmitter; private readonly sendSdkMcpMessage?: SendSdkMcpMessage; + private healthConfig: MCPHealthMonitorConfig; + private healthCheckTimers: Map = new Map(); + private consecutiveFailures: Map = new Map(); + private isReconnecting: Map = new Map(); constructor( config: Config, toolRegistry: ToolRegistry, eventEmitter?: EventEmitter, sendSdkMcpMessage?: SendSdkMcpMessage, + healthConfig?: Partial, ) { this.cliConfig = config; this.toolRegistry = toolRegistry; this.eventEmitter = eventEmitter; this.sendSdkMcpMessage = sendSdkMcpMessage; + this.healthConfig = { ...DEFAULT_HEALTH_CONFIG, ...healthConfig }; } /** @@ -68,6 +95,12 @@ export class McpClientManager { this.eventEmitter?.emit('mcp-client-update', this.clients); const discoveryPromises = Object.entries(servers).map( async ([name, config]) => { + // Skip disabled servers + if (cliConfig.isMcpServerDisabled(name)) { + debugLogger.debug(`Skipping disabled MCP server: ${name}`); + return; + } + // For SDK MCP servers, pass the sendSdkMcpMessage callback const sdkCallback = isSdkMcpServerConfig(config) ? this.sendSdkMcpMessage @@ -160,6 +193,8 @@ export class McpClientManager { try { await client.connect(); await client.discover(cliConfig); + // Start health check for this server after successful discovery + this.startHealthCheck(serverName); } catch (error) { // Log the error but don't throw: callers expect best-effort discovery. debugLogger.error( @@ -177,6 +212,9 @@ export class McpClientManager { * This is the cleanup method to be called on application exit. */ async stop(): Promise { + // Stop all health checks first + this.stopAllHealthChecks(); + const disconnectionPromises = Array.from(this.clients.entries()).map( async ([name, client]) => { try { @@ -191,12 +229,267 @@ export class McpClientManager { await Promise.all(disconnectionPromises); this.clients.clear(); + this.consecutiveFailures.clear(); + this.isReconnecting.clear(); + } + + /** + * Disconnects a specific MCP server. + * @param serverName The name of the server to disconnect. + */ + async disconnectServer(serverName: string): Promise { + // Stop health check for this server + this.stopHealthCheck(serverName); + + const client = this.clients.get(serverName); + if (client) { + try { + await client.disconnect(); + } catch (error) { + debugLogger.error( + `Error disconnecting client '${serverName}': ${getErrorMessage(error)}`, + ); + } finally { + this.clients.delete(serverName); + this.consecutiveFailures.delete(serverName); + this.isReconnecting.delete(serverName); + this.eventEmitter?.emit('mcp-client-update', this.clients); + } + } } getDiscoveryState(): MCPDiscoveryState { return this.discoveryState; } + /** + * Gets the health monitoring configuration + */ + getHealthConfig(): MCPHealthMonitorConfig { + return { ...this.healthConfig }; + } + + /** + * Updates the health monitoring configuration + */ + updateHealthConfig(config: Partial): void { + this.healthConfig = { ...this.healthConfig, ...config }; + // Restart health checks with new configuration + this.stopAllHealthChecks(); + if (this.healthConfig.autoReconnect) { + this.startAllHealthChecks(); + } + } + + /** + * Starts health monitoring for a specific server + */ + private startHealthCheck(serverName: string): void { + if (!this.healthConfig.autoReconnect) { + return; + } + + // Clear existing timer if any + this.stopHealthCheck(serverName); + + const timer = setInterval(async () => { + await this.performHealthCheck(serverName); + }, this.healthConfig.checkIntervalMs); + + this.healthCheckTimers.set(serverName, timer); + } + + /** + * Stops health monitoring for a specific server + */ + private stopHealthCheck(serverName: string): void { + const timer = this.healthCheckTimers.get(serverName); + if (timer) { + clearInterval(timer); + this.healthCheckTimers.delete(serverName); + } + } + + /** + * Stops all health checks + */ + private stopAllHealthChecks(): void { + for (const [, timer] of this.healthCheckTimers.entries()) { + clearInterval(timer); + } + this.healthCheckTimers.clear(); + } + + /** + * Starts health checks for all connected servers + */ + private startAllHealthChecks(): void { + for (const serverName of this.clients.keys()) { + this.startHealthCheck(serverName); + } + } + + /** + * Performs a health check on a specific server + */ + private async performHealthCheck(serverName: string): Promise { + const client = this.clients.get(serverName); + if (!client) { + return; + } + + // Skip if already reconnecting + if (this.isReconnecting.get(serverName)) { + return; + } + + try { + // Check if client is connected by getting its status + const status = client.getStatus(); + + if (status !== MCPServerStatus.CONNECTED) { + // Connection is not healthy + const failures = (this.consecutiveFailures.get(serverName) || 0) + 1; + this.consecutiveFailures.set(serverName, failures); + + debugLogger.warn( + `Health check failed for server '${serverName}' (${failures}/${this.healthConfig.maxConsecutiveFailures})`, + ); + + if (failures >= this.healthConfig.maxConsecutiveFailures) { + // Trigger reconnection + await this.reconnectServer(serverName); + } + } else { + // Connection is healthy, reset failure count + this.consecutiveFailures.set(serverName, 0); + } + } catch (error) { + debugLogger.error( + `Error during health check for server '${serverName}': ${getErrorMessage(error)}`, + ); + } + } + + /** + * Reconnects a specific server + */ + private async reconnectServer(serverName: string): Promise { + if (this.isReconnecting.get(serverName)) { + return; + } + + this.isReconnecting.set(serverName, true); + debugLogger.info(`Attempting to reconnect to server '${serverName}'...`); + + try { + // Wait before reconnecting + await new Promise((resolve) => + setTimeout(resolve, this.healthConfig.reconnectDelayMs), + ); + + await this.discoverMcpToolsForServer(serverName, this.cliConfig); + + // Reset failure count on successful reconnection + this.consecutiveFailures.set(serverName, 0); + debugLogger.info(`Successfully reconnected to server '${serverName}'`); + } catch (error) { + debugLogger.error( + `Failed to reconnect to server '${serverName}': ${getErrorMessage(error)}`, + ); + } finally { + this.isReconnecting.set(serverName, false); + } + } + + /** + * Discovers tools incrementally for all configured servers. + * Only updates servers that have changed or are new. + */ + async discoverAllMcpToolsIncremental(cliConfig: Config): Promise { + if (!cliConfig.isTrustedFolder()) { + return; + } + + const servers = populateMcpServerCommand( + this.cliConfig.getMcpServers() || {}, + this.cliConfig.getMcpServerCommand(), + ); + + this.discoveryState = MCPDiscoveryState.IN_PROGRESS; + + // Find servers that are new or have changed configuration + const serversToUpdate: string[] = []; + const currentServerNames = new Set(this.clients.keys()); + const newServerNames = new Set(Object.keys(servers)); + + // Check for new servers or configuration changes + for (const [name] of Object.entries(servers)) { + const existingClient = this.clients.get(name); + if (!existingClient) { + // New server + serversToUpdate.push(name); + } else if (existingClient.getStatus() === MCPServerStatus.DISCONNECTED) { + // Disconnected server, try to reconnect + serversToUpdate.push(name); + } + // Note: Configuration change detection would require comparing + // the old and new config, which is not implemented here + } + + // Find removed servers + for (const name of currentServerNames) { + if (!newServerNames.has(name)) { + // Server was removed from configuration + await this.removeServer(name); + } + } + + // Update only the servers that need it + const discoveryPromises = serversToUpdate.map(async (name) => { + try { + await this.discoverMcpToolsForServer(name, cliConfig); + } catch (error) { + debugLogger.error( + `Error during incremental discovery for server '${name}': ${getErrorMessage(error)}`, + ); + } + }); + + await Promise.all(discoveryPromises); + + // Start health checks for all connected servers + if (this.healthConfig.autoReconnect) { + this.startAllHealthChecks(); + } + + this.discoveryState = MCPDiscoveryState.COMPLETED; + } + + /** + * Removes a server and its tools + */ + private async removeServer(serverName: string): Promise { + const client = this.clients.get(serverName); + if (client) { + try { + await client.disconnect(); + } catch (error) { + debugLogger.error( + `Error disconnecting removed server '${serverName}': ${getErrorMessage(error)}`, + ); + } + this.clients.delete(serverName); + this.stopHealthCheck(serverName); + this.consecutiveFailures.delete(serverName); + } + + // Remove tools for this server from registry + this.toolRegistry.removeMcpToolsByServer(serverName); + + this.eventEmitter?.emit('mcp-client-update', this.clients); + } + async readResource( serverName: string, uri: string, diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 4ba6c6893..5d48b68c7 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -360,7 +360,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< private readonly cliConfig?: Config, private readonly mcpClient?: McpDirectClient, private readonly mcpTimeout?: number, - private readonly annotations?: McpToolAnnotations, + readonly annotations?: McpToolAnnotations, ) { super( nameOverride ?? diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index a3d738580..d03509451 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -268,10 +268,10 @@ describe('ShellTool', () => { resolveExecutionPromise(fullResult); }; - it('should wrap command on linux and parse pgrep output', async () => { + it('should wrap background command on linux and parse pgrep output', async () => { const invocation = shellTool.build({ - command: 'my-command &', - is_background: false, + command: 'my-command', + is_background: true, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ pid: 54321 }); @@ -291,7 +291,7 @@ describe('ShellTool', () => { false, {}, ); - expect(result.llmContent).toContain('Background PIDs: 54322'); + expect(result.llmContent).toContain('PIDs: 54322'); expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); }); @@ -353,15 +353,11 @@ describe('ShellTool', () => { const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ pid: 54321 }); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n'); - await promise; - const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); - const wrappedCommand = `{ npm test; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + // Foreground commands should not be wrapped with pgrep expect(mockShellExecutionService).toHaveBeenCalledWith( - wrappedCommand, + 'npm test', expect.any(String), expect.any(Function), expect.any(AbortSignal), @@ -383,10 +379,9 @@ describe('ShellTool', () => { resolveShellExecution(); await promise; - const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); - const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + // Foreground commands should not be wrapped with pgrep expect(mockShellExecutionService).toHaveBeenCalledWith( - wrappedCommand, + 'ls', '/test/dir/subdir', expect.any(Function), expect.any(AbortSignal), @@ -733,7 +728,6 @@ describe('ShellTool', () => { await promise; - // On Linux, commands are wrapped with pgrep functionality expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining('npm install'), expect.any(String), @@ -762,7 +756,6 @@ describe('ShellTool', () => { await promise; - // On Linux, commands are wrapped with pgrep functionality expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining('git commit'), expect.any(String), @@ -828,7 +821,6 @@ describe('ShellTool', () => { await promise; - // On Linux, commands are wrapped with pgrep functionality expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining('git commit -m "Initial commit"'), expect.any(String), diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index e55d03626..01a9ac5cf 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -181,15 +181,16 @@ export class ShellToolInvocation extends BaseToolInvocation< finalCommand = finalCommand.trim().replace(/&+$/, '').trim(); } - // pgrep is not available on Windows, so we can't get background PIDs - const commandToExecute = isWindows - ? finalCommand - : (() => { - // wrap command to append subprocess pids (via pgrep) to temporary file - let command = finalCommand.trim(); - if (!command.endsWith('&')) command += ';'; - return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; - })(); + // On non-Windows background commands, wrap with pgrep to capture + // subprocess PIDs so we can report them to the user. + const commandToExecute = + !isWindows && shouldRunInBackground + ? (() => { + let command = finalCommand.trim(); + if (!command.endsWith('&')) command += ';'; + return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; + })() + : finalCommand; const cwd = this.params.directory || this.config.getTargetDir(); @@ -240,7 +241,9 @@ export class ShellToolInvocation extends BaseToolInvocation< } }, combinedSignal, - this.config.getShouldUseNodePtyShell(), + shouldRunInBackground + ? false + : this.config.getShouldUseNodePtyShell(), shellExecutionConfig ?? {}, ); @@ -248,14 +251,11 @@ export class ShellToolInvocation extends BaseToolInvocation< setPidCallback(pid); } - if (shouldRunInBackground) { - // For background tasks, return immediately with PID info - // Note: We cannot reliably detect startup errors for background processes - // since their stdio is typically detached/ignored + // On Windows, background commands rely on early return since there's + // no & backgrounding or pgrep. Awaiting would block until completion. + if (shouldRunInBackground && isWindows) { const pidMsg = pid ? ` PID: ${pid}` : ''; - const killHint = isWindows - ? ' (Use taskkill /F /T /PID to stop)' - : ' (Use kill to stop)'; + const killHint = ' (Use taskkill /F /T /PID to stop)'; return { llmContent: `Background command started.${pidMsg}${killHint}`, @@ -265,27 +265,42 @@ export class ShellToolInvocation extends BaseToolInvocation< const result = await resultPromise; - const backgroundPIDs: number[] = []; - if (os.platform() !== 'win32') { - if (fs.existsSync(tempFilePath)) { - const pgrepLines = fs - .readFileSync(tempFilePath, 'utf8') - .split(EOL) - .filter(Boolean); - for (const line of pgrepLines) { - if (!/^\d+$/.test(line)) { - debugLogger.warn(`pgrep: ${line}`); + if (shouldRunInBackground) { + // Read subprocess PIDs captured by the pgrep wrapper (non-Windows only) + const backgroundPIDs: number[] = []; + if (!isWindows) { + if (fs.existsSync(tempFilePath)) { + const pgrepLines = fs + .readFileSync(tempFilePath, 'utf8') + .split(EOL) + .filter(Boolean); + for (const line of pgrepLines) { + if (!/^\d+$/.test(line)) { + debugLogger.warn(`pgrep: ${line}`); + continue; + } + const bgPid = Number(line); + if (bgPid !== result.pid) { + backgroundPIDs.push(bgPid); + } } - const pid = Number(line); - if (pid !== result.pid) { - backgroundPIDs.push(pid); - } - } - } else { - if (!signal.aborted) { + } else if (!signal.aborted) { debugLogger.warn('missing pgrep output'); } } + + const bgPidMsg = + backgroundPIDs.length > 0 + ? ` PIDs: ${backgroundPIDs.join(', ')}` + : pid + ? ` PID: ${pid}` + : ''; + const killHint = ' (Use kill to stop)'; + + return { + llmContent: `Background command started.${bgPidMsg}${killHint}`, + returnDisplay: `Background command started.${bgPidMsg}${killHint}`, + }; } let llmContent = ''; @@ -327,9 +342,6 @@ export class ShellToolInvocation extends BaseToolInvocation< `Error: ${finalError}`, // Use the cleaned error string. `Exit Code: ${result.exitCode ?? '(none)'}`, `Signal: ${result.signal ?? '(none)'}`, - `Background PIDs: ${ - backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)' - }`, `Process Group PGID: ${result.pid ?? '(none)'}`, ].join('\n'); } diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 1db7f7e59..dc14bef86 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -229,6 +229,28 @@ export class ToolRegistry { } } + /** + * Disables an MCP server by removing its tools, prompts, and disconnecting the client. + * Also updates the config's exclusion list. + * @param serverName The name of the server to disable. + */ + async disableMcpServer(serverName: string): Promise { + // Remove tools from registry + this.removeMcpToolsByServer(serverName); + + // Remove prompts + this.config.getPromptRegistry().removePromptsByServer(serverName); + + // Disconnect the MCP client + await this.mcpClientManager.disconnectServer(serverName); + + // Update config's exclusion list + const currentExcluded = this.config.getExcludedMcpServers() || []; + if (!currentExcluded.includes(serverName)) { + this.config.setExcludedMcpServers([...currentExcluded, serverName]); + } + } + /** * Discovers tools from project (if available and configured). * Can be called multiple times to update discovered tools. diff --git a/packages/core/src/utils/debugLogger.test.ts b/packages/core/src/utils/debugLogger.test.ts index af7d04f48..8549359c0 100644 --- a/packages/core/src/utils/debugLogger.test.ts +++ b/packages/core/src/utils/debugLogger.test.ts @@ -13,6 +13,7 @@ import { type DebugLogSession, } from './debugLogger.js'; import { promises as fs } from 'node:fs'; +import path from 'node:path'; import { Storage } from '../config/storage.js'; vi.mock('node:fs', async (importOriginal) => { @@ -23,6 +24,9 @@ vi.mock('node:fs', async (importOriginal) => { ...actual.promises, mkdir: vi.fn().mockResolvedValue(undefined), appendFile: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + symlink: vi.fn().mockResolvedValue(undefined), + copyFile: vi.fn().mockResolvedValue(undefined), }, }; }); @@ -154,6 +158,7 @@ describe('debugLogger', () => { }); it('returns true when mkdir fails', async () => { + resetDebugLoggingState(); vi.mocked(fs.mkdir).mockRejectedValueOnce(new Error('Permission denied')); const logger = createDebugLogger(); @@ -196,6 +201,55 @@ describe('debugLogger', () => { }); }); + describe('latest debug log symlink', () => { + const expectedLatestPath = path.join(Storage.getGlobalDebugDir(), 'latest'); + + it('creates a symlink to the current session log file', async () => { + resetDebugLoggingState(); + setDebugLogSession(mockSession); + + await vi.runAllTimersAsync(); + + expect(fs.unlink).toHaveBeenCalledWith(expectedLatestPath); + expect(fs.symlink).toHaveBeenCalledWith( + 'test-session-123.txt', + expectedLatestPath, + ); + }); + + it('does not create symlink when session is cleared', async () => { + vi.clearAllMocks(); + resetDebugLoggingState(); + setDebugLogSession(null); + + await vi.runAllTimersAsync(); + + expect(fs.symlink).not.toHaveBeenCalled(); + }); + + it('does not fall back to copy when symlink fails', async () => { + resetDebugLoggingState(); + vi.mocked(fs.symlink).mockRejectedValueOnce(new Error('EPERM')); + + setDebugLogSession(mockSession); + + await vi.runAllTimersAsync(); + + expect(fs.copyFile).not.toHaveBeenCalled(); + }); + + it('does not create symlink when debug logging is disabled', async () => { + process.env['QWEN_DEBUG_LOG_FILE'] = '0'; + vi.clearAllMocks(); + resetDebugLoggingState(); + setDebugLogSession(mockSession); + + await vi.runAllTimersAsync(); + + expect(fs.symlink).not.toHaveBeenCalled(); + }); + }); + describe('resetDebugLoggingState', () => { it('resets the degraded state', async () => { vi.mocked(fs.appendFile).mockRejectedValueOnce(new Error('Disk full')); diff --git a/packages/core/src/utils/debugLogger.ts b/packages/core/src/utils/debugLogger.ts index 8c9e60eae..356028a2f 100644 --- a/packages/core/src/utils/debugLogger.ts +++ b/packages/core/src/utils/debugLogger.ts @@ -5,9 +5,11 @@ */ import { promises as fs } from 'node:fs'; +import path from 'node:path'; import { AsyncLocalStorage } from 'node:async_hooks'; import util from 'node:util'; import { Storage } from '../config/storage.js'; +import { updateSymlink } from './symlink.js'; type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; @@ -115,6 +117,23 @@ export function resetDebugLoggingState(): void { ensureDebugDirPromise = null; } +const DEBUG_LATEST_ALIAS = 'latest'; + +function updateLatestDebugLogAlias(sessionId: string): void { + if (!isDebugLogFileEnabled()) { + return; + } + + const aliasPath = path.join(Storage.getGlobalDebugDir(), DEBUG_LATEST_ALIAS); + const targetPath = Storage.getDebugLogPath(sessionId); + + void ensureDebugDirExists() + .then(() => updateSymlink(aliasPath, targetPath, { fallbackCopy: false })) + .catch(() => { + // Best-effort; don't degrade overall logging + }); +} + /** * Sets the process-wide debug log session used by createDebugLogger(). * @@ -125,6 +144,9 @@ export function setDebugLogSession( session: DebugLogSession | null | undefined, ) { globalSession = session ?? null; + if (session) { + updateLatestDebugLogAlias(session.getSessionId()); + } } /** diff --git a/packages/core/src/utils/ignorePatterns.test.ts b/packages/core/src/utils/ignorePatterns.test.ts index 646c4b6bb..722f72edb 100644 --- a/packages/core/src/utils/ignorePatterns.test.ts +++ b/packages/core/src/utils/ignorePatterns.test.ts @@ -14,7 +14,7 @@ import type { Config } from '../config/config.js'; // Mock the memoryTool module vi.mock('../tools/memoryTool.js', () => ({ - getCurrentGeminiMdFilename: vi.fn(() => 'GEMINI.md'), + getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md', 'AGENTS.md']), })); describe('FileExclusions', () => { @@ -56,6 +56,7 @@ describe('FileExclusions', () => { // Should include dynamic patterns expect(patterns).toContain('**/GEMINI.md'); + expect(patterns).toContain('**/AGENTS.md'); }); it('should respect includeDefaults option', () => { @@ -68,6 +69,7 @@ describe('FileExclusions', () => { expect(patterns).not.toContain('**/node_modules/**'); expect(patterns).not.toContain('**/.git/**'); expect(patterns).not.toContain('**/GEMINI.md'); + expect(patterns).not.toContain('**/AGENTS.md'); expect(patterns).toHaveLength(0); }); @@ -101,7 +103,9 @@ describe('FileExclusions', () => { }); expect(patternsWithDynamic).toContain('**/GEMINI.md'); + expect(patternsWithDynamic).toContain('**/AGENTS.md'); expect(patternsWithoutDynamic).not.toContain('**/GEMINI.md'); + expect(patternsWithoutDynamic).not.toContain('**/AGENTS.md'); }); }); @@ -114,6 +118,7 @@ describe('FileExclusions', () => { expect(patterns).toContain('**/node_modules/**'); expect(patterns).toContain('**/.git/**'); expect(patterns).toContain('**/GEMINI.md'); + expect(patterns).toContain('**/AGENTS.md'); // Should include additional excludes expect(patterns).toContain('**/*.log'); diff --git a/packages/core/src/utils/ignorePatterns.ts b/packages/core/src/utils/ignorePatterns.ts index 9f9776db5..b4a4c2e40 100644 --- a/packages/core/src/utils/ignorePatterns.ts +++ b/packages/core/src/utils/ignorePatterns.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import type { Config } from '../config/config.js'; -import { getCurrentGeminiMdFilename } from '../tools/memoryTool.js'; +import { getAllGeminiMdFilenames } from '../tools/memoryTool.js'; /** * Common ignore patterns used across multiple tools for basic exclusions. @@ -119,7 +119,7 @@ export interface ExcludeOptions { runtimePatterns?: string[]; /** - * Whether to include dynamic patterns like the current Gemini MD filename. Defaults to true. + * Whether to include dynamic patterns like configured context filenames. Defaults to true. */ includeDynamicPatterns?: boolean; } @@ -158,9 +158,11 @@ export class FileExclusions { patterns.push(...DEFAULT_FILE_EXCLUDES); } - // Add dynamic patterns (like current Gemini MD filename) + // Add dynamic patterns (like context filenames) if (includeDynamicPatterns) { - patterns.push(`**/${getCurrentGeminiMdFilename()}`); + for (const filename of getAllGeminiMdFilenames()) { + patterns.push(`**/${filename}`); + } } // Add custom patterns from configuration diff --git a/packages/core/src/utils/symlink.ts b/packages/core/src/utils/symlink.ts new file mode 100644 index 000000000..d000d0103 --- /dev/null +++ b/packages/core/src/utils/symlink.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +export interface UpdateSymlinkOptions { + /** + * When true, falls back to copying the file if symlinks are not + * available (e.g. Windows without elevated privileges). + * Disable this for targets that keep changing after creation (like log + * files) where a one-time copy would be immediately stale. + * + * @default true + */ + fallbackCopy?: boolean; +} + +/** + * Create or replace a symlink at {@link linkPath} pointing to + * {@link targetPath}. + * + * The symlink uses a relative target so it stays valid even when the + * parent directory is moved. + * + * All errors are swallowed — the operation is strictly best-effort. + */ +export async function updateSymlink( + linkPath: string, + targetPath: string, + options?: UpdateSymlinkOptions, +): Promise { + const { fallbackCopy = true } = options ?? {}; + const linkDir = path.dirname(linkPath); + const relativeTarget = path.relative(linkDir, targetPath); + + try { + await fs.unlink(linkPath); + } catch { + // File doesn't exist, ignore + } + + try { + await fs.symlink(relativeTarget, linkPath); + return; + } catch { + // Symlink not supported, try fallback + } + + if (fallbackCopy) { + try { + await fs.copyFile(targetPath, linkPath); + } catch { + // Best-effort; swallow error + } + } +} diff --git a/packages/core/src/utils/textUtils.ts b/packages/core/src/utils/textUtils.ts index ab59c2d59..32c25b89f 100644 --- a/packages/core/src/utils/textUtils.ts +++ b/packages/core/src/utils/textUtils.ts @@ -55,12 +55,21 @@ export function isBinary( } /** - * Normalizes text for cross-platform parsing. - * - Strips UTF-8 BOM at start. - * - Converts CRLF and CR to LF. + * Normalizes text content by stripping the UTF-8 BOM and converting all CRLF (\r\n) + * or standalone CR (\r) line endings to LF (\n). + * + * This is crucial for cross-platform compatibility, particularly to prevent parsing + * failures on Windows where files may be saved with CRLF line endings. + * + * @param content The raw text content to normalize + * @returns The normalized string with uniform \n line endings */ export function normalizeContent(content: string): string { + // Strip UTF-8 BOM to ensure string processing starts at the first real character. let normalized = content.replace(/^\uFEFF/, ''); + + // Normalize line endings to LF (\n). normalized = normalized.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + return normalized; } diff --git a/packages/vscode-ide-companion/.vscodeignore b/packages/vscode-ide-companion/.vscodeignore index 18e07a04b..5d1a75d88 100644 --- a/packages/vscode-ide-companion/.vscodeignore +++ b/packages/vscode-ide-companion/.vscodeignore @@ -6,3 +6,5 @@ !LICENSE !NOTICES.txt !assets/ +!schemas/ +!schemas/** diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 9daf209d9..af27b707a 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -1,5 +1,202 @@ This file contains third-party software notices and license terms. +============================================================ +@agentclientprotocol/sdk@0.14.1 +(git+https://github.com/agentclientprotocol/typescript-sdk.git) + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Zed Industries, Inc. and contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ============================================================ @qwen-code/webui@undefined (No repository found) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 358aa018a..19f5074e3 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -38,10 +38,12 @@ "onCommand:qwen-code.showLogs" ], "contributes": { - "configuration": { - "title": "Qwen Code Companion", - "properties": {} - }, + "jsonValidation": [ + { + "fileMatch": "**/.qwen/settings.json", + "url": "./schemas/settings.schema.json" + } + ], "viewsContainers": { "activitybar": [ { @@ -226,6 +228,7 @@ "vitest": "^3.2.4" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@qwen-code/webui": "*", "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json new file mode 100644 index 000000000..d0eef6ae9 --- /dev/null +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -0,0 +1,622 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "Qwen Code settings configuration", + "properties": { + "mcpServers": { + "description": "Configuration for MCP servers.", + "type": "object", + "additionalProperties": true + }, + "modelProviders": { + "description": "Model providers configuration grouped by authType. Each authType contains an array of model configurations.", + "type": "object", + "additionalProperties": true + }, + "codingPlan": { + "description": "Coding Plan template version tracking and configuration.", + "type": "object", + "properties": { + "version": { + "description": "SHA256 hash of the Coding Plan template. Used to detect template updates.", + "type": "string" + } + } + }, + "env": { + "description": "Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.json env field.", + "type": "object", + "additionalProperties": true + }, + "general": { + "description": "General application settings.", + "type": "object", + "properties": { + "preferredEditor": { + "description": "The preferred editor to open files in.", + "type": "string" + }, + "vimMode": { + "description": "Enable Vim keybindings", + "type": "boolean", + "default": false + }, + "enableAutoUpdate": { + "description": "Enable automatic update checks and installations on startup.", + "type": "boolean", + "default": true + }, + "gitCoAuthor": { + "description": "Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.", + "type": "boolean", + "default": true + }, + "checkpointing": { + "description": "Session checkpointing settings.", + "type": "object", + "properties": { + "enabled": { + "description": "Enable session checkpointing for recovery", + "type": "boolean", + "default": false + } + } + }, + "debugKeystrokeLogging": { + "description": "Enable debug logging of keystrokes to the console.", + "type": "boolean", + "default": false + }, + "language": { + "description": "The language for the user interface. Use \"auto\" to detect from system settings. You can also use custom language codes (e.g., \"es\", \"fr\") by placing JS language files in ~/.qwen/locales/ (e.g., ~/.qwen/locales/es.js). Options: auto, en, zh, ru, de, ja, pt", + "enum": [ + "auto", + "en", + "zh", + "ru", + "de", + "ja", + "pt" + ], + "default": "auto" + }, + "outputLanguage": { + "description": "The language for LLM output. Use \"auto\" to detect from system settings, or set a specific language.", + "type": "string", + "default": "auto" + }, + "terminalBell": { + "description": "Play terminal bell sound when response completes or needs approval.", + "type": "boolean", + "default": true + }, + "chatRecording": { + "description": "Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.", + "type": "boolean", + "default": true + }, + "defaultFileEncoding": { + "description": "Default encoding for new files. Use \"utf-8\" (default) for UTF-8 without BOM, or \"utf-8-bom\" for UTF-8 with BOM. Only change this if your project specifically requires BOM. Options: utf-8, utf-8-bom", + "enum": [ + "utf-8", + "utf-8-bom" + ], + "default": "utf-8" + } + } + }, + "output": { + "description": "Settings for the CLI output.", + "type": "object", + "properties": { + "format": { + "description": "The format of the CLI output. Options: text, json", + "enum": [ + "text", + "json" + ], + "default": "text" + } + } + }, + "ui": { + "description": "User interface settings.", + "type": "object", + "properties": { + "theme": { + "description": "The color theme for the UI.", + "type": "string", + "default": "Qwen Dark" + }, + "customThemes": { + "description": "Custom theme definitions.", + "type": "object", + "additionalProperties": true + }, + "hideWindowTitle": { + "description": "Hide the window title bar", + "type": "boolean", + "default": false + }, + "showStatusInTitle": { + "description": "Show Qwen Code status and thoughts in the terminal window title", + "type": "boolean", + "default": false + }, + "hideTips": { + "description": "Hide helpful tips in the UI", + "type": "boolean", + "default": false + }, + "showLineNumbers": { + "description": "Show line numbers in the code output.", + "type": "boolean", + "default": true + }, + "showCitations": { + "description": "Show citations for generated text in the chat.", + "type": "boolean", + "default": false + }, + "customWittyPhrases": { + "description": "Custom witty phrases to display during loading.", + "type": "array", + "items": { + "type": "string" + } + }, + "enableWelcomeBack": { + "description": "Show welcome back dialog when returning to a project with conversation history.", + "type": "boolean", + "default": true + }, + "enableUserFeedback": { + "description": "Show optional feedback dialog after conversations to help improve Qwen performance.", + "type": "boolean", + "default": true + }, + "accessibility": { + "description": "Accessibility settings.", + "type": "object", + "properties": { + "enableLoadingPhrases": { + "description": "Enable loading phrases (disable for accessibility)", + "type": "boolean", + "default": true + }, + "screenReader": { + "description": "Render output in plain-text to be more screen reader accessible", + "type": "boolean" + } + } + }, + "feedbackLastShownTimestamp": { + "description": "The last time the feedback dialog was shown.", + "type": "number", + "default": 0 + } + } + }, + "ide": { + "description": "IDE integration settings.", + "type": "object", + "properties": { + "enabled": { + "description": "Enable IDE integration mode", + "type": "boolean", + "default": false + }, + "hasSeenNudge": { + "description": "Whether the user has seen the IDE integration nudge.", + "type": "boolean", + "default": false + } + } + }, + "privacy": { + "description": "Privacy-related settings.", + "type": "object", + "properties": { + "usageStatisticsEnabled": { + "description": "Enable collection of usage statistics", + "type": "boolean", + "default": true + } + } + }, + "telemetry": { + "description": "Telemetry configuration.", + "type": "object", + "additionalProperties": true + }, + "model": { + "description": "Settings related to the generative model.", + "type": "object", + "properties": { + "name": { + "description": "The model to use for conversations.", + "type": "string" + }, + "maxSessionTurns": { + "description": "Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.", + "type": "number", + "default": -1 + }, + "summarizeToolOutput": { + "description": "Settings for summarizing tool output.", + "type": "object", + "additionalProperties": true + }, + "chatCompression": { + "description": "Chat compression settings.", + "type": "object", + "additionalProperties": true + }, + "sessionTokenLimit": { + "description": "The maximum number of tokens allowed in a session.", + "type": "number" + }, + "skipNextSpeakerCheck": { + "description": "Skip the next speaker check.", + "type": "boolean", + "default": true + }, + "skipLoopDetection": { + "description": "Disable all loop detection checks (streaming and LLM).", + "type": "boolean", + "default": true + }, + "skipStartupContext": { + "description": "Avoid sending the workspace startup context at the beginning of each session.", + "type": "boolean", + "default": false + }, + "enableOpenAILogging": { + "description": "Enable OpenAI logging.", + "type": "boolean", + "default": false + }, + "openAILoggingDir": { + "description": "Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.", + "type": "string" + }, + "generationConfig": { + "description": "Generation configuration settings.", + "type": "object", + "properties": { + "timeout": { + "description": "Request timeout in milliseconds.", + "type": "number" + }, + "maxRetries": { + "description": "Maximum number of retries for failed requests.", + "type": "number" + }, + "enableCacheControl": { + "description": "Enable cache control for DashScope providers.", + "type": "boolean", + "default": true + }, + "schemaCompliance": { + "description": "The compliance mode for tool schemas sent to the model. Use \"openapi_30\" for strict OpenAPI 3.0 compatibility (e.g., for Gemini). Options: auto, openapi_30", + "enum": [ + "auto", + "openapi_30" + ], + "default": "auto" + }, + "contextWindowSize": { + "description": "Overrides the default context window size for the selected model. Use this setting when a provider's effective context limit differs from Qwen Code's default. This value defines the model's assumed maximum context capacity, not a per-request token limit.", + "type": "number" + } + } + } + } + }, + "context": { + "description": "Settings for managing context provided to the model.", + "type": "object", + "properties": { + "fileName": { + "description": "The name of the context file.", + "type": "object", + "additionalProperties": true + }, + "importFormat": { + "description": "The format to use when importing memory.", + "type": "string" + }, + "includeDirectories": { + "description": "Additional directories to include in the workspace context. Missing directories will be skipped with a warning.", + "type": "array", + "items": { + "type": "string" + } + }, + "loadFromIncludeDirectories": { + "description": "Whether to load memory files from include directories.", + "type": "boolean", + "default": false + }, + "fileFiltering": { + "description": "Settings for git-aware file filtering.", + "type": "object", + "properties": { + "respectGitIgnore": { + "description": "Respect .gitignore files when searching", + "type": "boolean", + "default": true + }, + "respectQwenIgnore": { + "description": "Respect .qwenignore files when searching", + "type": "boolean", + "default": true + }, + "enableRecursiveFileSearch": { + "description": "Enable recursive file search functionality", + "type": "boolean", + "default": true + }, + "enableFuzzySearch": { + "description": "Enable fuzzy search when searching for files.", + "type": "boolean", + "default": true + } + } + } + } + }, + "tools": { + "description": "Settings for built-in and custom tools.", + "type": "object", + "properties": { + "sandbox": { + "description": "Sandbox execution environment (can be a boolean or a path string).", + "type": "object", + "additionalProperties": true + }, + "shell": { + "description": "Settings for shell execution.", + "type": "object", + "properties": { + "enableInteractiveShell": { + "description": "Use node-pty for an interactive shell experience. Falls back to child_process if PTY is unavailable.", + "type": "boolean", + "default": true + }, + "pager": { + "description": "The pager command to use for shell output. Defaults to `cat`.", + "type": "string", + "default": "cat" + }, + "showColor": { + "description": "Show color in shell output.", + "type": "boolean", + "default": false + } + } + }, + "core": { + "description": "Paths to core tool definitions.", + "type": "array", + "items": { + "type": "string" + } + }, + "allowed": { + "description": "A list of tool names that will bypass the confirmation dialog.", + "type": "array", + "items": { + "type": "string" + } + }, + "exclude": { + "description": "Tool names to exclude from discovery.", + "type": "array", + "items": { + "type": "string" + } + }, + "approvalMode": { + "description": "Approval mode for tool usage. Controls how tools are approved before execution. Options: plan, default, auto-edit, yolo", + "enum": [ + "plan", + "default", + "auto-edit", + "yolo" + ], + "default": "default" + }, + "autoAccept": { + "description": "Automatically accept and execute tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation.", + "type": "boolean", + "default": false + }, + "discoveryCommand": { + "description": "Command to run for tool discovery.", + "type": "string" + }, + "callCommand": { + "description": "Command to run for tool calls.", + "type": "string" + }, + "useRipgrep": { + "description": "Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.", + "type": "boolean", + "default": true + }, + "useBuiltinRipgrep": { + "description": "Use the bundled ripgrep binary. When set to false, the system-level \"rg\" command will be used instead. This setting is only effective when useRipgrep is true.", + "type": "boolean", + "default": true + }, + "enableToolOutputTruncation": { + "description": "Enable truncation of large tool outputs.", + "type": "boolean", + "default": true + }, + "truncateToolOutputThreshold": { + "description": "Truncate tool output if it is larger than this many characters. Set to -1 to disable.", + "type": "number", + "default": 25000 + }, + "truncateToolOutputLines": { + "description": "The number of lines to keep when truncating tool output.", + "type": "number", + "default": 1000 + } + } + }, + "mcp": { + "description": "Settings for Model Context Protocol (MCP) servers.", + "type": "object", + "properties": { + "serverCommand": { + "description": "Command to start an MCP server.", + "type": "string" + }, + "allowed": { + "description": "A list of MCP servers to allow.", + "type": "array", + "items": { + "type": "string" + } + }, + "excluded": { + "description": "A list of MCP servers to exclude.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "security": { + "description": "Security-related settings.", + "type": "object", + "properties": { + "folderTrust": { + "description": "Settings for folder trust.", + "type": "object", + "properties": { + "enabled": { + "description": "Setting to track whether Folder trust is enabled.", + "type": "boolean", + "default": false + } + } + }, + "auth": { + "description": "Authentication settings.", + "type": "object", + "properties": { + "selectedType": { + "description": "The currently selected authentication type.", + "type": "string" + }, + "enforcedType": { + "description": "The required auth type. If this does not match the selected auth type, the user will be prompted to re-authenticate.", + "type": "string" + }, + "useExternal": { + "description": "Whether to use an external authentication flow.", + "type": "boolean" + }, + "apiKey": { + "description": "API key for OpenAI compatible authentication.", + "type": "string" + }, + "baseUrl": { + "description": "Base URL for OpenAI compatible API.", + "type": "string" + } + } + } + } + }, + "advanced": { + "description": "Advanced settings for power users.", + "type": "object", + "properties": { + "autoConfigureMemory": { + "description": "Automatically configure Node.js memory limits", + "type": "boolean", + "default": false + }, + "dnsResolutionOrder": { + "description": "The DNS resolution order.", + "type": "string" + }, + "excludedEnvVars": { + "description": "Environment variables to exclude from project context.", + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "DEBUG", + "DEBUG_MODE" + ] + }, + "bugCommand": { + "description": "Configuration for the bug report command.", + "type": "object", + "additionalProperties": true + }, + "tavilyApiKey": { + "description": "⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.", + "type": "string" + } + } + }, + "webSearch": { + "description": "Configuration for web search providers.", + "type": "object", + "additionalProperties": true + }, + "hooksConfig": { + "description": "Hook configurations for intercepting and customizing agent behavior.", + "type": "object", + "properties": { + "enabled": { + "description": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.", + "type": "boolean", + "default": true + }, + "disabled": { + "description": "List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "hooks": { + "description": "Hook event configurations for extending CLI behavior at various lifecycle points.", + "type": "object", + "properties": { + "UserPromptSubmit": { + "description": "Hooks that execute before agent processing. Can modify prompts or inject context.", + "type": "array", + "items": { + "type": "string" + } + }, + "Stop": { + "description": "Hooks that execute after agent processing. Can post-process responses or log interactions.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "$version": { + "type": "number", + "description": "Settings schema version for migration tracking.", + "default": 3 + } + }, + "additionalProperties": true +} diff --git a/packages/vscode-ide-companion/src/constants/acpSchema.ts b/packages/vscode-ide-companion/src/constants/acpSchema.ts index 7cd8d4c09..526085293 100644 --- a/packages/vscode-ide-companion/src/constants/acpSchema.ts +++ b/packages/vscode-ide-companion/src/constants/acpSchema.ts @@ -4,41 +4,30 @@ * 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', - session_set_model: 'session/set_model', -} as const; +export { + AGENT_METHODS, + CLIENT_METHODS, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; -export const CLIENT_METHODS = { - fs_read_text_file: 'fs/read_text_file', - fs_write_text_file: 'fs/write_text_file', +export { RequestError } from '@agentclientprotocol/sdk'; + +// Local extension: authenticate/update is not part of the ACP spec. +// It is routed as an extension notification by our CLI. +export const EXT_CLIENT_METHODS = { authenticate_update: 'authenticate/update', - session_request_permission: 'session/request_permission', - session_update: 'session/update', } as const; +// Re-export error codes in the shape that existing consumers expect. +// The numeric values match the SDK's ErrorCode type. export const ACP_ERROR_CODES = { - // Parse error: invalid JSON received by server. PARSE_ERROR: -32700, - // Invalid request: JSON is not a valid Request object. INVALID_REQUEST: -32600, - // Method not found: method does not exist or is unavailable. METHOD_NOT_FOUND: -32601, - // Invalid params: invalid method parameter(s). INVALID_PARAMS: -32602, - // Internal error: implementation-defined server error. INTERNAL_ERROR: -32603, - // Authentication required: must authenticate before operation. + REQUEST_CANCELLED: -32800, AUTH_REQUIRED: -32000, - // Resource not found: e.g. missing file. RESOURCE_NOT_FOUND: -32002, } as const; diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 0a5aec02c..ce05d6d64 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -4,64 +4,62 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { JSONRPC_VERSION } from '../types/acpTypes.js'; -import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; import type { - AcpMessage, - AcpPermissionRequest, - AcpResponse, - AcpSessionUpdate, - AuthenticateUpdateNotification, -} from '../types/acpTypes.js'; + Client, + Agent, + SessionNotification, + RequestPermissionRequest, + RequestPermissionResponse, + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, + AuthenticateResponse, + NewSessionResponse, + LoadSessionResponse, + ListSessionsResponse, + PromptResponse, + SetSessionModeResponse, + SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; +import type { 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 { Readable, Writable } from 'node:stream'; import * as fs from 'node:fs'; +import { AcpFileHandler } from './acpFileHandler.js'; /** * ACP Connection Handler for VSCode Extension * - * This class implements the client side of the ACP (Agent Communication Protocol). + * External API preserved for backward compatibility. + * Internally uses SDK ClientSideConnection + ndJsonStream for protocol handling. */ 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 sdkConnection: ClientSideConnection | null = null; + private sessionId: string | null = null; private workingDir: string = process.cwd(); + private fileHandler = new AcpFileHandler(); - private messageHandler: AcpMessageHandler; - private sessionManager: AcpSessionManager; - - onSessionUpdate: (data: AcpSessionUpdate) => void = () => {}; - onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + onSessionUpdate: (data: SessionNotification) => void = () => {}; + onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ optionId: string; - }> = () => Promise.resolve({ optionId: 'allow' }); + }> = (data) => + Promise.resolve({ + optionId: this.resolvePermissionOptionId(data) || '', + }); onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void = () => {}; - onEndTurn: () => void = () => {}; - // Called after successful initialize() with the initialize result + onEndTurn: (reason?: string) => void = () => {}; 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(), @@ -75,8 +73,6 @@ export class AcpConnection { 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, ); @@ -84,15 +80,12 @@ export class AcpConnection { 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, @@ -113,7 +106,6 @@ export class AcpConnection { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], env, - // We spawn node directly; no shell needed (and shell quoting can break paths). shell: false, }; @@ -121,13 +113,10 @@ export class AcpConnection { await this.setupChildProcessHandlers(); } - /** - * Set up child process handlers - */ private async setupChildProcessHandlers(): Promise { let spawnError: Error | null = null; - this.child!.stderr?.on('data', (data) => { + this.child!.stderr?.on('data', (data: Buffer) => { const message = data.toString(); if ( message.toLowerCase().includes('error') && @@ -139,19 +128,16 @@ export class AcpConnection { } }); - this.child!.on('error', (error) => { + this.child!.on('error', (error: Error) => { spawnError = error; }); - this.child!.on('exit', (code, signal) => { + this.child!.on('exit', (code: number | null, signal: string | null) => { console.error( `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, ); - // Clear pending requests when process exits - this.pendingRequests.clear(); }); - // Wait for process to start await new Promise((resolve) => setTimeout(resolve, 1000)); if (spawnError) { @@ -162,291 +148,332 @@ export class AcpConnection { 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() || ''; + // Convert Node.js child process streams to Web Streams for SDK + const stdout = Readable.toWeb( + this.child.stdout!, + ) as ReadableStream; + const stdin = Writable.toWeb(this.child.stdin!) as WritableStream; - for (const line of lines) { - if (line.trim()) { + const stream = ndJsonStream(stdin, stdout); + + // Build the SDK Client implementation that bridges to our callbacks + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + this.sdkConnection = new ClientSideConnection( + (_agent: Agent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + console.log( + '[ACP] >>> Processing session_update:', + JSON.stringify(params).substring(0, 300), + ); + self.onSessionUpdate(params as unknown as SessionNotification); + return Promise.resolve(); + }, + + async requestPermission( + params: RequestPermissionRequest, + ): Promise { + const permissionData = params as unknown as RequestPermissionRequest; 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), - ); - } - } - } - }); + const response = await self.onPermissionRequest(permissionData); + const optionId = response?.optionId; + console.log('[ACP] Permission request:', optionId); + let outcome: 'selected' | 'cancelled'; + if ( + optionId && + (optionId.includes('reject') || optionId === 'cancel') + ) { + outcome = 'cancelled'; + } else { + outcome = 'selected'; + } + console.log('[ACP] Permission outcome:', outcome); - // Initialize protocol - const res = await this.sessionManager.initialize( - this.child, - this.pendingRequests, - this.nextRequestId, + if (outcome === 'cancelled') { + return { outcome: { outcome: 'cancelled' } }; + } + const selectedOptionId = self.resolvePermissionOptionId( + permissionData, + optionId, + ); + if (!selectedOptionId) { + return { outcome: { outcome: 'cancelled' } }; + } + return { + outcome: { + outcome: 'selected', + optionId: selectedOptionId, + }, + }; + } catch (_error) { + return { outcome: { outcome: 'cancelled' } }; + } + }, + + async readTextFile( + params: ReadTextFileRequest, + ): Promise { + const result = await self.fileHandler.handleReadTextFile({ + path: params.path, + sessionId: params.sessionId, + line: params.line ?? null, + limit: params.limit ?? null, + }); + return { content: result.content }; + }, + + async writeTextFile( + params: WriteTextFileRequest, + ): Promise { + await self.fileHandler.handleWriteTextFile({ + path: params.path, + content: params.content, + sessionId: params.sessionId, + }); + return {}; + }, + + async extNotification( + method: string, + params: Record, + ): Promise { + if (method === 'authenticate/update') { + console.log( + '[ACP] >>> Processing authenticate_update:', + JSON.stringify(params).substring(0, 300), + ); + self.onAuthenticateUpdate( + params as unknown as AuthenticateUpdateNotification, + ); + } else { + console.warn(`[ACP] Unhandled extension notification: ${method}`); + } + }, + }), + stream, ); - console.log('[ACP] Initialization response:', res); + // Initialize protocol via SDK + console.log('[ACP] Sending initialize request...'); + const initResponse = await this.sdkConnection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }); + + console.log('[ACP] Initialize successful'); + console.log('[ACP] Initialization response:', initResponse); try { - this.onInitialized(res); + this.onInitialized(initResponse); } 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') { - const errorMessage = - error instanceof Error - ? error.message - : typeof error === 'object' && - error !== null && - 'message' in error && - typeof (error as { message: unknown }).message === 'string' - ? (error as { message: string }).message - : String(error); - - let errorCode: number = ACP_ERROR_CODES.INTERNAL_ERROR; - const errorCodeValue = - typeof error === 'object' && error !== null && 'code' in error - ? (error as { code?: unknown }).code - : undefined; - - if (typeof errorCodeValue === 'number') { - errorCode = errorCodeValue; - } else if (errorCodeValue === 'ENOENT') { - errorCode = ACP_ERROR_CODES.RESOURCE_NOT_FOUND; - } - - this.messageHandler.sendResponseMessage(this.child, { - jsonrpc: JSONRPC_VERSION, - id: message.id, - error: { - code: errorCode, - message: errorMessage, - }, - }); - } - }); - } else { - // Response - this.messageHandler.handleMessage( - message, - this.pendingRequests, - callbacks, - ); + private ensureConnection(): ClientSideConnection { + if (!this.sdkConnection) { + throw new Error('Not connected to ACP agent'); } + return this.sdkConnection; } - /** - * 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, + private resolvePermissionOptionId( + request: RequestPermissionRequest, + preferredOptionId?: string, + ): string | undefined { + // ACP permission options expose two different identifiers: + // - `kind` (e.g. "allow_once"), used for UX intent + // - `optionId` (e.g. "proceed_once"), which the CLI parses as ToolConfirmationOutcome. + // We must always return a real optionId from request.options; sending `kind` + // as optionId (like "allow_once") will fail enum parsing on the CLI side. + const options = Array.isArray(request.options) ? request.options : []; + if (options.length === 0) { + return undefined; + } + + if ( + preferredOptionId && + options.some((option) => option.optionId === preferredOptionId) + ) { + return preferredOptionId; + } + + return ( + options.find((option) => option.kind === 'allow_once')?.optionId || + options.find((option) => option.optionId === 'proceed_once')?.optionId || + options.find((option) => option.optionId.includes('proceed_once')) + ?.optionId || + options[0]?.optionId ); } - /** - * Create new session - * - * @param cwd - Working directory - * @returns New session response - */ - async newSession(cwd: string = process.cwd()): Promise { - return this.sessionManager.newSession( + async authenticate(methodId?: string): Promise { + const conn = this.ensureConnection(); + const authMethodId = methodId || 'default'; + console.log( + '[ACP] Sending authenticate request with methodId:', + authMethodId, + ); + const response = await conn.authenticate({ methodId: authMethodId }); + console.log('[ACP] Authenticate successful', response); + return response; + } + + async newSession(cwd: string = process.cwd()): Promise { + const conn = this.ensureConnection(); + console.log('[ACP] Sending session/new request with cwd:', cwd); + const response: NewSessionResponse = await conn.newSession({ cwd, - this.child, - this.pendingRequests, - this.nextRequestId, - ); + mcpServers: [], + }); + this.sessionId = response.sessionId || null; + console.log('[ACP] Session created with ID:', this.sessionId); + return response; } - /** - * 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, - ); + async sendPrompt(prompt: string): Promise { + const conn = this.ensureConnection(); + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + const response: PromptResponse = await conn.prompt({ + sessionId: this.sessionId, + prompt: [{ type: 'text', text: prompt }], + }); + // Emit end-of-turn from stopReason + if (response.stopReason) { + this.onEndTurn(response.stopReason); + } else { + this.onEndTurn(); + } + return response; } - /** - * 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, - ); + ): Promise { + const conn = this.ensureConnection(); + console.log('[ACP] Sending session/load request for session:', sessionId); + const cwd = cwdOverride || this.workingDir; + try { + const response = await conn.loadSession({ + sessionId, + cwd, + mcpServers: [], + }); + console.log( + '[ACP] Session load succeeded. Response:', + JSON.stringify(response), + ); + this.sessionId = sessionId; + return response; + } catch (error) { + console.error( + '[ACP] Session load request failed:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } } - /** - * 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, + }): Promise { + const conn = this.ensureConnection(); + console.log('[ACP] Requesting session list...'); + try { + const params: Record = { cwd: this.workingDir }; + if (options?.cursor !== undefined) { + params['cursor'] = String(options.cursor); + } + if (options?.size !== undefined) { + params['size'] = options.size; + } + const response = await conn.unstable_listSessions( + params as Parameters[0], + ); + 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; + } + } + + async switchSession(sessionId: string): Promise { + console.log('[ACP] Switching to session:', sessionId); + this.sessionId = sessionId; + console.log( + '[ACP] Session ID updated locally (switch not supported by CLI)', ); } - /** - * 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); + const conn = this.ensureConnection(); + if (!this.sessionId) { + console.warn('[ACP] No active session to cancel'); + return; + } + console.log('[ACP] Cancelling session:', this.sessionId); + await conn.cancel({ sessionId: this.sessionId }); + console.log('[ACP] Cancel notification sent'); } - /** - * 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( + async setMode(modeId: ApprovalModeValue): Promise { + const conn = this.ensureConnection(); + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + console.log('[ACP] Sending session/set_mode:', modeId); + const res = await conn.setSessionMode({ + sessionId: this.sessionId, modeId, - this.child, - this.pendingRequests, - this.nextRequestId, - ); + }); + console.log('[ACP] set_mode response:', res); + return res; } - /** - * Set model for current session - * - * @param modelId - Model ID - * @returns Set model response - */ - async setModel(modelId: string): Promise { - return this.sessionManager.setModel( + async setModel(modelId: string): Promise { + const conn = this.ensureConnection(); + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + console.log('[ACP] Sending session/set_model:', modelId); + const res = await conn.unstable_setSessionModel({ + sessionId: this.sessionId, modelId, - this.child, - this.pendingRequests, - this.nextRequestId, - ); + }); + console.log('[ACP] set_model response:', res); + return res; } - /** - * Disconnect - */ disconnect(): void { if (this.child) { this.child.kill(); this.child = null; } - - this.pendingRequests.clear(); - this.sessionManager.reset(); + this.sdkConnection = null; + this.sessionId = null; } - /** - * 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; + return this.sessionId !== null; } - /** - * Get current session ID - */ get currentSessionId(): string | null { - return this.sessionManager.getCurrentSessionId(); + return this.sessionId; } } diff --git a/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts b/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts new file mode 100644 index 000000000..fa87c9ab0 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AcpFileHandler } from './acpFileHandler.js'; +import { promises as fs } from 'fs'; + +vi.mock('fs', () => ({ + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, +})); + +describe('AcpFileHandler', () => { + let handler: AcpFileHandler; + + beforeEach(() => { + handler = new AcpFileHandler(); + vi.clearAllMocks(); + }); + + describe('handleReadTextFile', () => { + it('returns full content when no line/limit specified', async () => { + vi.mocked(fs.readFile).mockResolvedValue('line1\nline2\nline3\n'); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: null, + limit: null, + }); + + expect(result.content).toBe('line1\nline2\nline3\n'); + }); + + it('uses 1-based line indexing (ACP spec)', async () => { + vi.mocked(fs.readFile).mockResolvedValue( + 'line1\nline2\nline3\nline4\nline5', + ); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: 2, + limit: 2, + }); + + expect(result.content).toBe('line2\nline3'); + }); + + it('treats line=1 as first line', async () => { + vi.mocked(fs.readFile).mockResolvedValue('first\nsecond\nthird'); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: 1, + limit: 1, + }); + + expect(result.content).toBe('first'); + }); + + it('defaults to line=1 when line is null but limit is set', async () => { + vi.mocked(fs.readFile).mockResolvedValue('a\nb\nc\nd'); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: null, + limit: 2, + }); + + expect(result.content).toBe('a\nb'); + }); + + it('clamps negative line values to 0', async () => { + vi.mocked(fs.readFile).mockResolvedValue('a\nb\nc'); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: -5, + limit: null, + }); + + expect(result.content).toBe('a\nb\nc'); + }); + + it('propagates ENOENT errors', async () => { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(err); + + await expect( + handler.handleReadTextFile({ + path: '/missing/file.txt', + sessionId: 'sid', + line: null, + limit: null, + }), + ).rejects.toThrow('ENOENT'); + }); + }); + + describe('handleWriteTextFile', () => { + it('creates directories and writes file', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const result = await handler.handleWriteTextFile({ + path: '/test/dir/file.txt', + content: 'hello', + sessionId: 'sid', + }); + + expect(result).toBeNull(); + expect(fs.mkdir).toHaveBeenCalledWith('/test/dir', { recursive: true }); + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/dir/file.txt', + 'hello', + 'utf-8', + ); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/acpFileHandler.ts b/packages/vscode-ide-companion/src/services/acpFileHandler.ts index 2416ceb37..e41240788 100644 --- a/packages/vscode-ide-companion/src/services/acpFileHandler.ts +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.ts @@ -48,10 +48,11 @@ export class AcpFileHandler { `[ACP] Successfully read file: ${params.path} (${content.length} bytes)`, ); - // Handle line offset and limit + // Handle line offset and limit. + // ACP spec: `line` is 1-based (first line = 1). if (params.line !== null || params.limit !== null) { const lines = content.split('\n'); - const startLine = params.line || 0; + const startLine = Math.max(0, (params.line ?? 1) - 1); const endLine = params.limit ? startLine + params.limit : lines.length; const selectedLines = lines.slice(startLine, endLine); const result = { content: selectedLines.join('\n') }; diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts deleted file mode 100644 index c2fad7701..000000000 --- a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @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'; -import { isWindows } from '../utils/platform.js'; - -/** - * 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 = isWindows ? '\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.test.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.test.ts deleted file mode 100644 index 17e3e4f8e..000000000 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AcpSessionManager } from './acpSessionManager.js'; -import type { ChildProcess } from 'child_process'; -import type { PendingRequest } from '../types/connectionTypes.js'; -import { AGENT_METHODS } from '../constants/acpSchema.js'; - -describe('AcpSessionManager', () => { - let sessionManager: AcpSessionManager; - let mockChild: ChildProcess; - let pendingRequests: Map>; - let nextRequestId: { value: number }; - let writtenMessages: string[]; - - beforeEach(() => { - sessionManager = new AcpSessionManager(); - writtenMessages = []; - - mockChild = { - stdin: { - write: vi.fn((msg: string) => { - writtenMessages.push(msg); - // Simulate async response - const parsed = JSON.parse(msg.trim()); - const id = parsed.id; - setTimeout(() => { - const pending = pendingRequests.get(id); - if (pending) { - pending.resolve({ modeId: 'default', modelId: 'test-model' }); - pendingRequests.delete(id); - } - }, 10); - }), - }, - } as unknown as ChildProcess; - - pendingRequests = new Map(); - nextRequestId = { value: 0 }; - }); - - describe('setModel', () => { - it('sends session/set_model request with correct parameters', async () => { - // First initialize the session - // @ts-expect-error - accessing private property for testing - sessionManager.sessionId = 'test-session-id'; - - const responsePromise = sessionManager.setModel( - 'qwen3-coder-plus', - mockChild, - pendingRequests, - nextRequestId, - ); - - // Wait for the response - const response = await responsePromise; - - // Verify the message was sent - expect(writtenMessages.length).toBe(1); - const sentMessage = JSON.parse(writtenMessages[0].trim()); - - expect(sentMessage.method).toBe(AGENT_METHODS.session_set_model); - expect(sentMessage.params).toEqual({ - sessionId: 'test-session-id', - modelId: 'qwen3-coder-plus', - }); - expect(response).toEqual({ modeId: 'default', modelId: 'test-model' }); - }); - - it('throws error when no active session', async () => { - await expect( - sessionManager.setModel( - 'qwen3-coder-plus', - mockChild, - pendingRequests, - nextRequestId, - ), - ).rejects.toThrow('No active ACP session'); - }); - - it('increments request ID for each call', async () => { - // @ts-expect-error - accessing private property for testing - sessionManager.sessionId = 'test-session-id'; - - await sessionManager.setModel( - 'model-1', - mockChild, - pendingRequests, - nextRequestId, - ); - - await sessionManager.setModel( - 'model-2', - mockChild, - pendingRequests, - nextRequestId, - ); - - const firstMessage = JSON.parse(writtenMessages[0].trim()); - const secondMessage = JSON.parse(writtenMessages[1].trim()); - - expect(firstMessage.id).toBe(0); - expect(secondMessage.id).toBe(1); - }); - }); - - describe('setMode', () => { - it('sends session/set_mode request with correct parameters', async () => { - // @ts-expect-error - accessing private property for testing - sessionManager.sessionId = 'test-session-id'; - - const responsePromise = sessionManager.setMode( - 'auto-edit', - mockChild, - pendingRequests, - nextRequestId, - ); - - const response = await responsePromise; - - expect(writtenMessages.length).toBe(1); - const sentMessage = JSON.parse(writtenMessages[0].trim()); - - expect(sentMessage.method).toBe(AGENT_METHODS.session_set_mode); - expect(sentMessage.params).toEqual({ - sessionId: 'test-session-id', - modeId: 'auto-edit', - }); - expect(response).toBeDefined(); - }); - - it('throws error when no active session', async () => { - await expect( - sessionManager.setMode( - 'default', - mockChild, - pendingRequests, - nextRequestId, - ), - ).rejects.toThrow('No active ACP session'); - }); - }); -}); diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts deleted file mode 100644 index 240bd5736..000000000 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.ts +++ /dev/null @@ -1,511 +0,0 @@ -/** - * @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'; -import { isWindows } from '../utils/platform.js'; - -/** - * 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) => { - // No timeout for session_prompt as LLM tasks can take 5-10 minutes or longer - // The request should always terminate with a stop_reason - let timeoutId: NodeJS.Timeout | undefined; - let timeoutDuration: number | undefined; - - if (method !== AGENT_METHODS.session_prompt) { - // Set timeout for other methods - timeoutDuration = method === AGENT_METHODS.initialize ? 120000 : 60000; - timeoutId = setTimeout(() => { - pendingRequests.delete(id); - reject(new Error(`Request ${method} timed out`)); - }, timeoutDuration); - } - - const pendingRequest: PendingRequest = { - resolve: (value: T) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - resolve(value); - }, - reject: (error: Error) => { - if (timeoutId) { - 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 = isWindows ? '\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; - } - - /** - * Set model for current session (ACP session/set_model) - * - * @param modelId - Model ID - */ - async setModel( - modelId: string, - 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_model:', modelId); - const res = await this.sendRequest( - AGENT_METHODS.session_set_model, - { sessionId: this.sessionId, modelId }, - child, - pendingRequests, - nextRequestId, - ); - console.log('[ACP] set_model 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/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 0944ee5b7..38113dd08 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -5,12 +5,12 @@ */ import { AcpConnection } from './acpConnection.js'; import type { - AcpSessionUpdate, - AcpPermissionRequest, - AuthenticateUpdateNotification, ModelInfo, AvailableCommand, -} from '../types/acpTypes.js'; + RequestPermissionRequest, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import type { AuthenticateUpdateNotification } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; import { QwenSessionManager } from './qwenSessionManager.js'; @@ -29,6 +29,7 @@ import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; import { authMethod } from '../types/acpTypes.js'; import { extractModelInfoFromNewSessionResult, + extractSessionModeState, extractSessionModelState, } from '../utils/acpModelInfo.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js'; @@ -65,6 +66,18 @@ export class QwenAgentManager { // Callback storage private callbacks: QwenAgentCallbacks = {}; + // Baseline state from session/new (default/settings-backed), used to clear stale + // UI mode/model when session/load response omits optional fields. + private baselineModeId: ApprovalModeValue = 'default'; + private baselineAvailableModes: + | Array<{ + id: ApprovalModeValue; + name: string; + description: string; + }> + | undefined; + private baselineModelInfo: ModelInfo | null = null; + private baselineAvailableModels: ModelInfo[] = []; constructor() { this.connection = new AcpConnection(); @@ -74,9 +87,13 @@ export class QwenAgentManager { this.sessionUpdateHandler = new QwenSessionUpdateHandler({}); // Set ACP connection callbacks - this.connection.onSessionUpdate = (data: AcpSessionUpdate) => { + this.connection.onSessionUpdate = (data: SessionNotification) => { // If we are rehydrating a loaded session, map message chunks into - // full messages for the UI, instead of streaming behavior. + // discrete messages for the UI instead of streaming behavior. + // During rehydration the webview is NOT in streaming mode, so + // streaming-only callbacks (onStreamChunk, onThoughtChunk) would be + // silently dropped by the UI. Route all text-bearing updates through + // onMessage which calls addMessage() regardless of streaming state. try { const targetId = this.rehydratingSessionId; if ( @@ -91,19 +108,18 @@ export class QwenAgentManager { update: { sessionUpdate: string; content?: { text?: string }; - _meta?: { timestamp?: number }; + _meta?: Record; }; } ).update; const text = update?.content?.text || ''; + const metaObj = update?._meta ?? {}; const timestamp = - typeof update?._meta?.timestamp === 'number' - ? update._meta.timestamp + typeof metaObj['timestamp'] === 'number' + ? (metaObj['timestamp'] as number) : Date.now(); + if (update?.sessionUpdate === 'user_message_chunk' && text) { - console.log( - '[QwenAgentManager] Rehydration: routing user message chunk', - ); this.callbacks.onMessage?.({ role: 'user', content: text, @@ -111,10 +127,8 @@ export class QwenAgentManager { }); return; } + if (update?.sessionUpdate === 'agent_message_chunk' && text) { - console.log( - '[QwenAgentManager] Rehydration: routing agent message chunk', - ); this.callbacks.onMessage?.({ role: 'assistant', content: text, @@ -122,10 +136,44 @@ export class QwenAgentManager { }); return; } - // For other types during rehydration, fall through to normal handler - console.log( - '[QwenAgentManager] Rehydration: non-text update, forwarding to handler', - ); + + if (update?.sessionUpdate === 'agent_thought_chunk' && text) { + this.callbacks.onMessage?.({ + role: 'thinking', + content: text, + timestamp, + }); + return; + } + + // Usage-only agent_message_chunk (empty text): forward usage but + // skip the empty stream chunk that would be discarded anyway. + if ( + update?.sessionUpdate === 'agent_message_chunk' && + !text && + metaObj['usage'] + ) { + if (this.callbacks.onUsageUpdate) { + const raw = metaObj['usage'] as Record; + this.callbacks.onUsageUpdate({ + usage: { + inputTokens: raw['inputTokens'] as number | undefined, + outputTokens: raw['outputTokens'] as number | undefined, + totalTokens: raw['totalTokens'] as number | undefined, + thoughtTokens: raw['thoughtTokens'] as number | undefined, + cachedReadTokens: raw['cachedReadTokens'] as + | number + | undefined, + }, + durationMs: metaObj['durationMs'] as number | undefined, + }); + } + return; + } + + // Tool calls, plans, mode/model updates: fall through to the + // normal handler which emits them via dedicated callbacks that + // the webview can process independently of streaming state. } } catch (err) { console.warn('[QwenAgentManager] Rehydration routing failed:', err); @@ -136,13 +184,18 @@ export class QwenAgentManager { }; this.connection.onPermissionRequest = async ( - data: AcpPermissionRequest, + data: RequestPermissionRequest, ) => { if (this.callbacks.onPermissionRequest) { const optionId = await this.callbacks.onPermissionRequest(data); - return { optionId }; + return { + optionId: + this.resolvePermissionOptionId(data, optionId) || + this.resolvePermissionOptionId(data) || + '', + }; } - return { optionId: 'allow_once' }; + return { optionId: this.resolvePermissionOptionId(data) || '' }; }; this.connection.onEndTurn = (reason?: string) => { @@ -217,10 +270,12 @@ export class QwenAgentManager { options, ); if (res.modelInfo && this.callbacks.onModelInfo) { + this.baselineModelInfo = res.modelInfo; this.callbacks.onModelInfo(res.modelInfo); } // Emit available models from connect result if (res.availableModels && res.availableModels.length > 0) { + this.baselineAvailableModels = res.availableModels; console.log( '[QwenAgentManager] Emitting availableModels from connect():', res.availableModels.map((m) => m.modelId), @@ -229,6 +284,21 @@ export class QwenAgentManager { this.callbacks.onAvailableModels(res.availableModels); } } + if (res.currentModeId) { + this.baselineModeId = res.currentModeId; + this.callbacks.onModeChanged?.(res.currentModeId); + } + if (res.availableModes) { + this.baselineAvailableModes = res.availableModes; + this.callbacks.onModeInfo?.({ + currentModeId: res.currentModeId ?? this.baselineModeId, + availableModes: res.availableModes, + }); + } else if (res.currentModeId) { + this.callbacks.onModeInfo?.({ + currentModeId: res.currentModeId, + }); + } return res; } @@ -249,16 +319,9 @@ export class QwenAgentManager { ): 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; + await this.connection.setMode(modeId); + // set_mode response has no mode payload; use requested value. + const confirmed = modeId; this.callbacks.onModeChanged?.(confirmed); return confirmed; } catch (err) { @@ -272,10 +335,8 @@ export class QwenAgentManager { */ async setModelFromUi(modelId: string): Promise { try { - const res = await this.connection.setModel(modelId); - // Parse response and notify UI - const result = (res?.result || {}) as { modelId?: string }; - const confirmedModelId = result.modelId || modelId; + await this.connection.setModel(modelId); + const confirmedModelId = modelId; const modelInfo: ModelInfo = { modelId: confirmedModelId, name: confirmedModelId, @@ -338,19 +399,13 @@ export class QwenAgentManager { 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>) + if (res && typeof res === 'object' && 'sessions' in res) { + const sessionsValue = (res as { sessions?: unknown }).sessions; + items = Array.isArray(sessionsValue) + ? (sessionsValue as Array>) : []; } @@ -366,7 +421,7 @@ export class QwenAgentManager { 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, + lastUpdated: item.updatedAt || item.mtime || item.lastUpdated, messageCount: item.messageCount || 0, projectHash: item.projectHash, filePath: item.filePath, @@ -445,17 +500,14 @@ export class QwenAgentManager { 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 : []; + if (res && typeof res === 'object' && 'sessions' in res) { + const sessionsValue = (res as { sessions?: unknown }).sessions; + items = Array.isArray(sessionsValue) + ? (sessionsValue as Array>) + : []; } const mapped = items.map((item) => ({ @@ -464,25 +516,29 @@ export class QwenAgentManager { 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, + lastUpdated: item.updatedAt || 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; + // SDK returns nextCursor as string; convert to numeric cursor for paging + let nextCursorNum: number | undefined; + if (typeof res === 'object' && res !== null && 'nextCursor' in res) { + const raw = (res as { nextCursor?: unknown }).nextCursor; + if (typeof raw === 'number') { + nextCursorNum = raw; + } else if (typeof raw === 'string') { + const parsed = Number(raw); + if (!Number.isNaN(parsed)) { + nextCursorNum = parsed; + } + } + } + const hasMore = nextCursorNum !== undefined; - return { sessions: mapped, nextCursor, hasMore }; + return { sessions: mapped, nextCursor: nextCursorNum, hasMore }; } catch (error) { console.warn('[QwenAgentManager] Paged ACP session list failed:', error); // fall through to file system @@ -893,63 +949,6 @@ export class QwenAgentManager { } } - /** - * 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 @@ -980,6 +979,9 @@ export class QwenAgentManager { '[QwenAgentManager] Session load succeeded. Response:', JSON.stringify(response).substring(0, 200), ); + this.applySessionStateFromResult(response); + this.restoreBaselineSessionStateAfterLoad(response); + return response; } catch (error) { const errorMessage = @@ -1190,35 +1192,7 @@ export class QwenAgentManager { } } - const modelInfo = - extractModelInfoFromNewSessionResult(newSessionResult); - if (modelInfo && this.callbacks.onModelInfo) { - this.callbacks.onModelInfo(modelInfo); - } - - // Extract and emit available models - const modelState = extractSessionModelState(newSessionResult); - console.log( - '[QwenAgentManager] Extracted model state from session/new:', - modelState, - ); - if ( - modelState?.availableModels && - modelState.availableModels.length > 0 - ) { - console.log( - '[QwenAgentManager] Emitting availableModels:', - modelState.availableModels, - ); - if (this.callbacks.onAvailableModels) { - this.callbacks.onAvailableModels(modelState.availableModels); - } - } else { - console.warn( - '[QwenAgentManager] No availableModels found in session/new response. Raw models field:', - (newSessionResult as Record)?.models, - ); - } + this.applySessionStateFromResult(newSessionResult); const newSessionId = this.connection.currentSessionId; console.log( @@ -1307,7 +1281,7 @@ export class QwenAgentManager { * @param callback - Permission request callback function */ onPermissionRequest( - callback: (request: AcpPermissionRequest) => Promise, + callback: (request: RequestPermissionRequest) => Promise, ): void { this.callbacks.onPermissionRequest = callback; this.sessionUpdateHandler.updateCallbacks(this.callbacks); @@ -1367,7 +1341,7 @@ export class QwenAgentManager { } /** - * Register callback for model changed updates (from ACP current_model_update) + * Register callback for model changed updates. */ onModelChanged(callback: (model: ModelInfo) => void): void { this.callbacks.onModelChanged = callback; @@ -1410,4 +1384,85 @@ export class QwenAgentManager { get currentSessionId(): string | null { return this.connection.currentSessionId; } + + private applySessionStateFromResult(result: unknown): void { + const modelInfo = extractModelInfoFromNewSessionResult(result); + if (modelInfo) { + this.baselineModelInfo = modelInfo; + this.callbacks.onModelInfo?.(modelInfo); + } + + const modelState = extractSessionModelState(result); + if (modelState?.availableModels && modelState.availableModels.length > 0) { + this.baselineAvailableModels = modelState.availableModels; + this.callbacks.onAvailableModels?.(modelState.availableModels); + } + + const modeState = extractSessionModeState(result); + if (modeState?.currentModeId) { + this.baselineModeId = modeState.currentModeId; + this.callbacks.onModeChanged?.(modeState.currentModeId); + } + if (modeState?.availableModes && modeState.availableModes.length > 0) { + this.baselineAvailableModes = modeState.availableModes; + } + if (modeState) { + this.callbacks.onModeInfo?.({ + currentModeId: modeState.currentModeId ?? this.baselineModeId, + availableModes: modeState.availableModes ?? this.baselineAvailableModes, + }); + } + } + + private restoreBaselineSessionStateAfterLoad(result: unknown): void { + const obj = (result || {}) as Record; + const hasModes = !!obj['modes']; + const hasModels = !!obj['models']; + + if (!hasModes) { + this.callbacks.onModeInfo?.({ + currentModeId: this.baselineModeId, + availableModes: this.baselineAvailableModes, + }); + this.callbacks.onModeChanged?.(this.baselineModeId); + } + + if (!hasModels) { + if (this.baselineModelInfo) { + this.callbacks.onModelInfo?.(this.baselineModelInfo); + } + if (this.baselineAvailableModels.length > 0) { + this.callbacks.onAvailableModels?.(this.baselineAvailableModels); + } + } + } + + private resolvePermissionOptionId( + request: RequestPermissionRequest, + preferredOptionId?: string, + ): string | undefined { + // Keep this mapping aligned with AcpConnection.resolvePermissionOptionId: + // Webview callbacks may provide a semantic choice (allow/reject) while the + // CLI requires a concrete ToolConfirmationOutcome optionId. + // Always normalize to an optionId that exists in request.options. + const options = Array.isArray(request.options) ? request.options : []; + if (options.length === 0) { + return undefined; + } + + if ( + preferredOptionId && + options.some((option) => option.optionId === preferredOptionId) + ) { + return preferredOptionId; + } + + return ( + options.find((option) => option.kind === 'allow_once')?.optionId || + options.find((option) => option.optionId === 'proceed_once')?.optionId || + options.find((option) => option.optionId.includes('proceed_once')) + ?.optionId || + options[0]?.optionId + ); + } } diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 9b4a188c8..5e33b548d 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -15,15 +15,23 @@ import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { authMethod } from '../types/acpTypes.js'; import { extractModelInfoFromNewSessionResult, + extractSessionModeState, extractSessionModelState, } from '../utils/acpModelInfo.js'; -import type { ModelInfo } from '../types/acpTypes.js'; +import type { ModelInfo } from '@agentclientprotocol/sdk'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; export interface QwenConnectionResult { sessionCreated: boolean; requiresAuth: boolean; modelInfo?: ModelInfo; availableModels?: ModelInfo[]; + currentModeId?: ApprovalModeValue; + availableModes?: Array<{ + id: ApprovalModeValue; + name: string; + description: string; + }>; } /** @@ -53,6 +61,14 @@ export class QwenConnectionHandler { let requiresAuth = false; let modelInfo: ModelInfo | undefined; let availableModels: ModelInfo[] | undefined; + let currentModeId: ApprovalModeValue | undefined; + let availableModes: + | Array<{ + id: ApprovalModeValue; + name: string; + description: string; + }> + | undefined; // Build extra CLI arguments (only essential parameters) const extraArgs: string[] = []; @@ -97,6 +113,9 @@ export class QwenConnectionHandler { availableModels.map((m) => m.modelId), ); } + const modeState = extractSessionModeState(newSessionResult); + currentModeId = modeState?.currentModeId; + availableModes = modeState?.availableModes; console.log('[QwenAgentManager] New session created successfully'); sessionCreated = true; @@ -124,7 +143,14 @@ export class QwenConnectionHandler { console.log(`\n========================================`); console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`); console.log(`========================================\n`); - return { sessionCreated, requiresAuth, modelInfo, availableModels }; + return { + sessionCreated, + requiresAuth, + modelInfo, + availableModels, + currentModeId, + availableModes, + }; } /** diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index a5e817cad..48a219ad9 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -9,17 +9,16 @@ import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; -import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; +import type { QwenSession } 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. + * This service provides direct filesystem access to load sessions. * - * 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. + * Note: Sessions are auto-saved by the CLI's ChatRecordingService. + * This class is primarily used as a fallback mechanism for loading sessions + * when ACP methods are unavailable or fail. */ export class QwenSessionManager { private qwenDir: string; @@ -44,60 +43,6 @@ export class QwenSessionManager { 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: 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 * diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts index dc84199e8..ab2e34179 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; -import type { AcpSessionUpdate } from '../types/acpTypes.js'; +import type { SessionNotification } from '@agentclientprotocol/sdk'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { QwenAgentCallbacks } from '../types/chatTypes.js'; @@ -28,81 +28,15 @@ describe('QwenSessionUpdateHandler', () => { handler = new QwenSessionUpdateHandler(mockCallbacks); }); - describe('current_model_update handling', () => { - it('calls onModelChanged callback with model info', () => { - const modelUpdate: AcpSessionUpdate = { - sessionId: 'test-session', - update: { - sessionUpdate: 'current_model_update', - model: { - modelId: 'qwen3-coder-plus', - name: 'Qwen3 Coder Plus', - description: 'A powerful coding model', - }, - }, - } as AcpSessionUpdate; - - handler.handleSessionUpdate(modelUpdate); - - expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({ - modelId: 'qwen3-coder-plus', - name: 'Qwen3 Coder Plus', - description: 'A powerful coding model', - }); - }); - - it('handles model update with _meta field', () => { - const modelUpdate: AcpSessionUpdate = { - sessionId: 'test-session', - update: { - sessionUpdate: 'current_model_update', - model: { - modelId: 'test-model', - name: 'Test Model', - _meta: { contextLimit: 128000 }, - }, - }, - } as AcpSessionUpdate; - - handler.handleSessionUpdate(modelUpdate); - - expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({ - modelId: 'test-model', - name: 'Test Model', - _meta: { contextLimit: 128000 }, - }); - }); - - it('does not call callback when onModelChanged is not set', () => { - const handlerWithoutCallback = new QwenSessionUpdateHandler({}); - - const modelUpdate: AcpSessionUpdate = { - sessionId: 'test-session', - update: { - sessionUpdate: 'current_model_update', - model: { - modelId: 'qwen3-coder', - name: 'Qwen3 Coder', - }, - }, - } as AcpSessionUpdate; - - // Should not throw - expect(() => - handlerWithoutCallback.handleSessionUpdate(modelUpdate), - ).not.toThrow(); - }); - }); - describe('current_mode_update handling', () => { it('calls onModeChanged callback with mode id', () => { - const modeUpdate: AcpSessionUpdate = { + const modeUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'current_mode_update', - modeId: 'auto-edit' as ApprovalModeValue, + currentModeId: 'auto-edit' as ApprovalModeValue, }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(modeUpdate); @@ -112,7 +46,7 @@ describe('QwenSessionUpdateHandler', () => { describe('agent_message_chunk handling', () => { it('calls onStreamChunk callback with text content', () => { - const messageUpdate: AcpSessionUpdate = { + const messageUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'agent_message_chunk', @@ -129,7 +63,7 @@ describe('QwenSessionUpdateHandler', () => { }); it('emits usage metadata when present', () => { - const messageUpdate: AcpSessionUpdate = { + const messageUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'agent_message_chunk', @@ -152,18 +86,66 @@ describe('QwenSessionUpdateHandler', () => { expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({ usage: { + inputTokens: 100, + outputTokens: 50, + thoughtTokens: undefined, + totalTokens: 150, + cachedReadTokens: undefined, + cachedWriteTokens: undefined, promptTokens: 100, completionTokens: 50, - totalTokens: 150, + thoughtsTokens: undefined, + cachedTokens: undefined, }, durationMs: 1234, }); }); + + it('maps SDK usage field names to both SDK and legacy fields', () => { + const messageUpdate: SessionNotification = { + sessionId: 'test-session', + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: 'Response', + }, + _meta: { + usage: { + inputTokens: 200, + outputTokens: 80, + thoughtTokens: 30, + totalTokens: 310, + cachedReadTokens: 10, + } as never, + durationMs: 500, + }, + }, + }; + + handler.handleSessionUpdate(messageUpdate); + + expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({ + usage: { + inputTokens: 200, + outputTokens: 80, + thoughtTokens: 30, + totalTokens: 310, + cachedReadTokens: 10, + cachedWriteTokens: undefined, + promptTokens: 200, + completionTokens: 80, + thoughtsTokens: 30, + cachedTokens: 10, + }, + durationMs: 500, + }); + }); }); describe('tool_call handling', () => { it('calls onToolCall callback with tool call data', () => { - const toolCallUpdate: AcpSessionUpdate = { + const toolCallUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'tool_call', @@ -191,7 +173,7 @@ describe('QwenSessionUpdateHandler', () => { describe('plan handling', () => { it('calls onPlan callback with plan entries', () => { - const planUpdate: AcpSessionUpdate = { + const planUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'plan', @@ -215,7 +197,7 @@ describe('QwenSessionUpdateHandler', () => { onStreamChunk: vi.fn(), }); - const planUpdate: AcpSessionUpdate = { + const planUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'plan', @@ -231,7 +213,7 @@ describe('QwenSessionUpdateHandler', () => { describe('available_commands_update handling', () => { it('calls onAvailableCommands callback with commands', () => { - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', @@ -253,7 +235,7 @@ describe('QwenSessionUpdateHandler', () => { }, ], }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(commandsUpdate); @@ -269,7 +251,7 @@ describe('QwenSessionUpdateHandler', () => { }); it('handles commands with input hint', () => { - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', @@ -281,7 +263,7 @@ describe('QwenSessionUpdateHandler', () => { }, ], }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(commandsUpdate); @@ -297,7 +279,7 @@ describe('QwenSessionUpdateHandler', () => { it('does not call callback when onAvailableCommands is not set', () => { const handlerWithoutCallback = new QwenSessionUpdateHandler({}); - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', @@ -305,7 +287,7 @@ describe('QwenSessionUpdateHandler', () => { { name: 'compress', description: 'Compress', input: null }, ], }, - } as AcpSessionUpdate; + } as SessionNotification; // Should not throw expect(() => @@ -314,13 +296,13 @@ describe('QwenSessionUpdateHandler', () => { }); it('handles empty commands list', () => { - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', availableCommands: [], }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(commandsUpdate); @@ -329,28 +311,25 @@ describe('QwenSessionUpdateHandler', () => { }); describe('updateCallbacks', () => { - it('updates callbacks and uses new ones', () => { - const newOnModelChanged = vi.fn(); + it('updates mode callback and uses new one', () => { + const newOnModeChanged = vi.fn(); handler.updateCallbacks({ ...mockCallbacks, - onModelChanged: newOnModelChanged, + onModeChanged: newOnModeChanged, }); - const modelUpdate: AcpSessionUpdate = { + const modeUpdate: SessionNotification = { sessionId: 'test-session', update: { - sessionUpdate: 'current_model_update', - model: { - modelId: 'new-model', - name: 'New Model', - }, + sessionUpdate: 'current_mode_update', + currentModeId: 'yolo' as ApprovalModeValue, }, - } as AcpSessionUpdate; + } as SessionNotification; - handler.handleSessionUpdate(modelUpdate); + handler.handleSessionUpdate(modeUpdate); - expect(newOnModelChanged).toHaveBeenCalled(); - expect(mockCallbacks.onModelChanged).not.toHaveBeenCalled(); + expect(newOnModeChanged).toHaveBeenCalled(); + expect(mockCallbacks.onModeChanged).not.toHaveBeenCalled(); }); it('updates onAvailableCommands callback', () => { @@ -360,7 +339,7 @@ describe('QwenSessionUpdateHandler', () => { onAvailableCommands: newOnAvailableCommands, }); - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', @@ -368,7 +347,7 @@ describe('QwenSessionUpdateHandler', () => { { name: 'test', description: 'Test command', input: null }, ], }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(commandsUpdate); diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index 2000003fd..06e03d454 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -11,11 +11,10 @@ */ import type { - AcpSessionUpdate, - SessionUpdateMeta, - ModelInfo, + SessionNotification, AvailableCommand, -} from '../types/acpTypes.js'; +} from '@agentclientprotocol/sdk'; +import type { SessionUpdateMeta } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { QwenAgentCallbacks, @@ -47,41 +46,58 @@ export class QwenSessionUpdateHandler { * * @param data - ACP session update data */ - handleSessionUpdate(data: AcpSessionUpdate): void { + handleSessionUpdate(data: SessionNotification): void { const update = data.update; + const sessionUpdate = (update as { sessionUpdate?: string }).sessionUpdate; console.log( '[SessionUpdateHandler] Processing update type:', - update.sessionUpdate, + sessionUpdate, ); - switch (update.sessionUpdate) { - case 'user_message_chunk': - if (update.content?.text && this.callbacks.onStreamChunk) { - this.callbacks.onStreamChunk(update.content.text); + switch (sessionUpdate) { + case 'user_message_chunk': { + const text = this.getTextContent( + (update as { content?: unknown }).content, + ); + if (text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(text); } break; + } - case 'agent_message_chunk': - if (update.content?.text && this.callbacks.onStreamChunk) { - this.callbacks.onStreamChunk(update.content.text); + case 'agent_message_chunk': { + const text = this.getTextContent( + (update as { content?: unknown }).content, + ); + if (text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(text); } - this.emitUsageMeta(update._meta); + this.emitUsageMeta( + (update as { _meta?: SessionUpdateMeta | null })._meta, + ); break; + } - case 'agent_thought_chunk': - if (update.content?.text) { + case 'agent_thought_chunk': { + const text = this.getTextContent( + (update as { content?: unknown }).content, + ); + if (text) { if (this.callbacks.onThoughtChunk) { - this.callbacks.onThoughtChunk(update.content.text); + this.callbacks.onThoughtChunk(text); } else if (this.callbacks.onStreamChunk) { // Fallback to regular stream processing console.log( '[SessionUpdateHandler] 🧠 Falling back to onStreamChunk', ); - this.callbacks.onStreamChunk(update.content.text); + this.callbacks.onStreamChunk(text); } } - this.emitUsageMeta(update._meta); + this.emitUsageMeta( + (update as { _meta?: SessionUpdateMeta | null })._meta, + ); break; + } case 'tool_call': { // Handle new tool call @@ -159,8 +175,9 @@ export class QwenSessionUpdateHandler { case 'current_mode_update': { // Notify UI about mode change try { - const modeId = (update as unknown as { modeId?: ApprovalModeValue }) - .modeId; + const modeId = ( + update as unknown as { currentModeId?: ApprovalModeValue } + ).currentModeId; if (modeId && this.callbacks.onModeChanged) { this.callbacks.onModeChanged(modeId); } @@ -173,22 +190,6 @@ export class QwenSessionUpdateHandler { break; } - case 'current_model_update': { - // Notify UI about model change - try { - const model = (update as unknown as { model?: ModelInfo }).model; - if (model && this.callbacks.onModelChanged) { - this.callbacks.onModelChanged(model); - } - } catch (err) { - console.warn( - '[SessionUpdateHandler] Failed to handle model update', - err, - ); - } - break; - } - case 'available_commands_update': { // Notify UI about available commands try { @@ -213,13 +214,58 @@ export class QwenSessionUpdateHandler { } } - private emitUsageMeta(meta?: SessionUpdateMeta): void { + private getTextContent(content: unknown): string | undefined { + if (!content || typeof content !== 'object') { + return undefined; + } + const text = (content as { text?: unknown }).text; + return typeof text === 'string' ? text : undefined; + } + + private emitUsageMeta(meta?: SessionUpdateMeta | null): void { if (!meta || !this.callbacks.onUsageUpdate) { return; } + const raw = meta.usage as Record | null | undefined; + const usage = raw + ? { + // SDK field names + inputTokens: + (raw['inputTokens'] as number | null | undefined) ?? + (raw['promptTokens'] as number | null | undefined), + outputTokens: + (raw['outputTokens'] as number | null | undefined) ?? + (raw['completionTokens'] as number | null | undefined), + thoughtTokens: + (raw['thoughtTokens'] as number | null | undefined) ?? + (raw['thoughtsTokens'] as number | null | undefined), + totalTokens: raw['totalTokens'] as number | null | undefined, + cachedReadTokens: + (raw['cachedReadTokens'] as number | null | undefined) ?? + (raw['cachedTokens'] as number | null | undefined), + cachedWriteTokens: raw['cachedWriteTokens'] as + | number + | null + | undefined, + // Legacy compat + promptTokens: + (raw['promptTokens'] as number | null | undefined) ?? + (raw['inputTokens'] as number | null | undefined), + completionTokens: + (raw['completionTokens'] as number | null | undefined) ?? + (raw['outputTokens'] as number | null | undefined), + thoughtsTokens: + (raw['thoughtsTokens'] as number | null | undefined) ?? + (raw['thoughtTokens'] as number | null | undefined), + cachedTokens: + (raw['cachedTokens'] as number | null | undefined) ?? + (raw['cachedReadTokens'] as number | null | undefined), + } + : undefined; + const payload: UsageStatsPayload = { - usage: meta.usage || undefined, + usage, durationMs: meta.durationMs ?? undefined, }; diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 14304a386..e22e8a726 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -3,177 +3,33 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ + +import type { Usage } from '@agentclientprotocol/sdk'; + import type { ApprovalModeValue } from './approvalModeValueTypes.js'; -export const JSONRPC_VERSION = '2.0' as const; +// --------------------------------------------------------------------------- +// Private / Qwen-specific types (not part of ACP spec) +// --------------------------------------------------------------------------- + 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; +/** + * Authenticate update notification (Qwen extension, not ACP spec). + * Sent by agent during the OAuth flow. + */ +export interface AuthenticateUpdateNotification { + _meta: { + authUri: string; }; - 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 UsageMetadata { - promptTokens?: number | null; - completionTokens?: number | null; - thoughtsTokens?: number | null; - totalTokens?: number | null; - cachedTokens?: number | null; } export interface SessionUpdateMeta { - usage?: UsageMetadata | null; + usage?: Usage | null; durationMs?: number | null; timestamp?: number | null; } -export type AcpMeta = Record; -export type ModelId = string; - -export interface ModelInfo { - _meta?: AcpMeta | null; - description?: string | null; - modelId: ModelId; - name: string; -} - -export interface SessionModelState { - _meta?: AcpMeta | null; - availableModels: ModelInfo[]; - currentModelId: ModelId; -} - -export interface UserMessageChunkUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'user_message_chunk'; - content: ContentBlock; - _meta?: SessionUpdateMeta; - }; -} - -export interface AgentMessageChunkUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'agent_message_chunk'; - content: ContentBlock; - _meta?: SessionUpdateMeta; - }; -} - -export interface AgentThoughtChunkUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'agent_thought_chunk'; - content: ContentBlock; - _meta?: SessionUpdateMeta; - }; -} - -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; - }>; - _meta?: SessionUpdateMeta; - }; -} - -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; - }>; - _meta?: SessionUpdateMeta; - }; -} - -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, @@ -181,91 +37,11 @@ export { 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; - }; -} - -// Current model update (sent by agent when model changes) -export interface CurrentModelUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'current_model_update'; - model: ModelInfo; - }; -} - -// Available command definition -export interface AvailableCommand { - name: string; - description: string; - input?: { - hint?: string; - } | null; -} - -// Available commands update (sent by agent after session creation) -export interface AvailableCommandsUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'available_commands_update'; - availableCommands: AvailableCommand[]; - }; -} - -// Authenticate update (sent by agent during authentication process) -export interface AuthenticateUpdateNotification { - _meta: { - authUri: string; - }; -} - -export type AcpSessionUpdate = - | UserMessageChunkUpdate - | AgentMessageChunkUpdate - | AgentThoughtChunkUpdate - | ToolCallUpdate - | ToolCallStatusUpdate - | PlanUpdate - | CurrentModeUpdate - | CurrentModelUpdate - | AvailableCommandsUpdate; - -// 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/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index b92cb35e5..84c7bb9f8 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ import type { - AcpPermissionRequest, ModelInfo, AvailableCommand, -} from './acpTypes.js'; + RequestPermissionRequest, +} from '@agentclientprotocol/sdk'; import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export interface ChatMessage { - role: 'user' | 'assistant'; + role: 'user' | 'assistant' | 'thinking'; content: string; timestamp: number; } @@ -35,10 +35,17 @@ export interface ToolCallUpdateData { export interface UsageStatsPayload { usage?: { + // SDK field names (primary) + inputTokens?: number | null; + outputTokens?: number | null; + thoughtTokens?: number | null; + totalTokens?: number | null; + cachedReadTokens?: number | null; + cachedWriteTokens?: number | null; + // Legacy field names (compat with older CLI builds) promptTokens?: number | null; completionTokens?: number | null; thoughtsTokens?: number | null; - totalTokens?: number | null; cachedTokens?: number | null; } | null; durationMs?: number | null; @@ -51,7 +58,7 @@ export interface QwenAgentCallbacks { onThoughtChunk?: (chunk: string) => void; onToolCall?: (update: ToolCallUpdateData) => void; onPlan?: (entries: PlanEntry[]) => void; - onPermissionRequest?: (request: AcpPermissionRequest) => Promise; + onPermissionRequest?: (request: RequestPermissionRequest) => Promise; onEndTurn?: (reason?: string) => void; onModeInfo?: (info: { currentModeId?: ApprovalModeValue; diff --git a/packages/vscode-ide-companion/src/types/connectionTypes.ts b/packages/vscode-ide-companion/src/types/connectionTypes.ts index 7ada3aedf..1f4fec2ae 100644 --- a/packages/vscode-ide-companion/src/types/connectionTypes.ts +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -6,10 +6,10 @@ import type { ChildProcess } from 'child_process'; import type { - AcpSessionUpdate, - AcpPermissionRequest, - AuthenticateUpdateNotification, -} from './acpTypes.js'; + RequestPermissionRequest, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import type { AuthenticateUpdateNotification } from './acpTypes.js'; export interface PendingRequest { resolve: (value: T) => void; @@ -19,8 +19,8 @@ export interface PendingRequest { } export interface AcpConnectionCallbacks { - onSessionUpdate: (data: AcpSessionUpdate) => void; - onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + onSessionUpdate: (data: SessionNotification) => void; + onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ optionId: string; }>; onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void; diff --git a/packages/vscode-ide-companion/src/utils/acpModelInfo.ts b/packages/vscode-ide-companion/src/utils/acpModelInfo.ts index 45df8aa0c..d2c8b5e1b 100644 --- a/packages/vscode-ide-companion/src/utils/acpModelInfo.ts +++ b/packages/vscode-ide-companion/src/utils/acpModelInfo.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AcpMeta, ModelInfo } from '../types/acpTypes.js'; +import type { ModelInfo } from '@agentclientprotocol/sdk'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; + +type AcpMeta = Record; const asMeta = (value: unknown): AcpMeta | null | undefined => { if (value === null) { @@ -77,6 +80,26 @@ export interface SessionModelState { currentModelId: string; } +export interface SessionModeState { + currentModeId?: ApprovalModeValue; + availableModes?: Array<{ + id: ApprovalModeValue; + name: string; + description: string; + }>; +} + +const APPROVAL_MODE_VALUES: ApprovalModeValue[] = [ + 'plan', + 'default', + 'auto-edit', + 'yolo', +]; + +const isApprovalModeValue = (value: unknown): value is ApprovalModeValue => + typeof value === 'string' && + APPROVAL_MODE_VALUES.includes(value as ApprovalModeValue); + /** * Extract complete model state from ACP `session/new` result. * @@ -132,6 +155,73 @@ export const extractSessionModelState = ( return null; }; +export const extractSessionModeState = ( + result: unknown, +): SessionModeState | null => { + if (!result || typeof result !== 'object') { + return null; + } + + const obj = result as Record; + const modes = obj['modes']; + if (!modes || typeof modes !== 'object' || Array.isArray(modes)) { + return null; + } + + const state = modes as Record; + const currentModeRaw = state['currentModeId']; + const availableModesRaw = state['availableModes']; + + const currentModeId = isApprovalModeValue(currentModeRaw) + ? currentModeRaw + : undefined; + + let availableModes: + | Array<{ + id: ApprovalModeValue; + name: string; + description: string; + }> + | undefined; + if (Array.isArray(availableModesRaw)) { + availableModes = availableModesRaw + .map((entry) => { + if (!entry || typeof entry !== 'object') { + return null; + } + const item = entry as Record; + const idRaw = item['id']; + if (!isApprovalModeValue(idRaw)) { + return null; + } + return { + id: idRaw, + name: typeof item['name'] === 'string' ? item['name'] : idRaw, + description: + typeof item['description'] === 'string' ? item['description'] : '', + }; + }) + .filter( + ( + item, + ): item is { + id: ApprovalModeValue; + name: string; + description: string; + } => Boolean(item), + ); + } + + if (!currentModeId && (!availableModes || availableModes.length === 0)) { + return null; + } + + return { + ...(currentModeId ? { currentModeId } : {}), + ...(availableModes ? { availableModes } : {}), + }; +}; + /** * Extract model info from ACP `session/new` result. * diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 8d2c0bfed..cb1409af1 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -44,7 +44,7 @@ import { InputForm } from './components/layout/InputForm.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; -import type { ModelInfo, AvailableCommand } from '../types/acpTypes.js'; +import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; import { DEFAULT_TOKEN_LIMIT, tokenLimit, diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 58163b691..cb747aff3 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -15,7 +15,7 @@ import type { } from '@qwen-code/webui'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; -import type { ModelInfo } from '../../../types/acpTypes.js'; +import type { ModelInfo } from '@agentclientprotocol/sdk'; import { ModelSelector } from './ModelSelector.js'; /** diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx index 3d594f435..ebc1c2853 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { FC } from 'react'; -import type { ModelInfo } from '../../../types/acpTypes.js'; +import type { ModelInfo } from '@agentclientprotocol/sdk'; import { PlanCompletedIcon } from '@qwen-code/webui'; interface ModelSelectorProps { diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 72278d62e..868838a1d 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -27,7 +27,6 @@ export class SessionMessageHandler extends BaseMessageHandler { 'newQwenSession', 'switchQwenSession', 'getQwenSessions', - 'saveSession', 'resumeSession', 'cancelStreaming', // UI action: open a new chat tab (new WebviewPanel) @@ -87,10 +86,6 @@ export class SessionMessageHandler extends BaseMessageHandler { ); break; - case 'saveSession': - await this.handleSaveSession((data?.tag as string) || ''); - break; - case 'resumeSession': await this.handleResumeSession((data?.sessionId as string) || ''); break; @@ -822,87 +817,6 @@ export class SessionMessageHandler extends BaseMessageHandler { } } - /** - * 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(AUTH_REQUIRED_CODE_PATTERN) || - 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(AUTH_REQUIRED_CODE_PATTERN) || - 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 */ diff --git a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts index 17fde331f..507da7e2a 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts @@ -107,24 +107,17 @@ export const useMessageHandling = () => { streamingMessageIndexRef.current = null; }, []); + const breakThinkingSegment = useCallback(() => { + thinkingMessageIndexRef.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; - }); + thinkingMessageIndexRef.current = null; }, []); /** @@ -178,18 +171,10 @@ export const useMessageHandling = () => { }); }, 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; - }); + thinkingMessageIndexRef.current = null; }, breakAssistantSegment, + breakThinkingSegment, 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 index 9fba4a803..294836e77 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts @@ -20,7 +20,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { 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); @@ -97,38 +96,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { [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, @@ -137,7 +104,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { showSessionSelector, sessionSearchQuery, filteredSessions, - savedSessionTags, nextCursor, hasMore, isLoading, @@ -148,7 +114,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { setCurrentSessionTitle, setShowSessionSelector, setSessionSearchQuery, - setSavedSessionTags, setNextCursor, setHasMore, setIsLoading, @@ -157,8 +122,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { handleLoadQwenSessions, handleNewQwenSession, handleSwitchSession, - handleSaveSession, - handleSaveSessionResponse, handleLoadMoreSessions, }; }; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 30a1166b0..0658aee20 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -14,7 +14,7 @@ import type { } from '../../types/chatTypes.js'; import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js'; -import type { ModelInfo, AvailableCommand } from '../../types/acpTypes.js'; +import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; const FORCE_CLEAR_STREAM_END_REASONS = new Set([ 'user_cancelled', @@ -41,10 +41,6 @@ interface UseWebViewMessagesProps { setNextCursor: (cursor: number | undefined) => void; setHasMore: (hasMore: boolean) => void; setIsLoading: (loading: boolean) => void; - handleSaveSessionResponse: (response: { - success: boolean; - message?: string; - }) => void; }; // File context @@ -91,6 +87,7 @@ interface UseWebViewMessagesProps { appendStreamChunk: (chunk: string) => void; endStreaming: () => void; breakAssistantSegment: () => void; + breakThinkingSegment: () => void; appendThinkingChunk: (chunk: string) => void; clearThinking: () => void; setWaitingForResponse: (message: string) => void; @@ -612,6 +609,7 @@ export const useWebViewMessages = ({ // Split assistant stream so subsequent chunks start a new assistant message handlers.messageHandling.breakAssistantSegment(); + handlers.messageHandling.breakThinkingSegment(); } break; } @@ -686,6 +684,7 @@ export const useWebViewMessages = ({ // Split assistant message segments, keep rendering blocks independent handlers.messageHandling.breakAssistantSegment?.(); + handlers.messageHandling.breakThinkingSegment?.(); } catch (_error) { console.warn( '[useWebViewMessages] failed to push/merge plan snapshot toolcall:', @@ -711,6 +710,7 @@ export const useWebViewMessages = ({ (status === 'completed' || status === 'failed'); if (isStart || isFinalUpdate) { handlers.messageHandling.breakAssistantSegment(); + handlers.messageHandling.breakThinkingSegment(); } // While long-running tools (e.g., execute/bash/command) are in progress, @@ -935,11 +935,6 @@ export const useWebViewMessages = ({ break; } - case 'saveSessionResponse': { - handlers.sessionManagement.handleSaveSessionResponse(message.data); - break; - } - case 'cancelStreaming': // Handle cancel streaming response from extension // Note: The "Interrupted" message is already added by handleCancel in App.tsx diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index d926b8315..f81e3c6f1 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -7,8 +7,10 @@ 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 type { ModelInfo } from '../../types/acpTypes.js'; +import type { + RequestPermissionRequest, + ModelInfo, +} from '@agentclientprotocol/sdk'; import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js'; import { PanelManager } from './PanelManager.js'; import { MessageHandler } from './MessageHandler.js'; @@ -30,7 +32,7 @@ export class WebViewProvider { // 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 pendingPermissionRequest: RequestPermissionRequest | null = null; private pendingPermissionResolve: ((optionId: string) => void) | null = null; // Track current ACP mode id to influence permission/diff behavior private currentModeId: ApprovalModeValue | null = null; @@ -140,7 +142,7 @@ export class WebViewProvider { }); }); - // Surface model changes (from ACP current_model_update or set_model response) + // Surface model changes (primarily from set_model response path) this.agentManager.onModelChanged((model) => { this.sendMessageToWebView({ type: 'modelChanged', @@ -221,7 +223,7 @@ export class WebViewProvider { }); this.agentManager.onPermissionRequest( - async (request: AcpPermissionRequest) => { + async (request: RequestPermissionRequest) => { // Auto-approve in auto/yolo mode (no UI, no diff) if (this.isAutoMode()) { const options = request.options || []; diff --git a/packages/vscode-ide-companion/src/webview/styles/App.css b/packages/vscode-ide-companion/src/webview/styles/App.css index 6216d2b87..f3dc303db 100644 --- a/packages/vscode-ide-companion/src/webview/styles/App.css +++ b/packages/vscode-ide-companion/src/webview/styles/App.css @@ -95,7 +95,10 @@ /* Buttons - VSCode tokens */ --app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground); - --app-button-foreground: var(--vscode-button-foreground, var(--app-qwen-ivory)); + --app-button-foreground: var( + --vscode-button-foreground, + var(--app-qwen-ivory) + ); --app-button-background: var( --vscode-button-background, var(--app-qwen-clay-button-orange) diff --git a/packages/web-templates/build.mjs b/packages/web-templates/build.mjs index c01646232..eb7bd0d25 100644 --- a/packages/web-templates/build.mjs +++ b/packages/web-templates/build.mjs @@ -23,10 +23,14 @@ const assetBuilds = [ const runCommand = ({ command, args, cwd, label }) => new Promise((resolve, reject) => { - const child = spawn(command, args, { + const useShell = process.platform === 'win32'; + // On Windows, quote the command path to handle spaces (e.g., "C:\Program Files\nodejs\node.exe") + const quotedCommand = useShell ? `"${command}"` : command; + + const child = spawn(quotedCommand, args, { cwd, stdio: 'inherit', - shell: process.platform === 'win32', + shell: useShell, }); child.on('error', reject); diff --git a/packages/web-templates/src/export-html/src/components/TempFileModal.css b/packages/web-templates/src/export-html/src/components/TempFileModal.css index ba317104e..6c66c7804 100644 --- a/packages/web-templates/src/export-html/src/components/TempFileModal.css +++ b/packages/web-templates/src/export-html/src/components/TempFileModal.css @@ -48,7 +48,9 @@ padding: 4px 8px; border-radius: 6px; line-height: 1; - transition: background-color 0.15s, color 0.15s; + transition: + background-color 0.15s, + color 0.15s; } .modal-close:hover { diff --git a/packages/web-templates/src/insight/src/styles.css b/packages/web-templates/src/insight/src/styles.css index 8ef9af761..e65972d83 100644 --- a/packages/web-templates/src/insight/src/styles.css +++ b/packages/web-templates/src/insight/src/styles.css @@ -65,7 +65,7 @@ :before, :after { - --tw-content: ""; + --tw-content: ''; } html, @@ -75,7 +75,9 @@ html, font-feature-settings: normal; font-variation-settings: normal; -webkit-tap-highlight-color: transparent; - font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: + ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; line-height: 1.5; } @@ -85,7 +87,9 @@ body { background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)); --tw-gradient-from: #f8fafc var(--tw-gradient-from-position); --tw-gradient-to: #f1f5f9 var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-from), #fff var(--tw-gradient-via-position), var(--tw-gradient-to); + --tw-gradient-stops: + var(--tw-gradient-from), #fff var(--tw-gradient-via-position), + var(--tw-gradient-to); --tw-text-opacity: 1; min-height: 100vh; color: rgb(15 23 42 / var(--tw-text-opacity, 1)); @@ -100,9 +104,15 @@ body { border-color: rgb(226 232 240 / var(--tw-border-opacity, 1)); --tw-shadow: 0 10px 40px #0f172a14; --tw-shadow-colored: 0 10px 40px var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow); --tw-backdrop-blur: blur(8px); - backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) + var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) + var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) + var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) + var(--tw-backdrop-sepia); background-color: #ffffff99; border-radius: 1rem; } @@ -225,25 +235,25 @@ body { gap: 1rem; } -.space-y-3> :not([hidden])~ :not([hidden]) { +.space-y-3 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); } -.space-y-4> :not([hidden])~ :not([hidden]) { +.space-y-4 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(1rem * var(--tw-space-y-reverse)); } -.divide-y> :not([hidden])~ :not([hidden]) { +.divide-y > :not([hidden]) ~ :not([hidden]) { --tw-divide-y-reverse: 0; border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); } -.divide-slate-200> :not([hidden])~ :not([hidden]) { +.divide-slate-200 > :not([hidden]) ~ :not([hidden]) { --tw-divide-opacity: 1; border-color: rgb(226 232 240 / var(--tw-divide-opacity, 1)); } @@ -305,7 +315,9 @@ body { .via-white { --tw-gradient-to: #ffffff00 var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-from), #ffffff var(--tw-gradient-via-position), var(--tw-gradient-to); + --tw-gradient-stops: + var(--tw-gradient-from), #ffffff var(--tw-gradient-via-position), + var(--tw-gradient-to); } .to-slate-100 { @@ -494,13 +506,17 @@ body { .shadow-inner { --tw-shadow: inset 0 2px 4px 0 #0000000d; --tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow); } .shadow-soft { --tw-shadow: 0 10px 40px #0f172a14; --tw-shadow-colored: 0 10px 40px var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow); } .shadow-slate-100 { @@ -509,20 +525,28 @@ body { } .transition { - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: + color, background-color, border-color, text-decoration-color, fill, stroke, + opacity, box-shadow, transform, filter, backdrop-filter; transition-duration: 0.15s; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } .hover\:-translate-y-\[1px\]:hover { --tw-translate-y: -1px; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } .hover\:shadow-lg:hover { --tw-shadow: 0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a; - --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + --tw-shadow-colored: + 0 10px 15px -3px var(--tw-shadow-color), + 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow); } .focus-visible\:outline:focus-visible { @@ -543,12 +567,16 @@ body { .active\:translate-y-\[1px\]:active { --tw-translate-y: 1px; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } .group:hover .group-hover\:translate-x-0\.5 { --tw-translate-x: 0.125rem; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } @media (min-width: 768px) { @@ -1259,4 +1287,4 @@ body { .header-title { font-size: 1.5rem; } -} \ No newline at end of file +} diff --git a/packages/webui/examples/cdn-usage-demo.html b/packages/webui/examples/cdn-usage-demo.html index c013e9078..2919614af 100644 --- a/packages/webui/examples/cdn-usage-demo.html +++ b/packages/webui/examples/cdn-usage-demo.html @@ -1,99 +1,117 @@ - + + + + + @qwen-code/webui CDN Usage Example + + + - - - - @qwen-code/webui CDN Usage Example - - - + + - window.ReactJSXRuntime = jsxRuntime; - window['react/jsx-runtime'] = jsxRuntime; - window['react/jsx-dev-runtime'] = jsxRuntime; - + + - - + + - - - - - - - -
-

@qwen-code/webui CDN Usage Example

-

ChatViewer Component Demo

-
-
- - - + theme: 'light', + }), + ); + ReactDOM.render(ChatAppNoBabel, rootElementNoBabel); + + diff --git a/packages/webui/examples/complex-chat-demo.html b/packages/webui/examples/complex-chat-demo.html index c05be1e1b..22adbc3dd 100644 --- a/packages/webui/examples/complex-chat-demo.html +++ b/packages/webui/examples/complex-chat-demo.html @@ -1,99 +1,112 @@ - + + + + + @qwen-code/webui Complex Chat Demo + + + + + - - - - @qwen-code/webui Complex Chat Demo - - - - - + + - - + + - - + + - window.ReactJSXRuntime = jsxRuntime; - window['react/jsx-runtime'] = jsxRuntime; - window['react/jsx-dev-runtime'] = jsxRuntime; - + + - .chat-container { - height: 700px; - border: 1px solid #ddd; - border-radius: 4px; - overflow: hidden; - } - - + +
+

@qwen-code/webui Complex Chat Demo

+

Real conversation example with tool calls

+
- -
-

@qwen-code/webui Complex Chat Demo

-

Real conversation example with tool calls

-
+

Alternative: With Full Tailwind Support

+

+ For full Tailwind utility class support (like gap-1.5, button classes, + etc.), also include: +

+
<script src="https://cdn.tailwindcss.com"></script>
+
-

Alternative: With Full Tailwind Support

-

For full Tailwind utility class support (like gap-1.5, button classes, etc.), also include:

-
<script src="https://cdn.tailwindcss.com"></script>
-
- - - + // Create the ChatViewer element wrapped with PlatformProvider with complex data + const ChatApp = React.createElement( + PlatformProvider, + { value: platformContext }, + React.createElement(ChatViewer, { + messages: combinedMessages, + autoScroll: true, + theme: 'light', + emptyMessage: 'Loading conversation...', + }), + ); + ReactDOM.render(ChatApp, rootElement); + + diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.css b/packages/webui/src/components/ChatViewer/ChatViewer.css index 3d8144caf..94b8dca78 100644 --- a/packages/webui/src/components/ChatViewer/ChatViewer.css +++ b/packages/webui/src/components/ChatViewer/ChatViewer.css @@ -15,9 +15,22 @@ flex-direction: column; width: 100%; height: 100%; - background-color: var(--app-background, var(--app-primary-background, #1e1e1e)); + background-color: var( + --app-background, + var(--app-primary-background, #1e1e1e) + ); color: var(--app-primary-foreground, #cccccc); - font-family: var(--vscode-chat-font-family, var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif)); + font-family: var( + --vscode-chat-font-family, + var( + --vscode-font-family, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif + ) + ); font-size: var(--vscode-chat-font-size, 13px); overflow: hidden; } @@ -58,21 +71,25 @@ /* Light theme scrollbar styling */ @media (prefers-color-scheme: light) { - .chat-viewer-container.auto-theme .chat-viewer-messages::-webkit-scrollbar-thumb { + .chat-viewer-container.auto-theme + .chat-viewer-messages::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); } - - .chat-viewer-container.auto-theme .chat-viewer-messages::-webkit-scrollbar-thumb:hover { + + .chat-viewer-container.auto-theme + .chat-viewer-messages::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.3); } } /* Force light theme scrollbar */ -.chat-viewer-container.light-theme .chat-viewer-messages::-webkit-scrollbar-thumb { +.chat-viewer-container.light-theme + .chat-viewer-messages::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); } -.chat-viewer-container.light-theme .chat-viewer-messages::-webkit-scrollbar-thumb:hover { +.chat-viewer-container.light-theme + .chat-viewer-messages::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.3); } diff --git a/packages/webui/src/components/messages/Assistant/AssistantMessage.css b/packages/webui/src/components/messages/Assistant/AssistantMessage.css index a9a1369fd..24ebbe26f 100644 --- a/packages/webui/src/components/messages/Assistant/AssistantMessage.css +++ b/packages/webui/src/components/messages/Assistant/AssistantMessage.css @@ -63,8 +63,13 @@ } @keyframes assistantPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } } /* Timeline connector line - full height by default */ diff --git a/packages/webui/src/styles/components.css b/packages/webui/src/styles/components.css index e873e7d9e..7ef3cd237 100644 --- a/packages/webui/src/styles/components.css +++ b/packages/webui/src/styles/components.css @@ -180,7 +180,10 @@ align-items: center; justify-content: space-between; padding: 8px 12px; - background: var(--app-input-secondary-background, var(--app-background-secondary)); + background: var( + --app-input-secondary-background, + var(--app-background-secondary) + ); border-bottom: 1px solid var(--app-input-border); } @@ -393,7 +396,10 @@ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); transition: border-color 0.2s; z-index: 1; - background: var(--app-input-secondary-background, var(--app-background-secondary)); + background: var( + --app-input-secondary-background, + var(--app-background-secondary) + ); color: var(--app-input-foreground); } @@ -424,7 +430,7 @@ } .composer-input:empty::before, -.composer-input[data-empty="true"]::before { +.composer-input[data-empty='true']::before { content: attr(data-placeholder); color: var(--app-input-placeholder-foreground); pointer-events: none; @@ -440,7 +446,7 @@ } .composer-input:disabled, -.composer-input[contenteditable="false"] { +.composer-input[contenteditable='false'] { color: #999; cursor: not-allowed; } diff --git a/packages/webui/src/styles/timeline.css b/packages/webui/src/styles/timeline.css index b69aeb815..d437938df 100644 --- a/packages/webui/src/styles/timeline.css +++ b/packages/webui/src/styles/timeline.css @@ -46,7 +46,9 @@ top: var(--timeline-center-offset, 13px); } -.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after, +.qwen-message.message-item:not(.user-message-container):has( + + .user-message-container + )::after, .qwen-message.message-item:not(.user-message-container):has( + :not(.qwen-message.message-item) )::after, diff --git a/scripts/build.js b/scripts/build.js index 68da1c6e8..0ce010b3b 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -56,6 +56,15 @@ for (const workspace of buildOrder) { stdio: 'inherit', cwd: root, }); + + // After cli is built, generate the JSON Schema for settings + // so the vscode-ide-companion extension can provide IntelliSense + if (workspace === 'packages/cli') { + execSync('npx tsx scripts/generate-settings-schema.ts', { + stdio: 'inherit', + cwd: root, + }); + } } // also build container image if sandboxing is enabled diff --git a/scripts/build_sandbox.js b/scripts/build_sandbox.js index 2dffe892c..55e8a79bb 100644 --- a/scripts/build_sandbox.js +++ b/scripts/build_sandbox.js @@ -135,7 +135,7 @@ function buildImage(imageName, dockerfile) { ).version; const imageTag = - process.env.GEMINI_SANDBOX_IMAGE_TAG || imageName.split(':')[1]; + process.env.QWEN_SANDBOX_IMAGE_TAG || imageName.split(':')[1]; const finalImageName = `${imageName.split(':')[0]}:${imageTag}`; try { diff --git a/scripts/dev.js b/scripts/dev.js index 8432a32ce..32f4a2280 100644 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -15,18 +15,15 @@ */ import { spawn } from 'node:child_process'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { tmpdir, platform } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); const cliPackageDir = join(root, 'packages', 'cli'); -// Resolve tsx from node_modules -const tsxPath = resolve(root, 'node_modules', '.bin', 'tsx'); - // Entry point for the CLI const cliEntry = join(cliPackageDir, 'index.ts'); @@ -79,12 +76,16 @@ const env = { NODE_OPTIONS: `${existingNodeOptions} ${importFlag}`.trim(), }; -const nodeArgs = [tsxPath, cliEntry, ...process.argv.slice(2)]; +// On Windows, use tsx.cmd; on Unix, use tsx directly +const isWin = platform() === 'win32'; +const tsxCmd = isWin ? 'tsx.cmd' : 'tsx'; +const tsxArgs = [cliEntry, ...process.argv.slice(2)]; -const child = spawn('node', nodeArgs, { +const child = spawn(tsxCmd, tsxArgs, { stdio: 'inherit', env, cwd: process.cwd(), + shell: isWin, // Use shell on Windows to resolve .cmd files }); child.on('error', (err) => { diff --git a/scripts/generate-settings-schema.ts b/scripts/generate-settings-schema.ts new file mode 100644 index 000000000..9d13e8166 --- /dev/null +++ b/scripts/generate-settings-schema.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Generates a JSON Schema from the internal SETTINGS_SCHEMA definition. + * + * Usage: npx tsx scripts/generate-settings-schema.ts + * + * This reads the TypeScript settings schema and converts it to a standard + * JSON Schema file that VS Code uses for IntelliSense in settings.json files. + * + * Prerequisites: npm run build (core package must be built first) + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { + SettingDefinition, + SettingsSchema, +} from '../packages/cli/src/config/settingsSchema.js'; +import { getSettingsSchema } from '../packages/cli/src/config/settingsSchema.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface JsonSchemaProperty { + $schema?: string; + type?: string | string[]; + description?: string; + properties?: Record; + items?: JsonSchemaProperty; + enum?: (string | number)[]; + default?: unknown; + additionalProperties?: boolean | JsonSchemaProperty; +} + +function convertSettingToJsonSchema( + setting: SettingDefinition, +): JsonSchemaProperty { + const schema: JsonSchemaProperty = {}; + + if (setting.description) { + schema.description = setting.description; + } + + switch (setting.type) { + case 'boolean': + schema.type = 'boolean'; + break; + case 'string': + schema.type = 'string'; + break; + case 'number': + schema.type = 'number'; + break; + case 'array': + schema.type = 'array'; + schema.items = { type: 'string' }; + break; + case 'enum': + if (setting.options && setting.options.length > 0) { + schema.enum = setting.options.map((o) => o.value); + schema.description += + ' Options: ' + setting.options.map((o) => `${o.value}`).join(', '); + } else { + // Enum without predefined options - accept any string + schema.type = 'string'; + } + break; + case 'object': + schema.type = 'object'; + if (setting.properties) { + schema.properties = {}; + for (const [key, childDef] of Object.entries(setting.properties)) { + schema.properties[key] = convertSettingToJsonSchema( + childDef as SettingDefinition, + ); + } + } else { + schema.additionalProperties = true; + } + break; + } + + // Add default value for simple types only + if (setting.default !== undefined && setting.default !== null) { + const defaultVal = setting.default; + if ( + typeof defaultVal === 'boolean' || + typeof defaultVal === 'number' || + typeof defaultVal === 'string' + ) { + schema.default = defaultVal; + } else if (Array.isArray(defaultVal) && defaultVal.length > 0) { + schema.default = defaultVal; + } + } + + return schema; +} + +function generateJsonSchema( + settingsSchema: SettingsSchema, +): JsonSchemaProperty { + const jsonSchema: JsonSchemaProperty = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + description: 'Qwen Code settings configuration', + properties: {}, + additionalProperties: true, + }; + + for (const [key, setting] of Object.entries(settingsSchema)) { + jsonSchema.properties![key] = convertSettingToJsonSchema( + setting as SettingDefinition, + ); + } + + // Add $version property + jsonSchema.properties!['$version'] = { + type: 'number', + description: 'Settings schema version for migration tracking.', + default: 3, + }; + + return jsonSchema; +} + +const schema = getSettingsSchema(); +const jsonSchema = generateJsonSchema(schema as unknown as SettingsSchema); + +const outputDir = path.resolve( + __dirname, + '../packages/vscode-ide-companion/schemas', +); +const outputPath = path.join(outputDir, 'settings.schema.json'); + +fs.mkdirSync(outputDir, { recursive: true }); +fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2) + '\n'); + +console.log(`Generated settings JSON Schema at: ${outputPath}`); diff --git a/scripts/sandbox_command.js b/scripts/sandbox_command.js index 75cc4127a..629e96f60 100644 --- a/scripts/sandbox_command.js +++ b/scripts/sandbox_command.js @@ -32,27 +32,27 @@ const argv = yargs(hideBin(process.argv)).option('q', { default: false, }).argv; -let geminiSandbox = process.env.GEMINI_SANDBOX; +let qwenSandbox = process.env.QWEN_SANDBOX; -if (!geminiSandbox) { +if (!qwenSandbox) { const userSettingsFile = join(os.homedir(), '.qwen', 'settings.json'); if (existsSync(userSettingsFile)) { const settings = JSON.parse( stripJsonComments(readFileSync(userSettingsFile, 'utf-8')), ); if (settings.sandbox) { - geminiSandbox = settings.sandbox; + qwenSandbox = settings.sandbox; } } } -if (!geminiSandbox) { +if (!qwenSandbox) { let currentDir = process.cwd(); while (true) { - const geminiEnv = join(currentDir, '.qwen', '.env'); + const qwenEnv = join(currentDir, '.qwen', '.env'); const regularEnv = join(currentDir, '.env'); - if (existsSync(geminiEnv)) { - dotenv.config({ path: geminiEnv, quiet: true }); + if (existsSync(qwenEnv)) { + dotenv.config({ path: qwenEnv, quiet: true }); break; } else if (existsSync(regularEnv)) { dotenv.config({ path: regularEnv, quiet: true }); @@ -64,10 +64,10 @@ if (!geminiSandbox) { } currentDir = parentDir; } - geminiSandbox = process.env.GEMINI_SANDBOX; + qwenSandbox = process.env.QWEN_SANDBOX; } -geminiSandbox = (geminiSandbox || '').toLowerCase(); +qwenSandbox = (qwenSandbox || '').toLowerCase(); const commandExists = (cmd) => { // Use 'where.exe' (not 'where') on Windows because PowerShell aliases @@ -90,23 +90,23 @@ const commandExists = (cmd) => { }; let command = ''; -if (['1', 'true'].includes(geminiSandbox)) { +if (['1', 'true'].includes(qwenSandbox)) { if (commandExists('docker')) { command = 'docker'; } else if (commandExists('podman')) { command = 'podman'; } else { console.error( - 'ERROR: install docker or podman or specify command in GEMINI_SANDBOX', + 'ERROR: install docker or podman or specify command in QWEN_SANDBOX', ); process.exit(1); } -} else if (geminiSandbox && !['0', 'false'].includes(geminiSandbox)) { - if (commandExists(geminiSandbox)) { - command = geminiSandbox; +} else if (qwenSandbox && !['0', 'false'].includes(qwenSandbox)) { + if (commandExists(qwenSandbox)) { + command = qwenSandbox; } else { console.error( - `ERROR: missing sandbox command '${geminiSandbox}' (from GEMINI_SANDBOX)`, + `ERROR: missing sandbox command '${qwenSandbox}' (from QWEN_SANDBOX)`, ); process.exit(1); } diff --git a/scripts/telemetry_utils.js b/scripts/telemetry_utils.js index 504ed18cb..880652191 100644 --- a/scripts/telemetry_utils.js +++ b/scripts/telemetry_utils.js @@ -37,7 +37,7 @@ const projectHash = getProjectHash(projectRoot); // User-level .gemini directory in home const USER_GEMINI_DIR = path.join(os.homedir(), '.qwen'); // Project-level .gemini directory in the workspace -const WORKSPACE_GEMINI_DIR = path.join(projectRoot, '.qwen'); +const WORKSPACE_QWEN_DIR = path.join(projectRoot, '.qwen'); // Telemetry artifacts are stored in a hashed directory under the user's ~/.qwen/tmp export const OTEL_DIR = path.join(USER_GEMINI_DIR, 'tmp', projectHash, 'otel'); @@ -45,7 +45,7 @@ export const BIN_DIR = path.join(OTEL_DIR, 'bin'); // Workspace settings remain in the project's .gemini directory export const WORKSPACE_SETTINGS_FILE = path.join( - WORKSPACE_GEMINI_DIR, + WORKSPACE_QWEN_DIR, 'settings.json', );