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/.gitignore b/.gitignore index 27e0ab904..493296158 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ package-lock.json *.iml .cursor .qoder +.claude +CLAUDE.md # OS metadata .DS_Store @@ -53,9 +55,11 @@ packages/vscode-ide-companion/*.vsix # Qwen Code Configs -.qwen/ +.qwen/* !.qwen/commands/ +!.qwen/commands/** !.qwen/skills/ +!.qwen/skills/** logs/ # GHA credentials gha-creds-*.json diff --git a/.qwen/commands/qc/code-review.md b/.qwen/commands/qc/code-review.md index b5846485a..021a80d9f 100644 --- a/.qwen/commands/qc/code-review.md +++ b/.qwen/commands/qc/code-review.md @@ -14,6 +14,7 @@ You are an expert code reviewer. Follow these steps: - Any potential issues or risks Keep your review concise but thorough. Focus on: + - Code correctness - Following project conventions - Performance implications diff --git a/.qwen/commands/qc/commit.md b/.qwen/commands/qc/commit.md index 76ef6b417..fab58da2e 100644 --- a/.qwen/commands/qc/commit.md +++ b/.qwen/commands/qc/commit.md @@ -5,22 +5,26 @@ description: Commit staged changes with an AI-generated commit message and push # Commit and Push ## Overview + Generate a clear, concise commit message based on staged changes, confirm with the user, then commit and push. ## Steps ### 1. Check repository status + - Run `git status` to check: - Are there any staged changes? - Are there unstaged changes? - What is the current branch? ### 2. Handle unstaged changes + - If there are unstaged changes, notify the user and list them - Do NOT add or commit unstaged changes - Proceed only with staged changes ### 3. Review staged changes + - Run `git diff --staged` to see all staged changes - Analyze the changes in depth to understand: - What files were modified/added/deleted @@ -28,6 +32,7 @@ Generate a clear, concise commit message based on staged changes, confirm with t - The scope and impact of the changes ### 4. Handle branch logic + - Get current branch name with `git branch --show-current` - **If current branch is `main` or `master`:** - Generate a proper branch name based on the changes @@ -40,6 +45,7 @@ Generate a clear, concise commit message based on staged changes, confirm with t - Wait for user decision ### 5. Generate commit message + - Types: feat, fix, docs, style, refactor, test, chore - Guidelines: - Be clear and concise @@ -49,6 +55,7 @@ Generate a clear, concise commit message based on staged changes, confirm with t - Include a footer explaining the purpose/impact of the changes **Format:** + ``` (): - (optional) @@ -59,12 +66,14 @@ This . ``` ### 6. Present the result and confirm with user + - Present the generated commit message - Show which branch will be used - Ask for confirmation: "Proceed with commit and push?" - Wait for user approval ### 7. Commit and push + - After user confirms: - `git commit -m ""` - `git push -u origin ` (use `-u` for new branches) diff --git a/.qwen/commands/qc/create-issue.md b/.qwen/commands/qc/create-issue.md index 54317621b..020ef00d0 100644 --- a/.qwen/commands/qc/create-issue.md +++ b/.qwen/commands/qc/create-issue.md @@ -5,9 +5,11 @@ description: Draft and submit a GitHub issue based on a user-provided idea # Create Issue ## Overview + Take the user's idea or bug description, investigate the codebase to understand the full context, draft a GitHub issue for review, and submit it once approved. ## Input + The user provides a brief description of a feature request or bug report: {{args}} ## Steps diff --git a/.qwen/commands/qc/create-pr.md b/.qwen/commands/qc/create-pr.md index bf3c3c1e4..f2b491925 100644 --- a/.qwen/commands/qc/create-pr.md +++ b/.qwen/commands/qc/create-pr.md @@ -5,9 +5,11 @@ description: Create a pull request based on staged code changes # Create PR ## Overview + Create a well-structured pull request with proper description and title. ## Steps + 1. **Review staged changes** - Review all staged changes to understand what has been done - Do not touch unstaged changes @@ -31,4 +33,4 @@ Create a well-structured pull request with proper description and title. ## PR Template -@{.github/pull_request_template.md} \ No newline at end of file +@{.github/pull_request_template.md} diff --git a/.qwen/skills/docs-audit-and-refresh/SKILL.md b/.qwen/skills/docs-audit-and-refresh/SKILL.md new file mode 100644 index 000000000..f06161632 --- /dev/null +++ b/.qwen/skills/docs-audit-and-refresh/SKILL.md @@ -0,0 +1,71 @@ +--- +name: docs-audit-and-refresh +description: Audit the repository's docs/ content against the current codebase, find missing, incorrect, or stale documentation, and refresh the affected pages. Use when the user asks to review docs coverage, find outdated docs, compare docs with the current repo, or fix documentation drift across features, settings, tools, or integrations. +--- + +# Docs Audit And Refresh + +## Overview + +Audit `docs/` from the repository outward: inspect the current implementation, identify documentation gaps or inaccuracies, and update the relevant pages. Keep the work inside `docs/` and treat code, tests, and current configuration surfaces as the authoritative source. + +Read [references/audit-checklist.md](references/audit-checklist.md) before a broad audit so the scan stays focused on high-signal areas. + +## Workflow + +### 1. Build a current-state inventory + +Inspect the repository areas that define user-facing or developer-facing behavior. + +- Read the relevant code, tests, schemas, and package surfaces. +- Focus on shipped behavior, stable configuration, exposed commands, integrations, and developer workflows. +- Use the existing docs tree as a map of intended coverage, not as proof that coverage is complete. + +### 2. Compare implementation against `docs/` + +Look for three classes of issues: + +- Missing documentation for an existing feature, setting, tool, or workflow +- Incorrect documentation that contradicts the current codebase +- Stale documentation that uses old names, defaults, paths, or examples + +Prefer proving a gap with repository evidence before editing. Use current code and tests instead of intuition. + +### 3. Prioritize by reader impact + +Fix the highest-cost issues first: + +1. Broken onboarding, setup, auth, installation, or command flows +2. Wrong settings, defaults, paths, or feature behavior +3. Entirely missing documentation for a real surface area +4. Lower-impact clarity or organization improvements + +### 4. Refresh the docs + +Update the smallest correct set of pages under `docs/`. + +- Edit existing pages first +- Add new pages only for clear, durable gaps +- Update the nearest `_meta.ts` when adding or moving pages +- Keep examples executable and aligned with the current repository structure +- Remove dead or misleading text instead of layering warnings on top + +### 5. Validate the refresh + +Before finishing: + +- Search `docs/` for old terminology and replaced config keys +- Check neighboring pages for conflicting guidance +- Confirm new pages appear in the right `_meta.ts` +- Re-read critical examples, commands, and paths against code or tests + +## Audit standards + +- Favor breadth-first discovery, then depth on confirmed gaps. +- Do not rewrite large areas without evidence that they are wrong or missing. +- Keep README files out of scope for edits; limit changes to `docs/`. +- Call out residual gaps if the audit finds issues that are too large to solve in one pass. + +## Deliverable + +Produce a focused docs refresh that makes the current repository more accurate and complete. Summarize the audited surfaces and the concrete pages updated. diff --git a/.qwen/skills/docs-audit-and-refresh/references/audit-checklist.md b/.qwen/skills/docs-audit-and-refresh/references/audit-checklist.md new file mode 100644 index 000000000..54c0fb00f --- /dev/null +++ b/.qwen/skills/docs-audit-and-refresh/references/audit-checklist.md @@ -0,0 +1,41 @@ +# Audit Checklist + +Use this checklist to keep repository-wide documentation audits focused and repeatable. + +## High-signal repository surfaces + +- `packages/cli/**` + Inspect commands, flows, prompts, flags, and CLI-facing behavior. +- `packages/core/**` + Inspect shared behavior, settings, tools, provider integration, and feature semantics. +- `packages/sdk-typescript/**` and `packages/sdk-java/**` + Inspect SDK setup, usage, and examples that may affect developer docs. +- `packages/vscode-ide-companion/**`, `packages/zed-extension/**`, and related integration packages + Inspect IDE and extension behavior that should be reflected in user docs. +- `docs/**/_meta.ts` + Inspect navigation completeness after creating or moving pages. + +## Gap detection prompts + +Ask these questions while comparing the repo to `docs/`: + +- Does a visible feature exist in code but have no page or section in `docs/`? +- Does a docs page mention a command, setting, provider, or path that no longer exists? +- Do examples still match the current repository layout and command syntax? +- Is a page present but hidden or missing from `_meta.ts`? +- Do multiple pages describe the same feature inconsistently? + +## Common drift patterns + +- Renamed settings keys or changed defaults +- Updated authentication or provider configuration flow +- New or removed CLI commands and flags +- New tool behavior or approval/sandbox semantics +- IDE integration changes that never reached the docs +- Features documented in the wrong section, making them hard to find + +## Output standard + +- Prefer a small number of precise edits over a speculative docs rewrite. +- Leave a clear summary of what was missing, wrong, or stale. +- If the audit uncovers a larger docs reorganization, fix the highest-impact inaccuracies first and note the remaining work. diff --git a/.qwen/skills/docs-update-from-diff/SKILL.md b/.qwen/skills/docs-update-from-diff/SKILL.md new file mode 100644 index 000000000..1f7eb722c --- /dev/null +++ b/.qwen/skills/docs-update-from-diff/SKILL.md @@ -0,0 +1,73 @@ +--- +name: docs-update-from-diff +description: Review local code changes with git diff and update the official docs under docs/ to match. Use when the user asks to document current uncommitted work, sync docs with local changes, update docs after a feature or refactor, or when phrases like "git diff", "local changes", "update docs", or "official docs" appear. +--- + +# Docs Update From Diff + +## Overview + +Inspect local diffs, derive the documentation impact, and update only the repository's `docs/` pages. Treat the current code as the source of truth and keep changes scoped, specific, and navigable. + +Read [references/docs-surface.md](references/docs-surface.md) before editing if the affected feature does not map cleanly to an existing docs section. + +## Workflow + +### 1. Build the change set + +Start from local Git state, not from assumptions. + +- Inspect `git status --short`, `git diff --stat`, and targeted `git diff` output. +- Focus on non-doc changes first so the documentation delta is grounded in code. +- Ignore `README.md` and other non-`docs/` content unless they help confirm intent. + +### 2. Derive the docs impact + +For every changed behavior, extract the user-facing or developer-facing facts that documentation must reflect. + +- New command, flag, config key, default, workflow, or limitation +- Renamed behavior or removed behavior +- Changed examples, paths, or setup steps +- New feature that belongs in an existing page but is not mentioned yet + +Prefer updating an existing page over creating a new page. Create a new page only when the feature introduces a stable topic that would make an existing page harder to follow. + +### 3. Find the right docs location + +Map each change to the smallest correct documentation surface: + +- End-user behavior: `docs/users/**` +- Developer internals, SDKs, contributor workflow, tooling: `docs/developers/**` +- Shared landing or navigation changes: root `docs/**` and `_meta.ts` + +If you add a new page, update the nearest `_meta.ts` in the same docs section so the page is discoverable. + +### 4. Write the update + +Edit documentation with the following bar: + +- State the current behavior, not the implementation history +- Use concrete commands, file paths, setting keys, and defaults from the diff +- Remove or rewrite stale text instead of stacking caveats on top of it +- Keep examples aligned with the current CLI and repository layout +- Preserve the repository's existing docs tone and heading structure + +### 5. Cross-check before finishing + +Verify that the updated docs cover the actual delta: + +- Search `docs/` for old names, removed flags, or outdated examples +- Confirm links and relative paths still make sense +- Confirm any new page is included in the relevant `_meta.ts` +- Re-read the changed docs against the code diff, not against memory + +## Practical heuristics + +- If a change affects commands, also check quickstart, workflows, and feature pages for drift. +- If a change affects configuration, also check `docs/users/configuration/settings.md`, feature pages, and auth/provider docs. +- If a change affects tools or agent behavior, check both `docs/users/features/**` and `docs/developers/tools/**` when relevant. +- If tests reveal expected behavior more clearly than implementation code, use tests to confirm wording. + +## Deliverable + +Produce the docs edits under `docs/` that make the current local changes understandable to a reader who has not seen the diff. Keep the final summary short and identify which pages were updated. diff --git a/.qwen/skills/docs-update-from-diff/references/docs-surface.md b/.qwen/skills/docs-update-from-diff/references/docs-surface.md new file mode 100644 index 000000000..a55f0a9b4 --- /dev/null +++ b/.qwen/skills/docs-update-from-diff/references/docs-surface.md @@ -0,0 +1,39 @@ +# Docs Surface Map + +Use this file to choose the correct destination page under `docs/`. + +## Primary sections + +- `docs/users/overview.md`, `quickstart.md`, `common-workflow.md` + Good for entry points, first-run guidance, and broad user workflows. +- `docs/users/features/*.md` + Good for user-visible features such as skills, MCP, sandbox, sub-agents, commands, checkpointing, and approval modes. +- `docs/users/configuration/*.md` + Good for settings, auth, model providers, themes, trusted folders, `.qwen` files, and similar configuration topics. +- `docs/users/integration-*.md` and `docs/users/ide-integration/*.md` + Good for IDEs, GitHub Actions, and editor companion behavior. +- `docs/users/extension/*.md` + Good for extension authoring and extension usage. +- `docs/developers/*.md` + Good for architecture, contributing workflow, roadmaps, and SDK overviews. +- `docs/developers/tools/*.md` + Good for tool behavior, tool contracts, and implementation-facing explanations. +- `docs/developers/development/*.md` + Good for contributor setup, deployment, tests, telemetry, and automation details. + +## Navigation rules + +- Root navigation lives in `docs/_meta.ts`. +- Section navigation lives in the nearest `_meta.ts`, for example: + - `docs/users/_meta.ts` + - `docs/users/features/_meta.ts` + - `docs/developers/_meta.ts` + - `docs/developers/tools/_meta.ts` +- If you create a page and do not add it to the right `_meta.ts`, the docs will be incomplete even if the markdown exists. + +## Placement heuristics + +- Put the change where a reader would naturally look first. +- Update multiple pages when a single feature appears in setup, reference, and workflow docs. +- Prefer adjusting a nearby existing page instead of creating a top-level page for a small delta. +- Avoid duplicating long explanations across pages; add one source page and update nearby pages with short pointers if needed. diff --git a/.qwen/skills/terminal-capture/SKILL.md b/.qwen/skills/terminal-capture/SKILL.md index 7fc99a18d..043f49542 100644 --- a/.qwen/skills/terminal-capture/SKILL.md +++ b/.qwen/skills/terminal-capture/SKILL.md @@ -211,31 +211,31 @@ This tool is commonly used for visual verification during PR reviews. For the co ```typescript interface FlowStep { - type?: string; // Input text - key?: string | string[]; // Key press(es) - capture?: string; // Viewport screenshot filename - captureFull?: string; // Full scrollback screenshot filename + type?: string; // Input text + key?: string | string[]; // Key press(es) + capture?: string; // Viewport screenshot filename + captureFull?: string; // Full scrollback screenshot filename streaming?: { - delayMs?: number; // Delay before first capture (default: 0) - intervalMs: number; // Interval between captures in ms - count: number; // Maximum number of captures - gif?: boolean; // Generate animated GIF (default: true) + delayMs?: number; // Delay before first capture (default: 0) + intervalMs: number; // Interval between captures in ms + count: number; // Maximum number of captures + gif?: boolean; // Generate animated GIF (default: true) }; } interface ScenarioConfig { - name: string; // Scenario name (also used as screenshot subdirectory name) - spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"] - flow: FlowStep[]; // Interaction steps + name: string; // Scenario name (also used as screenshot subdirectory name) + spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"] + flow: FlowStep[]; // Interaction steps terminal?: { - cols?: number; // Number of columns, default 100 - rows?: number; // Number of rows, default 28 - theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl - chrome?: boolean; // macOS window decorations, default true - title?: string; // Window title, default "Terminal" - fontSize?: number; // Font size - cwd?: string; // Working directory (relative to config file) + cols?: number; // Number of columns, default 100 + rows?: number; // Number of rows, default 28 + theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl + chrome?: boolean; // macOS window decorations, default true + title?: string; // Window title, default "Terminal" + fontSize?: number; // Font size + cwd?: string; // Working directory (relative to config file) }; - outputDir?: string; // Screenshot output directory (relative to config file) + outputDir?: string; // Screenshot output directory (relative to config file) } ``` diff --git a/QWEN.md b/QWEN.md new file mode 100644 index 000000000..3e15e6d6f --- /dev/null +++ b/QWEN.md @@ -0,0 +1,297 @@ +# Qwen Code - Project Context + +## Project Overview + +**Qwen Code** is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps developers understand large codebases, automate tedious work, and ship faster. + +This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) with adaptations to better support Qwen-Coder models. + +### Key Features + +- **OpenAI-compatible, OAuth free tier**: Use an OpenAI-compatible API, or sign in with Qwen OAuth to get 1,000 free requests/day +- **Agentic workflow, feature-rich**: Rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow +- **Terminal-first, IDE-friendly**: Built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs + +## Technology Stack + +- **Runtime**: Node.js 20+ +- **Language**: TypeScript 5.3+ +- **Package Manager**: npm with workspaces +- **Build Tool**: esbuild +- **Testing**: Vitest +- **Linting**: ESLint + Prettier +- **UI Framework**: Ink (React for CLI) +- **React Version**: 19.x + +## Project Structure + +``` +├── packages/ +│ ├── cli/ # Command-line interface (main entry point) +│ ├── core/ # Core backend logic and tool implementations +│ ├── sdk-java/ # Java SDK +│ ├── sdk-typescript/ # TypeScript SDK +│ ├── test-utils/ # Shared testing utilities +│ ├── vscode-ide-companion/ # VS Code extension companion +│ ├── webui/ # Web UI components +│ └── zed-extension/ # Zed editor extension +├── scripts/ # Build and utility scripts +├── docs/ # Documentation source +├── docs-site/ # Documentation website (Next.js) +├── integration-tests/ # End-to-end integration tests +└── eslint-rules/ # Custom ESLint rules +``` + +### Package Details + +#### `@qwen-code/qwen-code` (packages/cli/) + +The main CLI package providing: + +- Interactive terminal UI using Ink/React +- Non-interactive/headless mode +- Authentication handling (OAuth, API keys) +- Configuration management +- Command system (`/help`, `/clear`, `/compress`, etc.) + +#### `@qwen-code/qwen-code-core` (packages/core/) + +Core library containing: + +- **Tools**: File operations (read, write, edit, glob, grep), shell execution, web fetch, LSP integration, MCP client +- **Subagents**: Task delegation to specialized agents +- **Skills**: Reusable skill system +- **Models**: Model configuration and registry for Qwen and OpenAI-compatible APIs +- **Services**: Git integration, file discovery, session management +- **LSP Support**: Language Server Protocol integration +- **MCP**: Model Context Protocol implementation + +## Building and Running + +### Prerequisites + +- **Node.js**: ~20.19.0 for development (use nvm to manage versions) +- **Git** +- For sandboxing: Docker or Podman (optional but recommended) + +### Setup + +```bash +# Clone and install +git clone https://github.com/QwenLM/qwen-code.git +cd qwen-code +npm install +``` + +### Build Commands + +```bash +# Build all packages +npm run build + +# Build everything including sandbox and VSCode companion +npm run build:all + +# Build only packages +npm run build:packages + +# Development mode with hot reload +npm run dev + +# Bundle for distribution +npm run bundle +``` + +### Running + +```bash +# Start interactive CLI +npm start + +# Or after global installation +qwen + +# Debug mode +npm run debug + +# With environment variables +DEBUG=1 npm start +``` + +### Testing + +```bash +# Run all unit tests +npm run test + +# Run integration tests (no sandbox) +npm run test:e2e + +# Run all integration tests with different sandbox modes +npm run test:integration:all + +# Terminal benchmark tests +npm run test:terminal-bench +``` + +### Code Quality + +```bash +# Run all checks (lint, format, build, test) +npm run preflight + +# Lint only +npm run lint +npm run lint:fix + +# Format only +npm run format + +# Type check +npm run typecheck +``` + +## Development Conventions + +### Code Style + +- **Strict TypeScript**: All strict flags enabled (`strictNullChecks`, `noImplicitAny`, etc.) +- **Module System**: ES modules (`"type": "module"`) +- **Import Style**: Node.js native ESM with `.js` extensions in imports +- **No Relative Imports Between Packages**: ESLint enforces this restriction + +### Key Configuration Files + +- `tsconfig.json`: Base TypeScript configuration with strict settings +- `eslint.config.js`: ESLint flat config with custom rules +- `esbuild.config.js`: Build configuration +- `vitest.config.ts`: Test configuration + +### Import Patterns + +```typescript +// Within a package - use relative paths +import { something } from './utils/something.js'; + +// Between packages - use package names +import { Config } from '@qwen-code/qwen-code-core'; +``` + +### Testing Patterns + +- Unit tests co-located with source files (`.test.ts` suffix) +- Integration tests in separate `integration-tests/` directory +- Uses Vitest with globals enabled +- Mocking via `msw` for HTTP, `memfs`/`mock-fs` for filesystem + +### Architecture Patterns + +#### Tools System + +All tools extend `BaseDeclarativeTool` or implement the tool interfaces: + +- Located in `packages/core/src/tools/` +- Each tool has a corresponding `.test.ts` file +- Tools are registered in the tool registry + +#### Subagents System + +Task delegation framework: + +- Configuration stored as Markdown + YAML frontmatter +- Supports both project-level and user-level subagents +- Event-driven architecture for UI updates + +#### Configuration System + +Hierarchical configuration loading: + +1. Default values +2. User settings (`~/.qwen/settings.json`) +3. Project settings (`.qwen/settings.json`) +4. Environment variables +5. CLI flags + +### Authentication Methods + +1. **Qwen OAuth** (recommended): Browser-based OAuth flow +2. **OpenAI-compatible API**: Via `OPENAI_API_KEY` environment variable + +Environment variables for API mode: + +```bash +export OPENAI_API_KEY="your-api-key" +export OPENAI_BASE_URL="https://api.openai.com/v1" # optional +export OPENAI_MODEL="gpt-4o" # optional +``` + +## Debugging + +### VS Code + +Press `F5` to launch with debugger attached, or: + +```bash +npm run debug # Runs with --inspect-brk +``` + +### React DevTools (for CLI UI) + +```bash +DEV=true npm start +npx react-devtools@4.28.5 +``` + +### Sandbox Debugging + +```bash +DEBUG=1 qwen +``` + +## Documentation + +- User documentation: +- Local docs development: + + ```bash + cd docs-site + npm install + npm run link # Links ../docs to content + npm run dev # http://localhost:3000 + ``` + +## Contributing Guidelines + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed guidelines. Key points: + +1. Link PRs to existing issues +2. Keep PRs small and focused +3. Use Draft PRs for WIP +4. Ensure `npm run preflight` passes +5. Update documentation for user-facing changes +6. Follow Conventional Commits for commit messages + +## Useful Commands Reference + +| Command | Description | +| ------------------- | -------------------------------------------------------------------- | +| `npm start` | Start CLI in interactive mode | +| `npm run dev` | Development mode with hot reload | +| `npm run build` | Build all packages | +| `npm run test` | Run unit tests | +| `npm run test:e2e` | Run integration tests | +| `npm run preflight` | Full CI check (clean, install, format, lint, build, typecheck, test) | +| `npm run lint` | Run ESLint | +| `npm run format` | Run Prettier | +| `npm run clean` | Clean build artifacts | + +## Session Commands (within CLI) + +- `/help` - Display available commands +- `/clear` - Clear conversation history +- `/compress` - Compress history to save tokens +- `/stats` - Show session information +- `/bug` - Submit bug report +- `/exit` or `/quit` - Exit Qwen Code + +--- diff --git a/README.md b/README.md index ab598666c..8d7293137 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Code #### Linux / macOS ```bash -curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash +bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)" ``` #### Windows (Run as Administrator CMD) diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index f5faee40e..94859048e 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -139,16 +139,16 @@ Logs are timestamped records of specific events. The following events are logged - `qwen-code.config`: This event occurs once at startup with the CLI's configuration. - **Attributes**: - `model` (string) - - `embedding_model` (string) - `sandbox_enabled` (boolean) - `core_tools_enabled` (string) - `approval_mode` (string) - - `api_key_enabled` (boolean) - - `vertex_ai_enabled` (boolean) - - `code_assist_enabled` (boolean) - - `log_prompts_enabled` (boolean) - `file_filtering_respect_git_ignore` (boolean) - `debug_mode` (boolean) + - `truncate_tool_output_threshold` (number) + - `truncate_tool_output_lines` (number) + - `hooks` (string, comma-separated hook event types, omitted if hooks disabled) + - `ide_enabled` (boolean) + - `interactive_shell_enabled` (boolean) - `mcp_servers` (string) - `output_format` (string: "text" or "json") diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md index 46625e840..4c705f068 100644 --- a/docs/developers/sdk-typescript.md +++ b/docs/developers/sdk-typescript.md @@ -63,6 +63,7 @@ Creates a new query session with the Qwen Code. | `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | | `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | | `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | | `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | | `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | | `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | @@ -248,6 +249,36 @@ const result = query({ }); ``` +### Override the System Prompt + +```typescript +import { query } from '@qwen-code/sdk'; + +const result = query({ + prompt: 'Say hello in one sentence.', + options: { + systemPrompt: 'You are a terse assistant. Answer in exactly one sentence.', + }, +}); +``` + +### Append to the Built-in System Prompt + +```typescript +import { query } from '@qwen-code/sdk'; + +const result = query({ + prompt: 'Review the current directory.', + options: { + systemPrompt: { + type: 'preset', + preset: 'qwen_code', + append: 'Be terse and focus on concrete findings.', + }, + }, +}); +``` + ### With SDK-Embedded MCP Servers The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process. diff --git a/docs/developers/tools/file-system.md b/docs/developers/tools/file-system.md index bfa6de8d0..118f5e0b6 100644 --- a/docs/developers/tools/file-system.md +++ b/docs/developers/tools/file-system.md @@ -24,7 +24,7 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local ## 2. `read_file` (ReadFile) -`read_file` reads and returns the content of a specified file. This tool handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), and PDF files. For text files, it can read specific line ranges. Other binary file types are generally skipped. +`read_file` reads and returns the content of a specified file. This tool handles text files and media files (images, PDFs, audio, video) whose modality is supported by the current model. For text files, it can read specific line ranges. Media files whose modality is not supported by the current model are rejected with a helpful error message. Other binary file types are generally skipped. - **Tool name:** `read_file` - **Display name:** ReadFile @@ -35,11 +35,12 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local - `limit` (number, optional): For text files, the maximum number of lines to read. If omitted, reads a default maximum (e.g., 2000 lines) or the entire file if feasible. - **Behavior:** - For text files: Returns the content. If `offset` and `limit` are used, returns only that slice of lines. Indicates if content was truncated due to line limits or line length limits. - - For image and PDF files: Returns the file content as a base64-encoded data structure suitable for model consumption. + - For media files (images, PDFs, audio, video): If the current model supports the file's modality, returns the file content as a base64-encoded `inlineData` object. If the model does not support the modality, returns an error message with guidance (e.g., suggesting skills or external tools). - For other binary files: Attempts to identify and skip them, returning a message indicating it's a generic binary file. - **Output:** (`llmContent`): - For text files: The file content, potentially prefixed with a truncation message (e.g., `[File content truncated: showing lines 1-100 of 500 total lines...]\nActual file content...`). - - For image/PDF files: An object containing `inlineData` with `mimeType` and base64 `data` (e.g., `{ inlineData: { mimeType: 'image/png', data: 'base64encodedstring' } }`). + - For supported media files: An object containing `inlineData` with `mimeType` and base64 `data` (e.g., `{ inlineData: { mimeType: 'image/png', data: 'base64encodedstring' } }`). + - For unsupported media files: An error message string explaining that the current model does not support this modality, with suggestions for alternatives. - For other binary files: A message like `Cannot display content of binary file: /path/to/data.bin`. - **Confirmation:** No. @@ -164,4 +165,63 @@ grep_search(pattern="function", glob="*.js", limit=10) - On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit because the text matches multiple locations...`). - **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file. +## File encoding and platform-specific behavior + +### Encoding detection and preservation + +When reading files, Qwen Code detects the file's encoding using a multi-step strategy: + +1. **UTF-8** — tried first (most modern tooling outputs UTF-8) +2. **chardet** — statistical detection for non-UTF-8 content +3. **System encoding** — falls back to the OS code page (Windows `chcp` / Unix `LANG`) + +Both `write_file` and `edit` preserve the original encoding and BOM (byte order mark) of existing files. If a file was read as GBK with a UTF-8 BOM, it will be written back the same way. + +### Configuring default encoding for new files + +The `defaultFileEncoding` setting controls encoding for **newly created** files (not edits to existing files): + +| Value | Behavior | +| ----------- | --------------------------------------------------------------------------- | +| _(not set)_ | UTF-8 without BOM, with automatic platform-specific adjustments (see below) | +| `utf-8` | UTF-8 without BOM, no automatic adjustments | +| `utf-8-bom` | UTF-8 with BOM for all new files | + +Set it in `.qwen/settings.json` or `~/.qwen/settings.json`: + +```json +{ + "general": { + "defaultFileEncoding": "utf-8-bom" + } +} +``` + +### Windows: CRLF for batch files + +On Windows, `.bat` and `.cmd` files are automatically written with CRLF (`\r\n`) line endings. This is required because `cmd.exe` uses CRLF as its line delimiter — LF-only endings can break multi-line `if`/`else`, `goto` labels, and `for` loops. This applies regardless of encoding settings and only on Windows. + +### Windows: UTF-8 BOM for PowerShell scripts + +On Windows with a **non-UTF-8 system code page** (e.g. GBK/cp936, Big5/cp950, Shift_JIS/cp932), newly created `.ps1` files are automatically written with a UTF-8 BOM. This is necessary because Windows PowerShell 5.1 (the version built into Windows 10/11) reads BOM-less scripts using the system's ANSI code page. Without a BOM, any non-ASCII characters in the script will be misinterpreted. + +This automatic BOM only applies when: + +- The platform is Windows +- The system code page is not UTF-8 (not code page 65001) +- The file is a new `.ps1` file (existing files keep their original encoding) +- The user has **not** explicitly set `defaultFileEncoding` in settings + +PowerShell 7+ (pwsh) defaults to UTF-8 and handles BOM transparently, so the BOM is harmless there. + +If you explicitly set `defaultFileEncoding` to `"utf-8"`, the automatic BOM is disabled — this is an intentional escape hatch for repositories or tooling that reject BOMs. + +### Summary + +| File type | Platform | Automatic behavior | +| -------------- | ----------------------------- | --------------------------- | +| `.bat`, `.cmd` | Windows | CRLF line endings | +| `.ps1` | Windows (non-UTF-8 code page) | UTF-8 BOM on new files | +| All others | All | UTF-8 without BOM (default) | + These file system tools provide a foundation for Qwen Code to understand and interact with your local project context. diff --git a/docs/developers/tools/shell.md b/docs/developers/tools/shell.md index 8113a9892..5325748b5 100644 --- a/docs/developers/tools/shell.md +++ b/docs/developers/tools/shell.md @@ -110,7 +110,11 @@ You can configure the behavior of the `run_shell_command` tool by modifying your ### Enabling Interactive Commands -To enable interactive commands, you need to set the `tools.shell.enableInteractiveShell` setting to `true`. This will use `node-pty` for shell command execution, which allows for interactive sessions. If `node-pty` is not available, it will fall back to the `child_process` implementation, which does not support interactive commands. +The `tools.shell.enableInteractiveShell` setting controls whether shell commands are executed via `node-pty` (interactive PTY) or the plain `child_process` backend. When enabled, interactive sessions such as `vim`, `git rebase -i`, and TUI programs work correctly. + +This setting defaults to `true` on most platforms. On Windows builds **<= 19041** (before Windows 10 version 2004), it defaults to `false` because older ConPTY implementations have known reliability issues (missing output, hangs). This matches the same cutoff used by VS Code ([microsoft/vscode#123725](https://github.com/microsoft/vscode/issues/123725)). If `node-pty` is not available at runtime, the tool falls back to `child_process` regardless of this setting. + +To explicitly override the default, set the value in `settings.json`: **Example `settings.json`:** diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index c648a231f..308d09e63 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -129,7 +129,6 @@ Settings are organized into categories. All settings should be placed within the | -------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | `model.name` | string | The Qwen model to use for conversations. | `undefined` | | `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | -| `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` | | `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | | `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` | | `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` | @@ -221,7 +220,6 @@ If you are experiencing performance issues with file searching (e.g., with `@` c | `tools.callCommand` | string | Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). | `undefined` | | | `tools.useRipgrep` | boolean | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | | | `tools.useBuiltinRipgrep` | boolean | Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. | `true` | | -| `tools.enableToolOutputTruncation` | boolean | Enable truncation of large tool outputs. | `true` | Requires restart: Yes | | `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes | | `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes | @@ -350,11 +348,6 @@ Here is an example of a `settings.json` file with the nested structure, new as o "maxSessionTurns": 10, "enableOpenAILogging": false, "openAILoggingDir": "~/qwen-logs", - "summarizeToolOutput": { - "run_shell_command": { - "tokenBudget": 100 - } - } }, "context": { "fileName": ["CONTEXT.md", "QWEN.md"], @@ -419,6 +412,8 @@ Arguments passed directly when running the CLI can override other configurations | `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` | | `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. | | `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` | +| `--system-prompt` | | Overrides the built-in main session system prompt for this run. | Your prompt text | Loaded context files such as `QWEN.md` are still appended after this override. Can be combined with `--append-system-prompt`. | +| `--append-system-prompt` | | Appends extra instructions to the main session system prompt for this run. | Your prompt text | Applied after the built-in prompt and loaded context files. Can be combined with `--system-prompt`. See [Headless Mode](../features/headless) for examples. | | `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. | | `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. | | `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. | 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/headless.md b/docs/users/features/headless.md index 203e08a2d..12172f121 100644 --- a/docs/users/features/headless.md +++ b/docs/users/features/headless.md @@ -58,6 +58,40 @@ qwen --resume 123e4567-e89b-12d3-a456-426614174000 -p "Apply the follow-up refac > - Session data is project-scoped JSONL under `~/.qwen/projects//chats`. > - Restores conversation history, tool outputs, and chat-compression checkpoints before sending the new prompt. +## Customize the Main Session Prompt + +You can change the main session system prompt for a single CLI run without editing shared memory files. + +### Override the Built-in System Prompt + +Use `--system-prompt` to replace Qwen Code's built-in main-session prompt for the current run: + +```bash +qwen -p "Review this patch" --system-prompt "You are a terse release reviewer. Report only blocking issues." +``` + +### Append Extra Instructions + +Use `--append-system-prompt` to keep the built-in prompt and add extra instructions for this run: + +```bash +qwen -p "Review this patch" --append-system-prompt "Be terse and focus on concrete findings." +``` + +You can combine both flags when you want a custom base prompt plus an extra run-specific instruction: + +```bash +qwen -p "Summarize this repository" \ + --system-prompt "You are a migration planner." \ + --append-system-prompt "Return exactly three bullets." +``` + +> [!note] +> +> - `--system-prompt` applies only to the current run's main session. +> - Loaded memory and context files such as `QWEN.md` are still appended after `--system-prompt`. +> - `--append-system-prompt` is applied after the built-in prompt and loaded memory, and can be used together with `--system-prompt`. + ## Output Formats Qwen Code supports multiple output formats for different use cases: @@ -189,19 +223,21 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq Key command-line options for headless usage: -| Option | Description | Example | -| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ | -| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | -| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` | -| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` | -| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | -| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | -| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | -| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | -| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | -| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | -| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` | -| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` | +| Option | Description | Example | +| ---------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | +| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` | +| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` | +| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | +| `--system-prompt` | Override the main session system prompt for this run | `qwen -p "query" --system-prompt "You are a terse reviewer."` | +| `--append-system-prompt` | Append extra instructions to the main session system prompt for this run | `qwen -p "query" --append-system-prompt "Focus on concrete findings."` | +| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | +| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | +| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | +| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | +| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | +| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` | +| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` | For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings). diff --git a/docs/users/features/sandbox.md b/docs/users/features/sandbox.md index 72005f959..ba5e477e0 100644 --- a/docs/users/features/sandbox.md +++ b/docs/users/features/sandbox.md @@ -181,6 +181,29 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping - Container sandbox: add them via `.qwen/sandbox.Dockerfile` or `.qwen/sandbox.bashrc`. - Seatbelt: your host binaries are used, but the sandbox may restrict access to some paths. +**Java not available in Docker sandbox** + +The official Qwen Code Docker image is intentionally minimal to keep the image small, secure, and fast to pull. Different users require different language runtimes (Java, Python, Node.js, etc.), and bundling all environments into a single image is not practical. Therefore, Java is **not included by default** in the Docker sandbox. + +If your workflow requires Java, you can extend the base image by creating a `.qwen/sandbox.Dockerfile` in your project: + +```dockerfile +FROM ghcr.io/qwenlm/qwen-code:latest + +RUN apt-get update && \ + apt-get install -y openjdk-17-jre && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* +``` + +Then rebuild the sandbox image: + +```bash +QWEN_SANDBOX=docker BUILD_SANDBOX=1 qwen -s +``` + +For more details on customizing the sandbox, see [Customizing the sandbox environment](/developers/tools/sandbox). + **Network issues** - Check sandbox profile allows network. diff --git a/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md index 85ca4aff9..256034e3c 100644 --- a/docs/users/features/sub-agents.md +++ b/docs/users/features/sub-agents.md @@ -502,3 +502,10 @@ Always follow these standards: - **Access Control**: Project and user-level separation provides appropriate boundaries - **Sensitive Information**: Avoid including secrets or credentials in agent configurations - **Production Environments**: Consider separate agents for production vs development environments + +## Limits + +The following soft warnings apply to Subagent configurations (no hard limits are enforced): + +- **Description Field**: A warning is shown for descriptions exceeding 1,000 characters +- **System Prompt**: A warning is shown for system prompts exceeding 10,000 characters diff --git a/docs/users/integration-jetbrains.md b/docs/users/integration-jetbrains.md index 3f4739eab..baced8149 100644 --- a/docs/users/integration-jetbrains.md +++ b/docs/users/integration-jetbrains.md @@ -16,6 +16,30 @@ ### Installation +#### Install from ACP Registry (Recommend) + +1. Install Qwen Code CLI: + + ```bash + npm install -g @qwen-code/qwen-code + ``` + +2. Open your JetBrains IDE and navigate to AI Chat tool window. + +3. Click **Add ACP Agent**, then click **Install**. + + ![Install](https://img.alicdn.com/imgextra/i4/O1CN01qNdPCW1y8AcqxRgCy_!!6000000006533-2-tps-2490-1788.png) + + For users using JetBrains AI Assistant and/or other ACP agents, click **Install From ACP Registry** in Agents List, then install Qwen Code ACP. + + ![Add from Agents List](https://img.alicdn.com/imgextra/i2/O1CN01ZyOugP26BOKzNgZXx_!!6000000007623-2-tps-479-523.png) + +4. The Qwen Code agent should now be available in the AI Assistant panel. + + ![Qwen Code in JetBrains AI Chat](https://img.alicdn.com/imgextra/i4/O1CN013kAVE41XVzbIZOxyv_!!6000000002930-2-tps-3188-2170.png) + +#### Manual Install (for older version of JetBrains IDEs) + 1. Install Qwen Code CLI: ```bash diff --git a/docs/users/integration-zed.md b/docs/users/integration-zed.md index 7379bf69b..003d31709 100644 --- a/docs/users/integration-zed.md +++ b/docs/users/integration-zed.md @@ -18,6 +18,24 @@ ### Installation +#### Install from ACP Registry (Recommend) + +1. Install Qwen Code CLI: + +```bash +npm install -g @qwen-code/qwen-code +``` + +2. Download and install [Zed Editor](https://zed.dev/) + +3. In Zed, click the **settings button** in the top right corner, select **"Add agent"**, choose **"Install from Registry"**, find **Qwen Code**, then click **Install**. + + ![ACP Registry](https://img.alicdn.com/imgextra/i4/O1CN0186ybL61EeG35fHFjy_!!6000000000376-2-tps-3056-1705.png) + + ![Qwen Code ACP Installed](https://img.alicdn.com/imgextra/i1/O1CN01OXHhoR1J8irAvjs8F_!!6000000000984-2-tps-1247-703.png) + +#### Manual Install + 1. Install Qwen Code CLI: ```bash diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 0bafaeeb0..0f7770e6c 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -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/file-system.test.ts b/integration-tests/file-system.test.ts index 95109a9de..f4c60edd7 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/file-system.test.ts @@ -139,7 +139,7 @@ describe('file-system', () => { ).toBeDefined(); const newFileContent = rig.readFile(fileName); - expect(newFileContent).toBe('1.0.1'); + expect(newFileContent).toContain('1.0.1'); }); it.skip('should replace multiple instances of a string', async () => { diff --git a/integration-tests/fixtures/settings-migration/workspaces.json b/integration-tests/fixtures/settings-migration/workspaces.json index af7a48f84..bd9798009 100644 --- a/integration-tests/fixtures/settings-migration/workspaces.json +++ b/integration-tests/fixtures/settings-migration/workspaces.json @@ -43,7 +43,6 @@ "maxSessionTurns": 50, "preferredEditor": "vscode", "sandbox": false, - "summarizeToolOutput": true, "telemetry": { "enabled": false }, diff --git a/integration-tests/hook-integration/hooks.test.ts b/integration-tests/hook-integration/hooks.test.ts index 17cc1e3db..e5c860d4b 100644 --- a/integration-tests/hook-integration/hooks.test.ts +++ b/integration-tests/hook-integration/hooks.test.ts @@ -35,18 +35,18 @@ describe('Hooks System Integration', () => { describe('Allow Decision', () => { it('should allow prompt when hook returns allow decision', async () => { const hookScript = - "console.log(JSON.stringify({decision: 'allow', reason: 'approved by hook'}));"; + 'echo \'{"decision": "allow", "reason": "approved by hook"}\''; await rig.setup('ups-allow-decision', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${hookScript}"`, + command: hookScript, name: 'ups-allow-hook', timeout: 5000, }, @@ -65,18 +65,18 @@ describe('Hooks System Integration', () => { it('should allow tool execution with allow decision and verify tool was called', async () => { const hookScript = - "console.log(JSON.stringify({decision: 'allow', reason: 'Tool execution approved'}));"; + 'echo \'{"decision": "allow", "reason": "Tool execution approved"}\''; await rig.setup('ups-allow-tool', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${hookScript}"`, + command: hookScript, name: 'ups-allow-tool-hook', timeout: 5000, }, @@ -100,18 +100,19 @@ describe('Hooks System Integration', () => { describe('Block Decision', () => { it('should block prompt when hook returns block decision', async () => { - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Prompt blocked by security policy'}));`; + const blockScript = + 'echo \'{"decision": "block", "reason": "Prompt blocked by security policy"}\''; await rig.setup('ups-block-decision', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'ups-block-hook', timeout: 5000, }, @@ -129,18 +130,19 @@ describe('Hooks System Integration', () => { }); it('should block tool execution when hook returns block and verify no tool was called', async () => { - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'File writing blocked by security policy'}));`; + const blockScript = + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "File writing blocked by security policy"}\''; await rig.setup('ups-block-tool', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'ups-block-tool-hook', timeout: 5000, }, @@ -158,6 +160,12 @@ describe('Hooks System Integration', () => { ).rejects.toThrow(/block/i); // Tool should not be called due to blocking hook + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(0); // At least one hook call occurred + const toolLogs = rig.readToolLogs(); const writeFileCalls = toolLogs.filter( (t) => @@ -170,18 +178,19 @@ describe('Hooks System Integration', () => { describe('Modify Prompt', () => { it('should use modified prompt when hook provides modification', async () => { - const modifyScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'UserPromptSubmit', modifiedPrompt: 'Modified prompt content', additionalContext: 'Context added by hook'}}));`; + 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${modifyScript}"`, + command: modifyScript, name: 'ups-modify-hook', timeout: 5000, }, @@ -200,18 +209,19 @@ describe('Hooks System Integration', () => { describe('Additional Context', () => { it('should include additional context in response when hook provides it', async () => { - const contextScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'Extra context information from hook'}}));`; + const contextScript = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Extra context information from hook"}}\''; await rig.setup('ups-add-context', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${contextScript}"`, + command: contextScript, name: 'ups-context-hook', timeout: 5000, }, @@ -232,8 +242,8 @@ describe('Hooks System Integration', () => { it('should continue execution when hook times out', async () => { await rig.setup('ups-timeout', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ @@ -261,8 +271,8 @@ describe('Hooks System Integration', () => { 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ @@ -288,15 +298,14 @@ describe('Hooks System Integration', () => { 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: - 'node -e "console.error(\'Critical security error\'); process.exit(2)"', + command: 'echo "Critical security error" >&2 && exit 2', name: 'ups-blocking-error-hook', timeout: 5000, }, @@ -312,17 +321,17 @@ describe('Hooks System Integration', () => { await expect(rig.run('Create a file')).rejects.toThrow(/block/i); }); - it('should block execution when hook command does not exist', async () => { + it('should continue execution when hook command is empty', async () => { await rig.setup('ups-missing-command', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: '/nonexistent/command/path', + command: '', name: 'ups-missing-hook', timeout: 5000, }, @@ -334,35 +343,28 @@ describe('Hooks System Integration', () => { }, }); - // Missing command is treated as a blocking error for UserPromptSubmit hooks - await expect(rig.run('Say missing test')).rejects.toThrow(/block/i); + // 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 = ` -const input = JSON.parse(process.argv[2] || '{}'); -const hasRequired = input.session_id && input.cwd && input.hook_event_name && input.prompt !== undefined; -console.log(JSON.stringify({ - decision: 'allow', - hookSpecificOutput: { - hookEventName: 'UserPromptSubmit', - additionalContext: hasRequired ? 'Valid input format' : 'Invalid input format' - } -})); -`; + const inputValidationScript = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "Valid input format"}}\''; await rig.setup('ups-correct-input', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${inputValidationScript.replace(/\n/g, ' ')}"`, + command: inputValidationScript, name: 'ups-input-hook', timeout: 5000, }, @@ -381,18 +383,19 @@ console.log(JSON.stringify({ describe('System Message', () => { it('should include system message in response when hook provides it', async () => { - const systemMsgScript = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'This is a system message from hook'}));`; + const systemMsgScript = + 'echo \'{"decision": "allow", "systemMessage": "This is a system message from hook"}\''; await rig.setup('ups-system-message', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${systemMsgScript}"`, + command: systemMsgScript, name: 'ups-system-msg-hook', timeout: 5000, }, @@ -411,25 +414,27 @@ console.log(JSON.stringify({ describe('Multiple UserPromptSubmit Hooks', () => { it('should block when one of multiple parallel hooks returns block', async () => { - const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Allowed'}));`; - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked by security policy'}));`; + 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${allowScript}"`, + command: allowScript, name: 'ups-allow-hook', timeout: 5000, }, { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'ups-block-hook', timeout: 5000, }, @@ -450,19 +455,20 @@ console.log(JSON.stringify({ // 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 = `console.log(JSON.stringify({decision: 'block', reason: 'First hook blocks'}));`; + const blockScript = + 'echo \'{"decision": "block", "reason": "First hook blocks"}\''; await rig.setup('ups-seq-first-blocks', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { sequential: true, hooks: [ { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'ups-seq-block-hook', timeout: 5000, }, @@ -482,26 +488,28 @@ console.log(JSON.stringify({ // 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 = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Second hook blocks'}));`; + 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: { - enabled: true, UserPromptSubmit: [ { sequential: true, hooks: [ { type: 'command', - command: `node -e "${allowScript}"`, + command: allowScript, name: 'ups-seq-first-allow', timeout: 5000, }, { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'ups-seq-second-block', timeout: 5000, }, @@ -518,32 +526,35 @@ console.log(JSON.stringify({ }); it('should handle multiple hooks all returning allow', async () => { - const allow1Script = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; - const allow2Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Second allows'}));`; - const allow3Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Third allows'}));`; + 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${allow1Script}"`, + command: allow1Script, name: 'ups-allow-1', timeout: 5000, }, { type: 'command', - command: `node -e "${allow2Script}"`, + command: allow2Script, name: 'ups-allow-2', timeout: 5000, }, { type: 'command', - command: `node -e "${allow3Script}"`, + command: allow3Script, name: 'ups-allow-3', timeout: 5000, }, @@ -562,25 +573,27 @@ console.log(JSON.stringify({ }); it('should handle multiple hooks all returning block', async () => { - const block1Script = `console.log(JSON.stringify({decision: 'block', reason: 'First blocks'}));`; - const block2Script = `console.log(JSON.stringify({decision: 'block', reason: 'Second blocks'}));`; + 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${block1Script}"`, + command: block1Script, name: 'ups-block-1', timeout: 5000, }, { type: 'command', - command: `node -e "${block2Script}"`, + command: block2Script, name: 'ups-block-2', timeout: 5000, }, @@ -597,25 +610,27 @@ console.log(JSON.stringify({ }); it('should concatenate additional context from multiple hooks', async () => { - const context1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context from hook 1'}}));`; - const context2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context from hook 2'}}));`; + 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${context1Script}"`, + command: context1Script, name: 'ups-context-1', timeout: 5000, }, { type: 'command', - command: `node -e "${context2Script}"`, + command: context2Script, name: 'ups-context-2', timeout: 5000, }, @@ -632,12 +647,13 @@ console.log(JSON.stringify({ }); it('should handle hook with error alongside blocking hook', async () => { - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked'}));`; + const blockScript = + 'echo \'{"decision": "block", "reason": "Blocked"}\''; await rig.setup('ups-error-with-block', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ @@ -649,7 +665,7 @@ console.log(JSON.stringify({ }, { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'ups-block-hook', timeout: 5000, }, @@ -666,12 +682,13 @@ console.log(JSON.stringify({ }); it('should handle hook timeout alongside blocking hook', async () => { - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked while other times out'}));`; + const blockScript = + 'echo \'{"decision": "block", "reason": "Blocked while other times out"}\''; await rig.setup('ups-timeout-with-block', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ @@ -683,7 +700,7 @@ console.log(JSON.stringify({ }, { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'ups-block-hook', timeout: 5000, }, @@ -700,19 +717,21 @@ console.log(JSON.stringify({ }); it('should handle multiple hook groups with different configurations', async () => { - const allow1Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Group 1 allows'}));`; - const allow2Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Group 2 allows'}));`; + 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${allow1Script}"`, + command: allow1Script, name: 'ups-group1-hook', timeout: 5000, }, @@ -723,7 +742,7 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: `node -e "${allow2Script}"`, + command: allow2Script, name: 'ups-group2-hook', timeout: 5000, }, @@ -740,19 +759,21 @@ console.log(JSON.stringify({ }); it('should block when one group blocks in multiple hook groups', async () => { - const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Group 1 allows'}));`; - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Group 2 blocks'}));`; + 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${allowScript}"`, + command: allowScript, name: 'ups-group1-allow', timeout: 5000, }, @@ -762,7 +783,7 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'ups-group2-block', timeout: 5000, }, @@ -779,26 +800,28 @@ console.log(JSON.stringify({ }); it('should handle modified prompt from multiple hooks', async () => { - const modify1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {modifiedPrompt: 'Modified by hook 1'}}));`; - const modify2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {modifiedPrompt: 'Modified by hook 2'}}));`; + 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: { - enabled: true, UserPromptSubmit: [ { sequential: true, hooks: [ { type: 'command', - command: `node -e "${modify1Script}"`, + command: modify1Script, name: 'ups-modify-1', timeout: 5000, }, { type: 'command', - command: `node -e "${modify2Script}"`, + command: modify2Script, name: 'ups-modify-2', timeout: 5000, }, @@ -815,25 +838,27 @@ console.log(JSON.stringify({ }); it('should handle system messages from multiple hooks', async () => { - const msg1Script = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'System message 1'}));`; - const msg2Script = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'System message 2'}));`; + 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${msg1Script}"`, + command: msg1Script, name: 'ups-msg-1', timeout: 5000, }, { type: 'command', - command: `node -e "${msg2Script}"`, + command: msg2Script, name: 'ups-msg-2', timeout: 5000, }, @@ -858,18 +883,19 @@ console.log(JSON.stringify({ describe('Stop Hooks', () => { describe('Allow Decision', () => { it('should allow stopping when hook returns allow decision', async () => { - const allowStopScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Stop allowed'}));`; + const allowStopScript = + 'echo \'{"decision": "allow", "reason": "Stop allowed"}\''; await rig.setup('stop-allow', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${allowStopScript}"`, + command: allowStopScript, name: 'stop-allow-hook', timeout: 5000, }, @@ -886,18 +912,19 @@ console.log(JSON.stringify({ }); it('should allow stopping and verify final response is produced', async () => { - const allowFinalScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'Final context from stop hook'}}));`; + const allowFinalScript = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Final context from stop hook"}}\''; await rig.setup('stop-allow-final', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${allowFinalScript}"`, + command: allowFinalScript, name: 'stop-final-hook', timeout: 5000, }, @@ -919,18 +946,20 @@ console.log(JSON.stringify({ 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 = `console.log(JSON.stringify({decision: 'block', reason: 'Stop blocked by security policy'}));`; + // Use background process to write count file, ensuring final output is pure JSON + const blockStopScript = + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\''; await rig.setup('stop-block-decision', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${blockStopScript}"`, + command: blockStopScript, name: 'stop-block-hook', timeout: 5000, }, @@ -942,27 +971,37 @@ console.log(JSON.stringify({ }, }); - const result = await rig.run('Say hello'); - // When Stop hook blocks, agent continues execution instead of stopping - // So we should get a valid response (not an error containing "block") - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + // 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', '3'); + + // Verify that execution completed successfully (not blocked by Stop hook) + // Verify Stop hook was invoked multiple times (indicating multiple rounds) + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); + + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); it('should continue execution with custom reason', async () => { // Stop hook's block decision means "block stopping" (i.e., force continuation) - const blockReasonScript = `console.log(JSON.stringify({decision: 'block', reason: 'Custom block reason: task incomplete'}));`; + const blockReasonScript = + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Custom block reason: task incomplete"}\''; await rig.setup('stop-block-custom-reason', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${blockReasonScript}"`, + command: blockReasonScript, name: 'stop-block-reason-hook', timeout: 5000, }, @@ -974,58 +1013,38 @@ console.log(JSON.stringify({ }, }); - const result = await rig.run('Say goodbye'); - // When Stop hook blocks, agent continues execution instead of stopping - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - }); - }); + // 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', '3'); - describe('Continue False', () => { - it('should request continue execution when hook returns continue: false', async () => { - const continueScript = `console.log(JSON.stringify({continue: false, stopReason: 'More work needed'}));`; + // Verify that execution completed successfully (not blocked by Stop hook) + // This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); - await rig.setup('stop-continue-false', { - settings: { - hooks: { - enabled: true, - Stop: [ - { - hooks: [ - { - type: 'command', - command: `node -e "${continueScript}"`, - name: 'stop-continue-hook', - timeout: 5000, - }, - ], - }, - ], - }, - trusted: true, - }, - }); - - const result = await rig.run('Say continue'); - // When continue: false, the agent may try to continue - expect(result).toBeDefined(); + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); }); describe('Additional Context', () => { it('should include additional context in final response', async () => { - const contextScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'Final context from hook'}}));`; + const contextScript = + 'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Final context from hook"}}\''; await rig.setup('stop-add-context', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${contextScript}"`, + command: contextScript, name: 'stop-context-hook', timeout: 5000, }, @@ -1042,25 +1061,27 @@ console.log(JSON.stringify({ }); it('should concatenate multiple additionalContext from multiple hooks', async () => { - const context1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context1'}}));`; - const context2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context2'}}));`; + 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: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${context1Script}"`, + command: context1Script, name: 'stop-context-1', timeout: 5000, }, { type: 'command', - command: `node -e "${context2Script}"`, + command: context2Script, name: 'stop-context-2', timeout: 5000, }, @@ -1079,18 +1100,19 @@ console.log(JSON.stringify({ describe('Stop Reason', () => { it('should include stop reason when hook provides it', async () => { - const reasonScript = `console.log(JSON.stringify({decision: 'allow', stopReason: 'Custom stop reason from hook'}));`; + const reasonScript = + 'echo \'{"decision": "allow", "stopReason": "Custom stop reason from hook"}\''; await rig.setup('stop-set-reason', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${reasonScript}"`, + command: reasonScript, name: 'stop-reason-hook', timeout: 5000, }, @@ -1111,8 +1133,8 @@ console.log(JSON.stringify({ it('should continue stopping when hook times out', async () => { await rig.setup('stop-timeout', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ @@ -1140,8 +1162,8 @@ console.log(JSON.stringify({ it('should continue stopping when hook has non-blocking error', async () => { await rig.setup('stop-error', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ @@ -1167,16 +1189,16 @@ console.log(JSON.stringify({ it('should continue stopping when hook command does not exist', async () => { await rig.setup('stop-missing-command', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: '/nonexistent/stop/command', + command: 'false', name: 'stop-missing-hook', - timeout: 5000, + timeout: 1000, }, ], }, @@ -1194,18 +1216,19 @@ console.log(JSON.stringify({ describe('System Message', () => { it('should include system message in final response', async () => { - const systemMsgScript = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'Final system message from stop hook'}));`; + const systemMsgScript = + 'echo \'{"decision": "allow", "systemMessage": "Final system message from stop hook"}\''; await rig.setup('stop-system-message', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${systemMsgScript}"`, + command: systemMsgScript, name: 'stop-system-msg-hook', timeout: 5000, }, @@ -1225,25 +1248,28 @@ console.log(JSON.stringify({ 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 = `console.log(JSON.stringify({decision: 'allow', reason: 'Stop allowed'}));`; - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Stop blocked by security policy'}));`; + const allowScript = + 'echo \'{"decision": "allow", "reason": "Stop allowed"}\''; + // Write to a file to count hook invocations, then echo the decision + const blockScript = + 'echo "hook_called" >> hook_invoke_count.txt; echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\''; await rig.setup('stop-multi-one-blocks', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${allowScript}"`, + command: allowScript, name: 'stop-allow-hook', timeout: 5000, }, { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'stop-block-hook', timeout: 5000, }, @@ -1255,34 +1281,50 @@ console.log(JSON.stringify({ }, }); - const result = await rig.run('Say multi stop'); - // When Stop hook blocks, agent continues execution instead of stopping - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + // 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', + '3', + ); + + // Verify that execution completed successfully (not blocked by Stop hook) + // This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); + + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); 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 = `console.log(JSON.stringify({decision: 'block', reason: 'First hook blocks stop'}));`; - const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'This should not run'}));`; + const blockScript = + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "First hook blocks stop"}\''; + const allowScript = + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow", "reason": "This should still run"}\''; await rig.setup('stop-seq-first-blocks', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { sequential: true, hooks: [ { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'stop-seq-block-hook', timeout: 5000, }, { type: 'command', - command: `node -e "${allowScript}"`, + command: allowScript, name: 'stop-seq-allow-hook', timeout: 5000, }, @@ -1294,34 +1336,50 @@ console.log(JSON.stringify({ }, }); - const result = await rig.run('Say sequential stop'); - // When Stop hook blocks, agent continues execution instead of stopping - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + // 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', + '3', + ); + + // Verify that execution completed successfully (not blocked by Stop hook) + // This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); + + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); 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 = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Second hook blocks stop'}));`; + const allowScript = + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow", "reason": "First allows"}\''; + const blockScript = + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Second hook blocks stop"}\''; await rig.setup('stop-seq-second-blocks', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { sequential: true, hooks: [ { type: 'command', - command: `node -e "${allowScript}"`, + command: allowScript, name: 'stop-seq-first-allow', timeout: 5000, }, { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'stop-seq-second-block', timeout: 5000, }, @@ -1333,39 +1391,56 @@ console.log(JSON.stringify({ }, }); - const result = await rig.run('Say seq second blocks'); - // When Stop hook blocks, agent continues execution instead of stopping - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + // 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', + '3', + ); + + // Verify that execution completed successfully (not blocked by Stop hook) + // This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); + + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); it('should handle multiple stop hooks all returning allow', async () => { - const allow1Script = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; - const allow2Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Second allows'}));`; - const allow3Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Third allows'}));`; + 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: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${allow1Script}"`, + command: allow1Script, name: 'stop-allow-1', timeout: 5000, }, { type: 'command', - command: `node -e "${allow2Script}"`, + command: allow2Script, name: 'stop-allow-2', timeout: 5000, }, { type: 'command', - command: `node -e "${allow3Script}"`, + command: allow3Script, name: 'stop-allow-3', timeout: 5000, }, @@ -1384,25 +1459,27 @@ console.log(JSON.stringify({ }); it('should handle multiple stop hooks all returning block', async () => { - const block1Script = `console.log(JSON.stringify({decision: 'block', reason: 'First blocks'}));`; - const block2Script = `console.log(JSON.stringify({decision: 'block', reason: 'Second blocks'}));`; + const block1Script = + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "First blocks"}\''; + const block2Script = + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Second blocks"}\''; await rig.setup('stop-multi-all-block', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ { type: 'command', - command: `node -e "${block1Script}"`, + command: block1Script, name: 'stop-block-1', timeout: 5000, }, { type: 'command', - command: `node -e "${block2Script}"`, + command: block2Script, name: 'stop-block-2', timeout: 5000, }, @@ -1414,127 +1491,28 @@ console.log(JSON.stringify({ }, }); - const result = await rig.run('Say all block'); - // When Stop hooks block, agent continues execution instead of stopping - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - }); + // 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', + '3', + ); - it('should handle multiple continue: false from different stop hooks', async () => { - const continue1Script = `console.log(JSON.stringify({continue: false, stopReason: 'First needs more work'}));`; - const continue2Script = `console.log(JSON.stringify({continue: false, stopReason: 'Second needs more work'}));`; - - await rig.setup('stop-multi-continue-false', { - settings: { - hooks: { - enabled: true, - Stop: [ - { - hooks: [ - { - type: 'command', - command: `node -e "${continue1Script}"`, - name: 'stop-continue-1', - timeout: 5000, - }, - { - type: 'command', - command: `node -e "${continue2Script}"`, - name: 'stop-continue-2', - timeout: 5000, - }, - ], - }, - ], - }, - trusted: true, - }, - }); - - const result = await rig.run('Say multi continue'); - // Multiple continue: false should be handled - expect(result).toBeDefined(); - }); - - it('should handle mixed allow and continue: false in stop hooks', async () => { - const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Allow stop'}));`; - const continueScript = `console.log(JSON.stringify({continue: false, stopReason: 'Need more work'}));`; - - await rig.setup('stop-mixed-allow-continue', { - settings: { - hooks: { - enabled: true, - Stop: [ - { - hooks: [ - { - type: 'command', - command: `node -e "${allowScript}"`, - name: 'stop-allow-hook', - timeout: 5000, - }, - { - type: 'command', - command: `node -e "${continueScript}"`, - name: 'stop-continue-hook', - timeout: 5000, - }, - ], - }, - ], - }, - trusted: true, - }, - }); - - const result = await rig.run('Say mixed'); - expect(result).toBeDefined(); - }); - - it('should handle block with higher priority than continue: false', async () => { - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Security block'}));`; - const continueScript = `console.log(JSON.stringify({continue: false, stopReason: 'Need more work'}));`; - - await rig.setup('stop-block-vs-continue', { - settings: { - hooks: { - enabled: true, - Stop: [ - { - hooks: [ - { - type: 'command', - command: `node -e "${blockScript}"`, - name: 'stop-block-priority', - timeout: 5000, - }, - { - type: 'command', - command: `node -e "${continueScript}"`, - name: 'stop-continue-lower', - timeout: 5000, - }, - ], - }, - ], - }, - trusted: true, - }, - }); - - const result = await rig.run('Say block priority'); - // Block should take priority - expect(result).toBeDefined(); - expect(result.toLowerCase()).toContain('block'); + // Verify Stop hook was invoked multiple times (indicating multiple rounds) + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); }); it('should handle stop hook with error alongside blocking hook', async () => { - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked'}));`; + const blockScript = 'echo {"decision": "block", "reason": "Blocked"}'; await rig.setup('stop-error-with-block', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, Stop: [ { hooks: [ @@ -1546,7 +1524,7 @@ console.log(JSON.stringify({ }, { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'stop-block-hook', timeout: 5000, }, @@ -1558,46 +1536,14 @@ console.log(JSON.stringify({ }, }); - const result = await rig.run('Say error with block'); - // Block should still work despite error in other hook + // 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.toLowerCase()).toContain('block'); - }); - - it('should handle stop hook timeout alongside blocking hook', async () => { - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked while other times out'}));`; - - await rig.setup('stop-timeout-with-block', { - settings: { - hooks: { - enabled: true, - Stop: [ - { - hooks: [ - { - type: 'command', - command: 'sleep 60', - name: 'stop-timeout-hook', - timeout: 1000, - }, - { - type: 'command', - command: `node -e "${blockScript}"`, - name: 'stop-block-hook', - timeout: 5000, - }, - ], - }, - ], - }, - trusted: true, - }, - }); - - const result = await rig.run('Say timeout with block'); - // Block should work despite timeout in other hook - expect(result).toBeDefined(); - expect(result.toLowerCase()).toContain('block'); + expect(result.length).toBeGreaterThan(0); }); }); }); @@ -1609,26 +1555,28 @@ console.log(JSON.stringify({ describe('Multiple Hooks', () => { describe('Sequential Execution', () => { it('should execute hooks sequentially when sequential: true', async () => { - const hook1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'first'}}));`; - const hook2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'second'}}));`; + 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: { - enabled: true, UserPromptSubmit: [ { sequential: true, hooks: [ { type: 'command', - command: `node -e "${hook1Script}"`, + command: hook1Script, name: 'seq-hook-1', timeout: 5000, }, { type: 'command', - command: `node -e "${hook2Script}"`, + command: hook2Script, name: 'seq-hook-2', timeout: 5000, }, @@ -1645,8 +1593,9 @@ console.log(JSON.stringify({ }); it('should stop at first blocking hook and not execute subsequent', async () => { - const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked by first hook'}));`; - const allowScript = `console.log(JSON.stringify({decision: 'allow'}));`; + const blockScript = + 'echo {"decision": "block", "reason": "Blocked by first hook"}'; + const allowScript = 'echo {"decision": "allow"}'; await rig.setup('multi-first-blocks', { settings: { @@ -1658,13 +1607,13 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'seq-block-hook', timeout: 5000, }, { type: 'command', - command: `node -e "${allowScript}"`, + command: allowScript, name: 'seq-should-not-run', timeout: 5000, }, @@ -1684,26 +1633,28 @@ console.log(JSON.stringify({ }); it('should pass output from first hook to second hook input', async () => { - const passScript1 = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'from first', passthrough: 'data'}}));`; - const passScript2 = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'received passthrough'}}));`; + 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: { - enabled: true, UserPromptSubmit: [ { sequential: true, hooks: [ { type: 'command', - command: `node -e "${passScript1}"`, + command: passScript1, name: 'passthrough-hook-1', timeout: 5000, }, { type: 'command', - command: `node -e "${passScript2}"`, + command: passScript2, name: 'passthrough-hook-2', timeout: 5000, }, @@ -1722,25 +1673,25 @@ console.log(JSON.stringify({ describe('Parallel Execution', () => { it('should execute hooks in parallel when sequential is not set', async () => { - const hook1Script = `console.log(JSON.stringify({decision: 'allow'}));`; - const hook2Script = `console.log(JSON.stringify({decision: 'allow'}));`; + const hook1Script = 'echo {"decision": "allow"}'; + const hook2Script = 'echo {"decision": "allow"}'; await rig.setup('multi-parallel', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${hook1Script}"`, + command: hook1Script, name: 'parallel-hook-1', timeout: 5000, }, { type: 'command', - command: `node -e "${hook2Script}"`, + command: hook2Script, name: 'parallel-hook-2', timeout: 5000, }, @@ -1757,18 +1708,20 @@ console.log(JSON.stringify({ }); it('should handle mixed success/failure results from parallel hooks', async () => { - const allowScript = `console.log(JSON.stringify({decision: 'allow'}));`; + // 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: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${allowScript}"`, + command: allowScript, name: 'mixed-allow-hook', timeout: 5000, }, @@ -1786,31 +1739,32 @@ console.log(JSON.stringify({ }, }); - const result = await rig.run('Say mixed'); - // Mixed results: one succeeds, one fails - should continue - expect(result).toBeDefined(); + // 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 = `console.log(JSON.stringify({decision: 'block', reason: 'blocked'}));`; - const allowScript = `console.log(JSON.stringify({decision: 'allow'}));`; + const blockScript = 'echo {"decision": "block", "reason": "blocked"}'; + const allowScript = 'echo {"decision": "allow"}'; await rig.setup('multi-or-logic', { settings: { + hooksConfig: { enabled: true }, hooks: { - enabled: true, UserPromptSubmit: [ { hooks: [ { type: 'command', - command: `node -e "${blockScript}"`, + command: blockScript, name: 'block-hook', timeout: 5000, }, { type: 'command', - command: `node -e "${allowScript}"`, + command: allowScript, name: 'allow-hook', timeout: 5000, }, @@ -1835,8 +1789,8 @@ console.log(JSON.stringify({ // ========================================================================== describe('Combined Hooks', () => { it('should execute both Stop and UserPromptSubmit hooks in same session', async () => { - const stopScript = `console.log(JSON.stringify({decision: 'allow'}));`; - const upsScript = `console.log(JSON.stringify({decision: 'allow'}));`; + const stopScript = 'echo {"decision": "allow"}'; + const upsScript = 'echo {"decision": "allow"}'; await rig.setup('combined-both-hooks', { settings: { @@ -1847,7 +1801,7 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: `node -e "${stopScript}"`, + command: stopScript, name: 'stop-hook', timeout: 5000, }, @@ -1859,7 +1813,7 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: `node -e "${upsScript}"`, + command: upsScript, name: 'ups-hook', timeout: 5000, }, @@ -1882,6 +1836,9 @@ console.log(JSON.stringify({ // ========================================================================== 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 }, @@ -1891,8 +1848,7 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: - "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Approved by script file', hookSpecificOutput: {additionalContext: 'Script file executed successfully'}}))\"", + command: scriptFileHook, name: 'script-file-hook', timeout: 5000, }, @@ -1909,6 +1865,9 @@ console.log(JSON.stringify({ }); 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 }, @@ -1918,8 +1877,7 @@ console.log(JSON.stringify({ hooks: [ { type: 'command', - command: - "node -e \"console.log(JSON.stringify({decision: 'block', reason: 'Blocked by security script'}))\"", + command: scriptBlockHook, name: 'script-block-hook', timeout: 5000, }, diff --git a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts index d4566fcf3..f9bd77963 100644 --- a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts +++ b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts @@ -13,7 +13,6 @@ import { isSDKAssistantMessage, isSDKResultMessage, type TextBlock, - type ContentBlock, type SDKUserMessage, } from '@qwen-code/sdk'; import { @@ -149,7 +148,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { describe('Process Lifecycle Monitoring', () => { it('should handle normal process completion', async () => { const q = query({ - prompt: 'Why do we choose to go to the moon?', + prompt: 'Say hello', options: { ...SHARED_TEST_OPTIONS, cwd: testDir, @@ -158,18 +157,12 @@ describe('AbortController and Process Lifecycle (E2E)', () => { }); let completedSuccessfully = false; + let receivedAssistantMessage = false; try { for await (const message of q) { if (isSDKAssistantMessage(message)) { - const textBlocks = message.message.content.filter( - (block): block is TextBlock => block.type === 'text', - ); - const text = textBlocks - .map((b) => b.text) - .join('') - .slice(0, 100); - expect(text.length).toBeGreaterThan(0); + receivedAssistantMessage = true; } } @@ -180,6 +173,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { } finally { await q.close(); expect(completedSuccessfully).toBe(true); + expect(receivedAssistantMessage).toBe(true); } }); @@ -219,7 +213,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { describe('Input Stream Control', () => { it('should support endInput() method', async () => { const q = query({ - prompt: 'What is 2 + 2?', + prompt: 'Say hello', options: { ...SHARED_TEST_OPTIONS, cwd: testDir, @@ -233,13 +227,6 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { if (isSDKAssistantMessage(message) && !endInputCalled) { - const textBlocks = message.message.content.filter( - (block: ContentBlock): block is TextBlock => - block.type === 'text', - ); - const text = textBlocks.map((b: TextBlock) => b.text).join(''); - - expect(text.length).toBeGreaterThan(0); receivedResponse = true; // End input after receiving first response @@ -485,7 +472,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { const stderrMessages: string[] = []; const q = query({ - prompt: 'Why do we choose to go to the moon?', + prompt: 'Say hello', options: { ...SHARED_TEST_OPTIONS, cwd: testDir, @@ -497,17 +484,8 @@ describe('AbortController and Process Lifecycle (E2E)', () => { }); try { - for await (const message of q) { - if (isSDKAssistantMessage(message)) { - const textBlocks = message.message.content.filter( - (block): block is TextBlock => block.type === 'text', - ); - const text = textBlocks - .map((b) => b.text) - .join('') - .slice(0, 50); - expect(text.length).toBeGreaterThan(0); - } + for await (const _message of q) { + // Just consume all messages } } finally { await q.close(); diff --git a/integration-tests/sdk-typescript/multi-turn.test.ts b/integration-tests/sdk-typescript/multi-turn.test.ts index 4cf845fc5..fb6c07698 100644 --- a/integration-tests/sdk-typescript/multi-turn.test.ts +++ b/integration-tests/sdk-typescript/multi-turn.test.ts @@ -154,10 +154,10 @@ describe('Multi-Turn Conversations (E2E)', () => { expect(messages.length).toBeGreaterThan(0); expect(assistantMessages.length).toBeGreaterThanOrEqual(3); - // Validate content of responses - expect(assistantTexts[0]).toMatch(/2/); - expect(assistantTexts[1]).toMatch(/4/); - expect(assistantTexts[2]).toMatch(/6/); + // Validate that we received text responses (may include thinking blocks) + // At least some assistant messages should have non-empty text + const nonEmptyTexts = assistantTexts.filter((t) => t.length > 0); + expect(nonEmptyTexts.length).toBeGreaterThan(0); } finally { await q.close(); } diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index 4c253dc28..5ea241db7 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -128,6 +128,7 @@ describe('Permission Control (E2E)', () => { prompt: 'Write a js hello world to file.', options: { ...SHARED_TEST_OPTIONS, + permissionMode: 'default', cwd: testDir, canUseTool: async (toolName, input) => { toolCalls.push({ toolName, input }); @@ -762,8 +763,15 @@ describe('Permission Control (E2E)', () => { it( 'should execute read-only tools without confirmation', async () => { + // Create a file so the model has something to read + await helper.createFile( + 'read-only-test.txt', + 'content for read-only test', + ); + const q = query({ - prompt: 'List files in the current directory', + prompt: + 'Use the read_file tool to read the file read-only-test.txt in the current directory.', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', diff --git a/package-lock.json b/package-lock.json index 5df32acc0..fd6cc6624 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.13.0", "workspaces": [ "packages/*" ], @@ -22,7 +22,6 @@ "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", - "@types/qrcode-terminal": "^0.12.2", "@types/shell-quote": "^1.7.5", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", @@ -74,6 +73,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", @@ -4529,13 +4537,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/qrcode-terminal": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", - "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -14702,14 +14703,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/qrcode-terminal": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", - "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -18791,8 +18784,9 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.13.0", "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", @@ -18815,7 +18809,6 @@ "open": "^10.1.2", "p-limit": "^7.3.0", "prompts": "^2.4.2", - "qrcode-terminal": "^0.12.0", "react": "^19.1.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", @@ -19448,7 +19441,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.12.0", + "version": "0.13.0", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -20877,39 +20870,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 +21677,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", @@ -22929,7 +22872,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.12.0", + "version": "0.13.0", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22941,9 +22884,10 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.12.0", + "version": "0.13.0", "license": "LICENSE", "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/webui": "*", "cors": "^2.8.5", @@ -23188,7 +23132,7 @@ }, "packages/web-templates": { "name": "@qwen-code/web-templates", - "version": "0.12.0", + "version": "0.13.0", "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -23716,7 +23660,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index d12e16152..a49760350 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.13.0", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.13.0" }, "scripts": { "start": "cross-env node scripts/start.js", @@ -80,7 +80,6 @@ "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", - "@types/qrcode-terminal": "^0.12.2", "@types/shell-quote": "^1.7.5", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 8e9912f10..3b00b9546 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -9,8 +9,30 @@ import './src/gemini.js'; import { main } from './src/gemini.js'; import { FatalError } from '@qwen-code/qwen-code-core'; +import { writeStderrLine } from './src/utils/stdioHelpers.js'; // --- Global Entry Point --- + +// Suppress known race condition in @lydell/node-pty on Windows where a +// deferred resize fires after the pty process has already exited. +// Tracking bug: https://github.com/microsoft/node-pty/issues/827 +process.on('uncaughtException', (error) => { + if ( + process.platform === 'win32' && + error instanceof Error && + error.message === 'Cannot resize a pty that has already exited' + ) { + return; + } + + if (error instanceof Error) { + writeStderrLine(error.stack ?? error.message); + } else { + writeStderrLine(String(error)); + } + process.exit(1); +}); + main().catch((error) => { if (error instanceof FatalError) { let errorMessage = error.message; diff --git a/packages/cli/package.json b/packages/cli/package.json index 1a2e53a85..fff36c603 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.13.0", "description": "Qwen Code", "repository": { "type": "git", @@ -33,9 +33,10 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.13.0" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", @@ -58,7 +59,6 @@ "open": "^10.1.2", "p-limit": "^7.3.0", "prompts": "^2.4.2", - "qrcode-terminal": "^0.12.0", "react": "^19.1.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", @@ -100,10 +100,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..a8683c7c5 100644 --- a/packages/cli/src/acp-integration/service/filesystem.test.ts +++ b/packages/cli/src/acp-integration/service/filesystem.test.ts @@ -7,26 +7,30 @@ 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(), - readTextFileWithInfo: vi - .fn() - .mockResolvedValue({ content: '', encoding: 'utf-8', bom: false }), - writeTextFile: vi.fn(), - detectFileBOM: vi.fn().mockResolvedValue(false), + readTextFile: vi.fn().mockResolvedValue({ + content: '', + _meta: { bom: false, encoding: 'utf-8' }, + }), + writeTextFile: vi.fn().mockResolvedValue({ _meta: undefined }), findFiles: vi.fn().mockReturnValue([]), }); describe('AcpFileSystemService', () => { - describe('detectFileBOM', () => { - it('detects BOM through ACP client when content starts with U+FEFF', async () => { + describe('readTextFile', () => { + it('reads through ACP and returns response', async () => { + const mockResponse = { + content: 'hello', + _meta: { bom: false, encoding: 'utf-8' }, + }; const client = { - readTextFile: vi - .fn() - .mockResolvedValue({ content: '\ufeff// BOM file' }), - } as unknown as import('../acp.js').Client; + readTextFile: vi.fn().mockResolvedValue(mockResponse), + } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( client, @@ -35,87 +39,23 @@ describe('AcpFileSystemService', () => { createFallback(), ); - const result = await svc.detectFileBOM('/test/file.txt'); - expect(result).toBe(true); + const result = await svc.readTextFile({ path: '/some/file.txt' }); + + expect(result).toEqual(mockResponse); expect(client.readTextFile).toHaveBeenCalledWith({ - path: '/test/file.txt', + path: '/some/file.txt', sessionId: 'session-1', - line: null, - limit: 1, }); }); - 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; - - const svc = new AcpFileSystemService( - client, - 'session-2', - { readTextFile: true, writeTextFile: true }, - createFallback(), - ); - - const result = await svc.detectFileBOM('/test/file.txt'); - expect(result).toBe(false); - }); - - 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; - - const fallback = createFallback(); - (fallback.detectFileBOM as ReturnType).mockResolvedValue( - true, - ); - - const svc = new AcpFileSystemService( - client, - 'session-3', - { readTextFile: true, writeTextFile: true }, - fallback, - ); - - const result = await svc.detectFileBOM('/test/file.txt'); - expect(result).toBe(true); - expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt'); - }); - - it('falls back to local filesystem when readTextFile capability is disabled', async () => { - const client = { - readTextFile: vi.fn(), - } as unknown as import('../acp.js').Client; - - const fallback = createFallback(); - (fallback.detectFileBOM as ReturnType).mockResolvedValue( - false, - ); - - const svc = new AcpFileSystemService( - client, - 'session-4', - { readTextFile: false, writeTextFile: true }, - fallback, - ); - - const result = await svc.detectFileBOM('/test/file.txt'); - expect(result).toBe(false); - expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt'); - expect(client.readTextFile).not.toHaveBeenCalled(); - }); - }); - - 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, @@ -124,7 +64,9 @@ describe('AcpFileSystemService', () => { createFallback(), ); - await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({ + await expect( + svc.readTextFile({ path: '/some/file.txt' }), + ).rejects.toMatchObject({ code: 'ENOENT', errno: -2, path: '/some/file.txt', @@ -133,12 +75,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, @@ -147,8 +89,10 @@ describe('AcpFileSystemService', () => { createFallback(), ); - await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, + await expect( + svc.readTextFile({ path: '/some/file.txt' }), + ).rejects.toMatchObject({ + code: INTERNAL_ERROR_CODE, message: 'Internal error', }); }); @@ -156,11 +100,15 @@ 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(); + const fallbackResponse = { + content: 'fallback content', + _meta: { bom: false, encoding: 'utf-8' }, + }; (fallback.readTextFile as ReturnType).mockResolvedValue( - 'fallback content', + fallbackResponse, ); const svc = new AcpFileSystemService( @@ -170,10 +118,12 @@ describe('AcpFileSystemService', () => { fallback, ); - const result = await svc.readTextFile('/some/file.txt'); + const result = await svc.readTextFile({ path: '/some/file.txt' }); - expect(result).toBe('fallback content'); - expect(fallback.readTextFile).toHaveBeenCalledWith('/some/file.txt'); + expect(result).toEqual(fallbackResponse); + expect(fallback.readTextFile).toHaveBeenCalledWith({ + path: '/some/file.txt', + }); expect(client.readTextFile).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index b20d5f0ff..201c86808 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -1,109 +1,96 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ +import type { + AgentSideConnection, + FileSystemCapability, + ReadTextFileRequest, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { RequestError } from '@agentclientprotocol/sdk'; import type { FileSystemService, - FileReadResult, + ReadTextFileResponse, } 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; + +function getErrorCode(error: unknown): unknown { + if (error instanceof RequestError) { + return error.code; + } + + if (typeof error === 'object' && error !== null && 'code' in error) { + return (error as { code?: unknown }).code; + } + + return undefined; +} + +function createEnoentError(filePath: string): NodeJS.ErrnoException { + const err = new Error(`File not found: ${filePath}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + err.errno = -2; + err.path = filePath; + return err; +} + 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, ) {} - async readTextFile(filePath: string): Promise { + async readTextFile( + params: Omit, + ): Promise { if (!this.capabilities.readTextFile) { - return this.fallback.readTextFile(filePath); + return this.fallback.readTextFile(params); } - let response: { content: string }; + let response: ReadTextFileResponse; try { - response = await this.client.readTextFile({ - path: filePath, + response = await this.connection.readTextFile({ + ...params, 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; + const errorCode = getErrorCode(error); - if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) { - const err = new Error( - `File not found: ${filePath}`, - ) as NodeJS.ErrnoException; - err.code = 'ENOENT'; - err.errno = -2; - err.path = filePath; - throw err; + if (errorCode === RESOURCE_NOT_FOUND_CODE) { + throw createEnoentError(params.path); } throw error; } - return response.content; - } - - async readTextFileWithInfo(filePath: string): Promise { - // ACP protocol does not expose encoding metadata; delegate to the local - // fallback which performs a single-pass read with encoding detection. - return this.fallback.readTextFileWithInfo(filePath); + return response; } async writeTextFile( - filePath: string, - content: string, - options?: { bom?: boolean; encoding?: string }, - ): Promise { + params: Omit, + ): Promise { if (!this.capabilities.writeTextFile) { - return this.fallback.writeTextFile(filePath, content, options); + return this.fallback.writeTextFile(params); } - // Prepend BOM character if requested - const finalContent = options?.bom ? '\uFEFF' + content : content; + const finalContent = params._meta?.['bom'] + ? '\uFEFF' + params.content + : params.content; - await this.client.writeTextFile({ - path: filePath, + await this.connection.writeTextFile({ + ...params, content: finalContent, sessionId: this.sessionId, }); - } - 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({ - 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 - ); - } catch { - // Fall through to fallback if ACP read fails - } - } - // Fall back to local filesystem detection - return this.fallback.detectFileBOM(filePath); + return { _meta: params._meta }; } findFiles(fileName: string, searchPaths: readonly string[]): string[] { 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..1458ce177 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'; @@ -81,6 +90,14 @@ const debugLogger = createDebugLogger('SESSION'); */ export class Session implements SessionContext { private pendingPrompt: AbortController | null = null; + /** + * Tracks the completion of the current prompt so that the next prompt + * can await it. This prevents a new prompt from reading chat history + * before the previous prompt's tool results have been added — + * a race condition that causes malformed history on Windows where + * process termination is slow. + */ + private pendingPromptCompletion: Promise | null = null; private turn: number = 0; // Modular components @@ -96,7 +113,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,11 +150,44 @@ export class Session implements SessionContext { this.pendingPrompt = null; } - async prompt(params: acp.PromptRequest): Promise { + async prompt(params: PromptRequest): Promise { + // Install this prompt's AbortController before awaiting the previous + // prompt, so that a session/cancel during the wait targets us. this.pendingPrompt?.abort(); const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; + // Wait for the previous prompt to finish so chat history is consistent. + if (this.pendingPromptCompletion) { + try { + await this.pendingPromptCompletion; + } catch { + // Expected: previous prompt was cancelled or errored + } + } + + // Cancelled while waiting for the previous prompt to finish. + if (pendingSend.signal.aborted) { + return { stopReason: 'cancelled' }; + } + + // Track this prompt's completion for the next prompt to await + let resolveCompletion!: () => void; + this.pendingPromptCompletion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + + try { + return await this.#executePrompt(params, pendingSend); + } finally { + resolveCompletion(); + } + } + + async #executePrompt( + params: PromptRequest, + pendingSend: AbortController, + ): Promise { // Increment turn counter for each user prompt this.turn += 1; @@ -254,10 +304,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 +334,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 +361,7 @@ export class Session implements SessionContext { }), ); - const update: AvailableCommandsUpdate = { + const update: SessionUpdate = { sessionUpdate: 'available_commands_update', availableCommands, }; @@ -331,8 +378,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 +387,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 +397,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 +419,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 +433,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 +455,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); @@ -512,13 +554,27 @@ export class Session implements SessionContext { } const confirmationDetails = - this.config.getApprovalMode() !== ApprovalMode.YOLO - ? await invocation.shouldConfirmExecute(abortSignal) - : false; + await invocation.shouldConfirmExecute(abortSignal); + + // In YOLO mode, auto-approve everything except ask_user_question + // (the user must always have a chance to respond to questions) + const isAskUserQuestionTool = + confirmationDetails && confirmationDetails.type === 'ask_user_question'; + const effectiveConfirmationDetails = + this.config.getApprovalMode() === ApprovalMode.YOLO && + !isAskUserQuestionTool + ? false + : confirmationDetails; // Check for plan mode enforcement - block non-read-only tools + // but allow ask_user_question so users can answer clarification questions const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; - if (isPlanMode && !isExitPlanModeTool && confirmationDetails) { + if ( + isPlanMode && + !isExitPlanModeTool && + !isAskUserQuestionTool && + effectiveConfirmationDetails + ) { // In plan mode, block any tool that requires confirmation (write operations) return errorResponse( new Error( @@ -528,25 +584,25 @@ export class Session implements SessionContext { ); } - if (confirmationDetails) { - const content: acp.ToolCallContent[] = []; + if (effectiveConfirmationDetails) { + const content: ToolCallContent[] = []; - if (confirmationDetails.type === 'edit') { + if (effectiveConfirmationDetails.type === 'edit') { content.push({ type: 'diff', - path: confirmationDetails.fileName, - oldText: confirmationDetails.originalContent, - newText: confirmationDetails.newContent, + path: effectiveConfirmationDetails.fileName, + oldText: effectiveConfirmationDetails.originalContent, + newText: effectiveConfirmationDetails.newContent, }); } // Add plan content for exit_plan_mode - if (confirmationDetails.type === 'plan') { + if (effectiveConfirmationDetails.type === 'plan') { content.push({ type: 'content', content: { type: 'text', - text: confirmationDetails.plan, + text: effectiveConfirmationDetails.plan, }, }); } @@ -554,9 +610,9 @@ 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), + options: toPermissionOptions(effectiveConfirmationDetails), toolCall: { toolCallId: callId, status: 'pending', @@ -564,10 +620,15 @@ export class Session implements SessionContext { content, locations: invocation.toolLocations(), kind: mappedKind, + rawInput: args, }, }; - const output = await this.client.requestPermission(params); + const output = (await this.client.requestPermission( + params, + )) as RequestPermissionResponse & { + answers?: Record; + }; const outcome = output.outcome.outcome === 'cancelled' ? ToolConfirmationOutcome.Cancel @@ -575,7 +636,9 @@ export class Session implements SessionContext { .nativeEnum(ToolConfirmationOutcome) .parse(output.outcome.optionId); - await confirmationDetails.onConfirm(outcome); + await effectiveConfirmationDetails.onConfirm(outcome, { + answers: output.answers, + }); // After exit_plan_mode confirmation, send current_mode_update notification if (isExitPlanModeTool && outcome !== ToolConfirmationOutcome.Cancel) { @@ -732,7 +795,7 @@ export class Session implements SessionContext { */ async #processSlashCommandResult( result: NonInteractiveSlashCommandResult, - originalPrompt: acp.ContentBlock[], + originalPrompt: ContentBlock[], ): Promise { switch (result.type) { case 'submit_prompt': @@ -741,9 +804,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 +831,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 +873,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 +1027,7 @@ const basicPermissionOptions = [ function toPermissionOptions( confirmation: ToolCallConfirmationDetails, -): acp.PermissionOption[] { +): PermissionOption[] { switch (confirmation.type) { case 'edit': return [ @@ -1027,6 +1088,19 @@ function toPermissionOptions( kind: 'reject_once', }, ]; + case 'ask_user_question': + return [ + { + optionId: ToolConfirmationOutcome.ProceedOnce, + name: 'Submit', + kind: 'allow_once', + }, + { + optionId: ToolConfirmationOutcome.Cancel, + name: 'Cancel', + kind: 'reject_once', + }, + ]; default: { const unreachable: never = confirmation; throw new Error(`Unexpected: ${unreachable}`); 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/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index 29fe25b88..57c5b3ce2 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -174,6 +174,7 @@ export const addCommand: CommandModule = { describe: 'Set environment variables (e.g. -e KEY=value)', type: 'array', string: true, + nargs: 1, }) .option('header', { alias: 'H', @@ -181,6 +182,7 @@ export const addCommand: CommandModule = { 'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")', type: 'array', string: true, + nargs: 1, }) .option('timeout', { describe: 'Set connection timeout in milliseconds', diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 644fc050c..3e304050a 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -241,6 +241,30 @@ describe('parseArguments', () => { expect(argv.prompt).toBeUndefined(); }); + it('should parse --system-prompt', async () => { + process.argv = [ + 'node', + 'script.js', + '--system-prompt', + 'You are a test system prompt.', + ]; + const argv = await parseArguments(); + expect(argv.systemPrompt).toBe('You are a test system prompt.'); + expect(argv.appendSystemPrompt).toBeUndefined(); + }); + + it('should parse --append-system-prompt', async () => { + process.argv = [ + 'node', + 'script.js', + '--append-system-prompt', + 'Be extra concise.', + ]; + const argv = await parseArguments(); + expect(argv.appendSystemPrompt).toBe('Be extra concise.'); + expect(argv.systemPrompt).toBeUndefined(); + }); + it('should allow -r flag as alias for --resume', async () => { process.argv = [ 'node', @@ -432,6 +456,21 @@ describe('parseArguments', () => { mockExit.mockRestore(); }); + it('should allow --system-prompt and --append-system-prompt together', async () => { + process.argv = [ + 'node', + 'script.js', + '--system-prompt', + 'Override prompt', + '--append-system-prompt', + 'Append prompt', + ]; + + const argv = await parseArguments(); + expect(argv.systemPrompt).toBe('Override prompt'); + expect(argv.appendSystemPrompt).toBe('Append prompt'); + }); + it('should throw an error when include-partial-messages is used without stream-json output', async () => { process.argv = ['node', 'script.js', '--include-partial-messages']; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 88153fe75..34a9c25cd 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -10,7 +10,6 @@ import { Config, DEFAULT_QWEN_EMBEDDING_MODEL, FileDiscoveryService, - FileEncoding, getAllGeminiMdFilenames, loadServerHierarchicalMemory, setGeminiMdFilename as setServerGeminiMdFilename, @@ -111,6 +110,8 @@ export interface CliArgs { debug: boolean | undefined; prompt: string | undefined; promptInteractive: string | undefined; + systemPrompt: string | undefined; + appendSystemPrompt: string | undefined; yolo: boolean | undefined; approvalMode: string | undefined; telemetry: boolean | undefined; @@ -290,6 +291,16 @@ export async function parseArguments(): Promise { description: 'Execute the provided prompt and continue in interactive mode', }) + .option('system-prompt', { + type: 'string', + description: + 'Override the main session system prompt for this run. Can be combined with --append-system-prompt.', + }) + .option('append-system-prompt', { + type: 'string', + description: + 'Append instructions to the main session system prompt for this run. Can be combined with --system-prompt.', + }) .option('sandbox', { alias: 's', type: 'boolean', @@ -962,6 +973,8 @@ export async function loadCliConfig( importFormat: settings.context?.importFormat || 'tree', debugMode, question, + systemPrompt: argv.systemPrompt, + appendSystemPrompt: argv.appendSystemPrompt, coreTools: argv.coreTools || settings.tools?.core || undefined, allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, excludeTools, @@ -1013,7 +1026,6 @@ export async function loadCliConfig( warnings: resolvedCliConfig.warnings, cliVersion: await getCliVersion(), webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), - summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, chatCompression: settings.model?.chatCompression, folderTrust, @@ -1027,7 +1039,6 @@ export async function loadCliConfig( skipStartupContext: settings.model?.skipStartupContext ?? false, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, truncateToolOutputLines: settings.tools?.truncateToolOutputLines, - enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: appEvents, gitCoAuthor: settings.general?.gitCoAuthor, output: { @@ -1043,8 +1054,7 @@ export async function loadCliConfig( // always be true and the settings file can never disable recording. chatRecording: argv.chatRecording ?? settings.general?.chatRecording ?? true, - defaultFileEncoding: - settings.general?.defaultFileEncoding ?? FileEncoding.UTF8, + defaultFileEncoding: settings.general?.defaultFileEncoding, lsp: { enabled: lspEnabled, }, diff --git a/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts b/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts index c87fa4480..c63979f35 100644 --- a/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts +++ b/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts @@ -55,7 +55,6 @@ export const V1_TO_V2_MIGRATION_MAP: Record = { shellPager: 'tools.shell.pager', shellShowColor: 'tools.shell.showColor', skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', - summarizeToolOutput: 'model.summarizeToolOutput', telemetry: 'telemetry', theme: 'ui.theme', toolDiscoveryCommand: 'tools.discoveryCommand', @@ -157,7 +156,6 @@ export const V1_INDICATOR_KEYS = [ 'shellPager', 'shellShowColor', 'skipNextSpeakerCheck', - 'summarizeToolOutput', 'toolDiscoveryCommand', 'toolCallCommand', 'usageStatisticsEnabled', diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 2234c9ea4..9550932c9 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -348,7 +348,7 @@ describe('Settings Loading and Merging', () => { fileName: 'WORKSPACE_CONTEXT.md', }, mcp: { - allowed: ['server1', 'server2'], + allowed: ['server1', 'server2', 'server3', 'server1', 'server2'], }, }); }); @@ -1474,8 +1474,8 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.mcp).toEqual({ - allowed: ['system-allowed'], - excluded: ['workspace-excluded'], + allowed: ['user-allowed', 'workspace-allowed', 'system-allowed'], + excluded: ['user-excluded', 'workspace-excluded'], }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 3ce34edc1..dbd9a20ec 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -14,8 +14,6 @@ import { QWEN_DIR, getErrorMessage, Storage, - setDebugLogSession, - sanitizeCwd, createDebugLogger, } from '@qwen-code/qwen-code-core'; import stripJsonComments from 'strip-json-comments'; @@ -105,10 +103,6 @@ export interface CheckpointingSettings { enabled?: boolean; } -export interface SummarizeToolOutputSettings { - tokenBudget?: number; -} - export interface AccessibilitySettings { enableLoadingPhrases?: boolean; screenReader?: boolean; @@ -476,16 +470,6 @@ export function loadEnvironment(settings: Settings): void { export function loadSettings( workspaceDir: string = process.cwd(), ): LoadedSettings { - // Set up a temporary debug log session for the startup phase. - // This allows migration errors to be logged to file instead of being - // exposed to users via stderr. The Config class will override this - // with the actual session once initialized. - const resolvedWorkspaceDir = path.resolve(workspaceDir); - const sanitizedProjectId = sanitizeCwd(resolvedWorkspaceDir); - setDebugLogSession({ - getSessionId: () => `startup-${sanitizedProjectId}`, - }); - let systemSettings: Settings = {}; let systemDefaultSettings: Settings = {}; let userSettings: Settings = {}; @@ -496,7 +480,7 @@ export function loadSettings( const migratedInMemorScopes = new Set(); // Resolve paths to their canonical representation to handle symlinks - // Note: resolvedWorkspaceDir is already defined at the top of the function + const resolvedWorkspaceDir = path.resolve(workspaceDir); const resolvedHomeDir = path.resolve(homedir()); let realWorkspaceDir = resolvedWorkspaceDir; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b2d24712b..373988d72 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -76,12 +76,98 @@ export interface SettingDefinition { mergeStrategy?: MergeStrategy; /** Enum type options */ options?: readonly SettingEnumOption[]; + /** Schema for array items when type is 'array' */ + items?: SettingItemDefinition; +} + +/** + * Schema definition for array item types. + * Supports simple types (string, number, boolean) and complex object types. + */ +export interface SettingItemDefinition { + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + properties?: Record< + string, + SettingItemDefinition & { + required?: boolean; + enum?: string[]; + additionalProperties?: SettingItemDefinition; + } + >; + items?: SettingItemDefinition; + required?: boolean; + enum?: string[]; + description?: string; + additionalProperties?: boolean | SettingItemDefinition; } export interface SettingsSchema { [key: string]: SettingDefinition; } +/** + * Common items schema for hook definitions. + * Used by both UserPromptSubmit and Stop hooks. + */ +const HOOK_DEFINITION_ITEMS: SettingItemDefinition = { + type: 'object', + description: + 'A hook definition with an optional matcher and a list of hook configurations.', + properties: { + matcher: { + type: 'string', + description: + 'An optional matcher pattern to filter when this hook definition applies.', + }, + sequential: { + type: 'boolean', + description: + 'Whether the hooks should be executed sequentially instead of in parallel.', + }, + hooks: { + type: 'array', + description: 'The list of hook configurations to execute.', + required: true, + items: { + type: 'object', + description: + 'A hook configuration entry that defines a command to execute.', + properties: { + type: { + type: 'string', + description: 'The type of hook.', + enum: ['command'], + required: true, + }, + command: { + type: 'string', + description: 'The command to execute when the hook is triggered.', + required: true, + }, + name: { + type: 'string', + description: 'An optional name for the hook.', + }, + description: { + type: 'string', + description: 'An optional description of what the hook does.', + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds for the hook execution.', + }, + env: { + type: 'object', + description: + 'Environment variables to set when executing the hook command.', + additionalProperties: { type: 'string' }, + }, + }, + }, + }, + }, +}; + export type MemoryImportFormat = 'tree' | 'flat'; export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; @@ -546,17 +632,6 @@ const SETTINGS_SCHEMA = { 'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.', showInDialog: false, }, - summarizeToolOutput: { - type: 'object', - label: 'Summarize Tool Output', - category: 'Model', - requiresRestart: false, - default: undefined as - | Record - | undefined, - description: 'Settings for summarizing tool output.', - showInDialog: false, - }, chatCompression: { type: 'object', label: 'Chat Compression', @@ -941,15 +1016,6 @@ const SETTINGS_SCHEMA = { '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.', showInDialog: false, }, - enableToolOutputTruncation: { - type: 'boolean', - label: 'Enable Tool Output Truncation', - category: 'General', - requiresRestart: true, - default: true, - description: 'Enable truncation of large tool outputs.', - showInDialog: false, - }, truncateToolOutputThreshold: { type: 'number', label: 'Tool Output Truncation Threshold', @@ -998,6 +1064,7 @@ const SETTINGS_SCHEMA = { default: undefined as string[] | undefined, description: 'A list of MCP servers to allow.', showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, }, excluded: { type: 'array', @@ -1007,6 +1074,7 @@ const SETTINGS_SCHEMA = { default: undefined as string[] | undefined, description: 'A list of MCP servers to exclude.', showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, }, }, }, @@ -1231,6 +1299,7 @@ const SETTINGS_SCHEMA = { 'Hooks that execute before agent processing. Can modify prompts or inject context.', showInDialog: false, mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, }, Stop: { type: 'array', @@ -1242,6 +1311,7 @@ const SETTINGS_SCHEMA = { 'Hooks that execute after agent processing. Can post-process responses or log interactions.', showInDialog: false, mergeStrategy: MergeStrategy.CONCAT, + items: HOOK_DEFINITION_ITEMS, }, }, }, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9b47de5b5..b9ddb97fa 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -467,6 +467,8 @@ describe('gemini.tsx main function kitty protocol', () => { debug: undefined, prompt: undefined, promptInteractive: undefined, + systemPrompt: undefined, + appendSystemPrompt: undefined, query: undefined, yolo: undefined, approvalMode: undefined, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index a5af9d471..09e138670 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -99,6 +99,7 @@ export default { 'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.', 'List available Qwen Code tools. Usage: /tools [desc]': 'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]', + 'List available skills.': 'Verfügbare Skills auflisten.', 'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:', 'No tools available': 'Keine Werkzeuge verfügbar', 'View or change the approval mode for tool usage': @@ -376,6 +377,7 @@ export default { 'Diese Editoren werden derzeit unterstützt. Bitte beachten Sie, dass einige Editoren nicht im Sandbox-Modus verwendet werden können.', 'Your preferred editor is:': 'Ihr bevorzugter Editor ist:', 'Manage extensions': 'Erweiterungen verwalten', + 'Manage installed extensions': 'Installierte Erweiterungen verwalten', 'List active extensions': 'Aktive Erweiterungen auflisten', 'Update extensions. Usage: update |--all': 'Erweiterungen aktualisieren. Verwendung: update |--all', @@ -585,6 +587,38 @@ export default { 'Fehler beim Konfigurieren von {{terminalName}}.', 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': 'Ihr Terminal ist bereits für optimale Erfahrung mit mehrzeiliger Eingabe konfiguriert (Umschalt+Enter und Strg+Enter).', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Qwen Code-Hooks verwalten', + 'List all configured hooks': 'Alle konfigurierten Hooks auflisten', + 'Enable a disabled hook': 'Einen deaktivierten Hook aktivieren', + 'Disable an active hook': 'Einen aktiven Hook deaktivieren', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + 'Den Nachrichtenverlauf der aktuellen Sitzung in eine Datei exportieren', + 'Export session to HTML format': 'Sitzung in das HTML-Format exportieren', + 'Export session to JSON format': 'Sitzung in das JSON-Format exportieren', + 'Export session to JSONL format (one message per line)': + 'Sitzung in das JSONL-Format exportieren (eine Nachricht pro Zeile)', + 'Export session to markdown format': + 'Sitzung in das Markdown-Format exportieren', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'Personalisierte Programmier-Einblicke aus Ihrem Chatverlauf generieren', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': 'Eine vorherige Sitzung fortsetzen', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'Einen Tool-Aufruf wiederherstellen. Dadurch werden Konversations- und Dateiverlauf auf den Zustand zurückgesetzt, in dem der Tool-Aufruf vorgeschlagen wurde', 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': 'Terminal-Typ konnte nicht erkannt werden. Unterstützte Terminals: VS Code, Cursor, Windsurf und Trae.', 'Terminal "{{terminal}}" is not supported yet.': @@ -745,6 +779,15 @@ export default { "Authentifizierung mit MCP-Server '{{name}}' fehlgeschlagen: {{error}}", "Re-discovering tools from '{{name}}'...": "Werkzeuge von '{{name}}' werden neu erkannt...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "{{count}} Werkzeug(e) von '{{name}}' entdeckt.", + 'Authentication complete. Returning to server details...': + 'Authentifizierung abgeschlossen. Zurück zu den Serverdetails...', + 'Authentication successful.': 'Authentifizierung erfolgreich.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'Falls der Browser sich nicht öffnet, kopieren Sie diese URL und fügen Sie sie in Ihren Browser ein:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ Stellen Sie sicher, dass Sie die VOLLSTÄNDIGE URL kopieren – sie kann über mehrere Zeilen gehen.', // ============================================================================ // Commands - Chat @@ -915,6 +958,11 @@ export default { 'Enter zum Bestätigen, Esc zum Abbrechen', Disable: 'Deaktivieren', Enable: 'Aktivieren', + Authenticate: 'Authentifizieren', + 'Re-authenticate': 'Erneut authentifizieren', + 'Clear Authentication': 'Authentifizierung löschen', + disabled: 'deaktiviert', + 'Server:': 'Server:', Reconnect: 'Neu verbinden', 'View tools': 'Werkzeuge anzeigen', 'Status:': 'Status:', @@ -943,6 +991,14 @@ export default { 'Run qwen --debug to see error logs': 'Führen Sie qwen --debug aus, um Fehlerprotokolle anzuzeigen', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth-Authentifizierung', + 'Press Enter to start authentication, Esc to go back': + 'Drücken Sie Enter, um die Authentifizierung zu starten, Esc zum Zurückgehen', + 'Authenticating... Please complete the login in your browser.': + 'Authentifizierung läuft... Bitte schließen Sie die Anmeldung in Ihrem Browser ab.', + 'Press Enter or Esc to go back': 'Drücken Sie Enter oder Esc zum Zurückgehen', + // MCP Tool List 'No tools available for this server.': 'Keine Werkzeuge für diesen Server verfügbar.', @@ -951,6 +1007,7 @@ export default { 'open-world': 'offene Welt', idempotent: 'idempotent', 'Tools for {{name}}': 'Werkzeuge für {{name}}', + 'Tools for {{serverName}}': 'Werkzeuge für {{serverName}}', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail @@ -1539,6 +1596,18 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!', + Custom: 'Benutzerdefiniert', + 'More instructions about configuring `modelProviders` manually.': + 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', + 'Select API-KEY configuration mode:': + 'API-KEY-Konfigurationsmodus auswählen:', + '(Press Escape to go back)': '(Escape drücken zum Zurückgehen)', + '(Press Enter to submit, Escape to cancel)': + '(Enter zum Absenden, Escape zum Abbrechen)', + 'More instructions please check:': 'Weitere Anweisungen finden Sie unter:', 'Select Region for Coding Plan': 'Region für Coding Plan auswählen', 'Choose based on where your account is registered': 'Wählen Sie basierend auf dem Registrierungsort Ihres Kontos', @@ -1557,4 +1626,33 @@ export default { '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.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Bitte beantworten Sie die folgende(n) Frage(n):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Benutzerfragen können im nicht-interaktiven Modus nicht gestellt werden. Bitte führen Sie das Tool im interaktiven Modus aus.', + 'User declined to answer the questions.': + 'Benutzer hat die Beantwortung der Fragen abgelehnt.', + 'User has provided the following answers:': + 'Benutzer hat die folgenden Antworten bereitgestellt:', + 'Failed to process user answers:': + 'Fehler beim Verarbeiten der Benutzerantworten:', + 'Type something...': 'Etwas eingeben...', + Submit: 'Senden', + 'Submit answers': 'Antworten senden', + Cancel: 'Abbrechen', + 'Your answers:': 'Ihre Antworten:', + '(not answered)': '(nicht beantwortet)', + 'Ready to submit your answers?': 'Bereit, Ihre Antworten zu senden?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Navigieren | ←/→: Tabs wechseln | Enter: Auswählen', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigieren | ←/→: Tabs wechseln | Space/Enter: Umschalten | Esc: Abbrechen', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigieren | Space/Enter: Umschalten | Esc: Abbrechen', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Navigieren | Enter: Auswählen | Esc: Abbrechen', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 94a3fe7e0..903310a6c 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -118,6 +118,7 @@ export default { '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 skills.': 'List available skills.', '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 +290,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) // ============================================================================ @@ -392,6 +460,7 @@ export default { 'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.', 'Your preferred editor is:': 'Your preferred editor is:', 'Manage extensions': 'Manage extensions', + 'Manage installed extensions': 'Manage installed extensions', 'List active extensions': 'List active extensions', 'Update extensions. Usage: update |--all': 'Update extensions. Usage: update |--all', @@ -592,6 +661,37 @@ export default { 'Failed to configure {{terminalName}}.', 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Manage Qwen Code hooks', + 'List all configured hooks': 'List all configured hooks', + 'Enable a disabled hook': 'Enable a disabled hook', + 'Disable an active hook': 'Disable an active hook', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + 'Export current session message history to a file', + 'Export session to HTML format': 'Export session to HTML format', + 'Export session to JSON format': 'Export session to JSON format', + 'Export session to JSONL format (one message per line)': + 'Export session to JSONL format (one message per line)', + 'Export session to markdown format': 'Export session to markdown format', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'generate personalized programming insights from your chat history', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': 'Resume a previous session', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.', 'Terminal "{{terminal}}" is not supported yet.': @@ -744,6 +844,15 @@ export default { "Failed to authenticate with MCP server '{{name}}': {{error}}", "Re-discovering tools from '{{name}}'...": "Re-discovering tools from '{{name}}'...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "Discovered {{count}} tool(s) from '{{name}}'.", + 'Authentication complete. Returning to server details...': + 'Authentication complete. Returning to server details...', + 'Authentication successful.': 'Authentication successful.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'If the browser does not open, copy and paste this URL into your browser:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.', // ============================================================================ // MCP Management Dialog @@ -775,7 +884,10 @@ export default { Reconnect: 'Reconnect', Enable: 'Enable', Disable: 'Disable', - 'Status:': 'Status:', + Authenticate: 'Authenticate', + 'Re-authenticate': 'Re-authenticate', + 'Clear Authentication': 'Clear Authentication', + 'Server:': 'Server:', 'Command:': 'Command:', 'Working Directory:': 'Working Directory:', 'Capabilities:': 'Capabilities:', @@ -789,7 +901,6 @@ export default { connected: 'connected', connecting: 'connecting', disconnected: 'disconnected', - error: 'error', // MCP Server List 'User MCPs': 'User MCPs', @@ -801,6 +912,14 @@ export default { 'Add MCP servers to your settings to get started.', 'Run qwen --debug to see error logs': 'Run qwen --debug to see error logs', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth Authentication', + 'Press Enter to start authentication, Esc to go back': + 'Press Enter to start authentication, Esc to go back', + 'Authenticating... Please complete the login in your browser.': + 'Authenticating... Please complete the login in your browser.', + 'Press Enter or Esc to go back': 'Press Enter or Esc to go back', + // MCP Tool List 'No tools available for this server.': 'No tools available for this server.', destructive: 'destructive', @@ -808,6 +927,7 @@ export default { 'open-world': 'open-world', idempotent: 'idempotent', 'Tools for {{name}}': 'Tools for {{name}}', + 'Tools for {{serverName}}': 'Tools for {{serverName}}', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail @@ -1530,6 +1650,16 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + "Paste your api key of Bailian Coding Plan and you're all set!", + Custom: 'Custom', + 'More instructions about configuring `modelProviders` manually.': + 'More instructions about configuring `modelProviders` manually.', + 'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:', + '(Press Escape to go back)': '(Press Escape to go back)', + '(Press Enter to submit, Escape to cancel)': + '(Press Enter to submit, Escape to cancel)', 'Select Region for Coding Plan': 'Select Region for Coding Plan', 'Choose based on where your account is registered': 'Choose based on where your account is registered', @@ -1548,4 +1678,32 @@ export default { '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.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Please answer the following question(s):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.', + 'User declined to answer the questions.': + 'User declined to answer the questions.', + 'User has provided the following answers:': + 'User has provided the following answers:', + 'Failed to process user answers:': 'Failed to process user answers:', + 'Type something...': 'Type something...', + Submit: 'Submit', + 'Submit answers': 'Submit answers', + Cancel: 'Cancel', + 'Your answers:': 'Your answers:', + '(not answered)': '(not answered)', + 'Ready to submit your answers?': 'Ready to submit your answers?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Navigate | Enter: Select | Esc: Cancel', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index b577e2cc1..4c99e4148 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -85,6 +85,7 @@ export default { 'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成', 'List available Qwen Code tools. Usage: /tools [desc]': '利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]', + 'List available skills.': '利用可能なスキルを一覧表示する。', 'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:', 'No tools available': '利用可能なツールはありません', 'View or change the approval mode for tool usage': @@ -328,6 +329,7 @@ export default { 'ワークスペース内のすべてのディレクトリを表示', 'set external editor preference': '外部エディタの設定', 'Manage extensions': '拡張機能を管理', + 'Manage installed extensions': 'インストール済みの拡張機能を管理する', 'List active extensions': '有効な拡張機能を一覧表示', 'Update extensions. Usage: update |--all': '拡張機能を更新。使い方: update <拡張機能名>|--all', @@ -371,6 +373,38 @@ export default { '{{terminalName}} の設定に失敗しました', 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': 'ターミナルは複数行入力(Shift+Enter と Ctrl+Enter)に最適化されています', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Qwen Code のフックを管理する', + 'List all configured hooks': '設定済みのフックをすべて表示する', + 'Enable a disabled hook': '無効なフックを有効にする', + 'Disable an active hook': '有効なフックを無効にする', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + '現在のセッションのメッセージ履歴をファイルにエクスポートする', + 'Export session to HTML format': 'セッションを HTML 形式でエクスポートする', + 'Export session to JSON format': 'セッションを JSON 形式でエクスポートする', + 'Export session to JSONL format (one message per line)': + 'セッションを JSONL 形式でエクスポートする(1 行に 1 メッセージ)', + 'Export session to markdown format': + 'セッションを Markdown 形式でエクスポートする', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'チャット履歴からパーソナライズされたプログラミングインサイトを生成する', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': '前のセッションを再開する', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'ツール呼び出しを復元します。これにより、会話とファイルの履歴はそのツール呼び出しが提案された時点の状態に戻ります', 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': 'ターミナルの種類を検出できませんでした。サポートされているターミナル: VS Code、Cursor、Windsurf、Trae', 'Terminal "{{terminal}}" is not supported yet.': @@ -507,6 +541,15 @@ export default { "MCPサーバー '{{name}}' での認証に失敗: {{error}}", "Re-discovering tools from '{{name}}'...": "'{{name}}' からツールを再検出中...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "'{{name}}' から {{count}} 個のツールを検出しました。", + 'Authentication complete. Returning to server details...': + '認証完了。サーバー詳細に戻ります...', + 'Authentication successful.': '認証成功。', + 'If the browser does not open, copy and paste this URL into your browser:': + 'ブラウザが開かない場合は、このURLをコピーしてブラウザに貼り付けてください:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ URL全体をコピーしてください——複数行にまたがる場合があります。', 'Configured MCP servers:': '設定済みMCPサーバー:', Ready: '準備完了', Disconnected: '切断', @@ -654,6 +697,11 @@ export default { 'Press Enter to confirm, Esc to cancel': 'Enter で確認、Esc でキャンセル', Disable: '無効化', Enable: '有効化', + Authenticate: '認証', + 'Re-authenticate': '再認証', + 'Clear Authentication': '認証をクリア', + disabled: '無効', + 'Server:': 'サーバー:', Reconnect: '再接続', 'View tools': 'ツールを表示', 'Status:': 'ステータス:', @@ -683,6 +731,14 @@ export default { 'Run qwen --debug to see error logs': 'qwen --debug を実行してエラーログを確認してください', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth 認証', + 'Press Enter to start authentication, Esc to go back': + 'Enter で認証開始、Esc で戻る', + 'Authenticating... Please complete the login in your browser.': + '認証中... ブラウザでログインを完了してください。', + 'Press Enter or Esc to go back': 'Enter または Esc で戻る', + // MCP Tool List 'No tools available for this server.': 'このサーバーには使用可能なツールがありません。', @@ -691,6 +747,7 @@ export default { 'open-world': 'オープンワールド', idempotent: '冪等', 'Tools for {{name}}': '{{name}} のツール', + 'Tools for {{serverName}}': '{{serverName}} のツール', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail @@ -1046,6 +1103,17 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です!', + Custom: 'カスタム', + 'More instructions about configuring `modelProviders` manually.': + '`modelProviders`を手動で設定する方法の詳細はこちら。', + 'Select API-KEY configuration mode:': 'API-KEY設定モードを選択してください:', + '(Press Escape to go back)': '(Escapeキーで戻る)', + '(Press Enter to submit, Escape to cancel)': + '(Enterで送信、Escapeでキャンセル)', + 'More instructions please check:': '詳細な手順はこちらをご確認ください:', 'Select Region for Coding Plan': 'Coding Planのリージョンを選択', 'Choose based on where your account is registered': 'アカウントの登録先に応じて選択してください', @@ -1064,4 +1132,31 @@ export default { '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました。', 'Tip: Use /model to switch between available Coding Plan models.': 'ヒント: /model で利用可能な Coding Plan モデルを切り替えられます。', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': '以下の質問に答えてください:', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + '非対話モードではユーザーに質問できません。このツールを使用するには対話モードで実行してください。', + 'User declined to answer the questions.': + 'ユーザーは質問への回答を拒否しました。', + 'User has provided the following answers:': + 'ユーザーは以下の回答を提供しました:', + 'Failed to process user answers:': 'ユーザー回答の処理に失敗しました:', + 'Type something...': '何か入力...', + Submit: '送信', + 'Submit answers': '回答を送信', + Cancel: 'キャンセル', + 'Your answers:': 'あなたの回答:', + '(not answered)': '(未回答)', + 'Ready to submit your answers?': '回答を送信しますか?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: ナビゲート | ←/→: タブ切り替え | Enter: 選択', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: ナビゲート | ←/→: タブ切り替え | Space/Enter: 切り替え | Esc: キャンセル', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: ナビゲート | Space/Enter: 切り替え | Esc: キャンセル', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: ナビゲート | Enter: 選択 | Esc: キャンセル', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index c1503e810..d7746377d 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -111,6 +111,7 @@ export default { '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 skills.': 'Listar habilidades disponíveis.', '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': @@ -401,6 +402,7 @@ export default { 'Estes editores são suportados atualmente. Note que alguns editores não podem ser usados no modo sandbox.', 'Your preferred editor is:': 'Seu editor preferido é:', 'Manage extensions': 'Gerenciar extensões', + 'Manage installed extensions': 'Gerenciar extensões instaladas', 'List active extensions': 'Listar extensões ativas', 'Update extensions. Usage: update |--all': 'Atualizar extensões. Uso: update |--all', @@ -590,6 +592,38 @@ export default { 'Falha ao configurar {{terminalName}}.', 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': 'Seu terminal já está configurado para uma experiência ideal com entrada multilinhas (Shift+Enter e Ctrl+Enter).', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Gerenciar hooks do Qwen Code', + 'List all configured hooks': 'Listar todos os hooks configurados', + 'Enable a disabled hook': 'Ativar um hook desativado', + 'Disable an active hook': 'Desativar um hook ativo', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + 'Exportar o histórico de mensagens da sessão atual para um arquivo', + 'Export session to HTML format': 'Exportar a sessão para o formato HTML', + 'Export session to JSON format': 'Exportar a sessão para o formato JSON', + 'Export session to JSONL format (one message per line)': + 'Exportar a sessão para o formato JSONL (uma mensagem por linha)', + 'Export session to markdown format': + 'Exportar a sessão para o formato Markdown', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'Gerar insights personalizados de programação a partir do seu histórico de chat', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': 'Retomar uma sessão anterior', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'Restaurar uma chamada de ferramenta. Isso redefinirá o histórico da conversa e dos arquivos para o estado em que a chamada da ferramenta foi sugerida', 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': 'Não foi possível detectar o tipo de terminal. Terminais suportados: VS Code, Cursor, Windsurf e Trae.', 'Terminal "{{terminal}}" is not supported yet.': @@ -751,6 +785,15 @@ export default { "Falha ao autenticar com o servidor MCP '{{name}}': {{error}}", "Re-discovering tools from '{{name}}'...": "Redescobrindo ferramentas de '{{name}}'...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "{{count}} ferramenta(s) descoberta(s) de '{{name}}'.", + 'Authentication complete. Returning to server details...': + 'Autenticação concluída. Retornando aos detalhes do servidor...', + 'Authentication successful.': 'Autenticação bem-sucedida.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'Se o navegador não abrir, copie e cole esta URL no seu navegador:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ Certifique-se de copiar a URL COMPLETA – ela pode ocupar várias linhas.', // ============================================================================ // Commands - Chat @@ -921,6 +964,11 @@ export default { 'Enter para confirmar, Esc para cancelar', Disable: 'Desativar', Enable: 'Ativar', + Authenticate: 'Autenticar', + 'Re-authenticate': 'Reautenticar', + 'Clear Authentication': 'Limpar autenticação', + disabled: 'desativado', + 'Server:': 'Servidor:', Reconnect: 'Reconectar', 'View tools': 'Ver ferramentas', 'Status:': 'Status:', @@ -950,6 +998,14 @@ export default { 'Run qwen --debug to see error logs': 'Execute qwen --debug para ver os logs de erro', + // MCP OAuth Authentication + 'OAuth Authentication': 'Autenticação OAuth', + 'Press Enter to start authentication, Esc to go back': + 'Pressione Enter para iniciar a autenticação, Esc para voltar', + 'Authenticating... Please complete the login in your browser.': + 'Autenticando... Por favor, conclua o login no seu navegador.', + 'Press Enter or Esc to go back': 'Pressione Enter ou Esc para voltar', + // MCP Tool List 'No tools available for this server.': 'Nenhuma ferramenta disponível para este servidor.', @@ -958,6 +1014,7 @@ export default { 'open-world': 'mundo aberto', idempotent: 'idempotente', 'Tools for {{name}}': 'Ferramentas para {{name}}', + 'Tools for {{serverName}}': 'Ferramentas para {{serverName}}', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail @@ -1534,6 +1591,18 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Cole sua chave de API do Bailian Coding Plan e pronto!', + Custom: 'Personalizado', + 'More instructions about configuring `modelProviders` manually.': + 'Mais instruções sobre como configurar `modelProviders` manualmente.', + 'Select API-KEY configuration mode:': + 'Selecione o modo de configuração da API-KEY:', + '(Press Escape to go back)': '(Pressione Escape para voltar)', + '(Press Enter to submit, Escape to cancel)': + '(Pressione Enter para enviar, Escape para cancelar)', + 'More instructions please check:': 'Mais instruções, consulte:', 'Select Region for Coding Plan': 'Selecionar região do Coding Plan', 'Choose based on where your account is registered': 'Escolha com base em onde sua conta está registrada', @@ -1552,4 +1621,33 @@ export default { '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.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Por favor, responda à(s) seguinte(s) pergunta(s):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Não é possível fazer perguntas ao usuário no modo não interativo. Por favor, execute no modo interativo para usar esta ferramenta.', + 'User declined to answer the questions.': + 'O usuário recusou responder às perguntas.', + 'User has provided the following answers:': + 'O usuário forneceu as seguintes respostas:', + 'Failed to process user answers:': + 'Falha ao processar as respostas do usuário:', + 'Type something...': 'Digite algo...', + Submit: 'Enviar', + 'Submit answers': 'Enviar respostas', + Cancel: 'Cancelar', + 'Your answers:': 'Suas respostas:', + '(not answered)': '(não respondido)', + 'Ready to submit your answers?': 'Pronto para enviar suas respostas?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Navegar | ←/→: Alternar abas | Enter: Selecionar', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navegar | ←/→: Alternar abas | Space/Enter: Alternar | Esc: Cancelar', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navegar | Space/Enter: Alternar | Esc: Cancelar', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Navegar | Enter: Selecionar | Esc: Cancelar', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 60b63880f..91c1eb057 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -119,6 +119,7 @@ export default { 'Анализ проекта и создание адаптированного файла QWEN.md', 'List available Qwen Code tools. Usage: /tools [desc]': 'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]', + 'List available skills.': 'Показать доступные навыки.', 'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:', 'No tools available': 'Нет доступных инструментов', 'View or change the approval mode for tool usage': @@ -398,6 +399,7 @@ export default { 'В настоящее время поддерживаются следующие редакторы. Обратите внимание, что некоторые редакторы нельзя использовать в режиме песочницы.', 'Your preferred editor is:': 'Ваш предпочитаемый редактор:', 'Manage extensions': 'Управление расширениями', + 'Manage installed extensions': 'Управлять установленными расширениями', 'List active extensions': 'Показать активные расширения', 'Update extensions. Usage: update |--all': 'Обновить расширения. Использование: update |--all', @@ -596,6 +598,38 @@ export default { 'Не удалось настроить {{terminalName}}.', 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': 'Ваш терминал уже настроен для оптимальной работы с многострочным вводом (Shift+Enter и Ctrl+Enter).', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': 'Управлять хуками Qwen Code', + 'List all configured hooks': 'Показать все настроенные хуки', + 'Enable a disabled hook': 'Включить отключенный хук', + 'Disable an active hook': 'Отключить активный хук', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + 'Экспортировать историю сообщений текущей сессии в файл', + 'Export session to HTML format': 'Экспортировать сессию в формат HTML', + 'Export session to JSON format': 'Экспортировать сессию в формат JSON', + 'Export session to JSONL format (one message per line)': + 'Экспортировать сессию в формат JSONL (одно сообщение на строку)', + 'Export session to markdown format': + 'Экспортировать сессию в формат Markdown', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + 'Создать персонализированные инсайты по программированию на основе истории чата', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': 'Продолжить предыдущую сессию', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + 'Восстановить вызов инструмента. Это вернет историю разговора и файлов к состоянию на момент, когда был предложен этот вызов инструмента', 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': 'Не удалось определить тип терминала. Поддерживаемые терминалы: VS Code, Cursor, Windsurf и Trae.', 'Terminal "{{terminal}}" is not supported yet.': @@ -754,6 +788,15 @@ export default { "Не удалось авторизоваться на MCP-сервере '{{name}}': {{error}}", "Re-discovering tools from '{{name}}'...": "Повторное обнаружение инструментов от '{{name}}'...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "Обнаружено {{count}} инструмент(ов) от '{{name}}'.", + 'Authentication complete. Returning to server details...': + 'Аутентификация завершена. Возврат к деталям сервера...', + 'Authentication successful.': 'Аутентификация успешна.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'Если браузер не открылся, скопируйте этот URL и вставьте его в браузер:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ Убедитесь, что скопировали ПОЛНЫЙ URL — он может занимать несколько строк.', // ============================================================================ // Команды - Чат @@ -899,6 +942,11 @@ export default { // MCP Management - Core translations Disable: 'Отключить', Enable: 'Включить', + Authenticate: 'Аутентификация', + 'Re-authenticate': 'Повторная аутентификация', + 'Clear Authentication': 'Очистить аутентификацию', + disabled: 'отключен', + 'Server:': 'Сервер:', Reconnect: 'Переподключить', 'View tools': 'Просмотреть инструменты', '(disabled)': '(отключен)', @@ -1478,6 +1526,17 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!', + Custom: 'Пользовательский', + 'More instructions about configuring `modelProviders` manually.': + 'Дополнительные инструкции по ручной настройке `modelProviders`.', + 'Select API-KEY configuration mode:': 'Выберите режим конфигурации API-KEY:', + '(Press Escape to go back)': '(Нажмите Escape для возврата)', + '(Press Enter to submit, Escape to cancel)': + '(Нажмите Enter для отправки, Escape для отмены)', + 'More instructions please check:': 'Дополнительные инструкции см.:', 'Select Region for Coding Plan': 'Выберите регион Coding Plan', 'Choose based on where your account is registered': 'Выберите в зависимости от места регистрации вашего аккаунта', @@ -1536,6 +1595,14 @@ export default { 'Run qwen --debug to see error logs': 'Запустите qwen --debug для просмотра журналов ошибок', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth-аутентификация', + 'Press Enter to start authentication, Esc to go back': + 'Нажмите Enter для начала аутентификации, Esc для возврата', + 'Authenticating... Please complete the login in your browser.': + 'Аутентификация... Пожалуйста, завершите вход в браузере.', + 'Press Enter or Esc to go back': 'Нажмите Enter или Esc для возврата', + // MCP Tool List 'No tools available for this server.': 'Для этого сервера нет доступных инструментов.', @@ -1544,6 +1611,7 @@ export default { 'open-world': 'открытый мир', idempotent: 'идемпотентный', 'Tools for {{name}}': 'Инструменты для {{name}}', + 'Tools for {{serverName}}': 'Инструменты для {{serverName}}', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail @@ -1565,4 +1633,33 @@ export default { 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json.', 'Tip: Use /model to switch between available Coding Plan models.': 'Совет: Используйте /model для переключения между доступными моделями Coding Plan.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Пожалуйста, ответьте на следующий(ие) вопрос(ы):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Невозможно задавать вопросы пользователю в неинтерактивном режиме. Пожалуйста, запустите в интерактивном режиме для использования этого инструмента.', + 'User declined to answer the questions.': + 'Пользователь отказался отвечать на вопросы.', + 'User has provided the following answers:': + 'Пользователь предоставил следующие ответы:', + 'Failed to process user answers:': + 'Не удалось обработать ответы пользователя:', + 'Type something...': 'Введите что-то...', + Submit: 'Отправить', + 'Submit answers': 'Отправить ответы', + Cancel: 'Отмена', + 'Your answers:': 'Ваши ответы:', + '(not answered)': '(не отвечено)', + 'Ready to submit your answers?': 'Готовы отправить свои ответы?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Навигация | ←/→: Переключение вкладок | Enter: Выбор', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Навигация | ←/→: Переключение вкладок | Space/Enter: Переключить | Esc: Отмена', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Навигация | Space/Enter: Переключить | Esc: Отмена', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Навигация | Enter: Выбор | Esc: Отмена', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 351e0931a..9a06554ff 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)': '模型上下文协议命令(来自外部服务器)', @@ -116,6 +116,7 @@ export default { '分析项目并创建定制的 QWEN.md 文件', 'List available Qwen Code tools. Usage: /tools [desc]': '列出可用的 Qwen Code 工具。用法:/tools [desc]', + 'List available skills.': '列出可用技能。', 'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:', 'No tools available': '没有可用工具', 'View or change the approval mode for tool usage': @@ -138,7 +139,7 @@ export default { '在所选作用域中未找到主题 "{{themeName}}"。', 'Clear conversation history and free up context': '清除对话历史并释放上下文', 'Compresses the context by replacing it with a summary.': - '通过用摘要替换来压缩上下文', + '通过摘要替换来压缩上下文', 'open full Qwen Code documentation in your browser': '在浏览器中打开完整的 Qwen Code 文档', 'Configuration not available.': '配置不可用', @@ -278,6 +279,67 @@ 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: '已启用', + '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) // ============================================================================ @@ -376,6 +438,7 @@ export default { '当前支持以下编辑器。请注意,某些编辑器无法在沙箱模式下使用。', 'Your preferred editor is:': '您的首选编辑器是:', 'Manage extensions': '管理扩展', + 'Manage installed extensions': '管理已安装的扩展', 'List active extensions': '列出活动扩展', 'Update extensions. Usage: update |--all': '更新扩展。用法:update |--all', @@ -562,6 +625,37 @@ export default { 'Failed to configure {{terminalName}}.': '配置 {{terminalName}} 失败。', 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': '您的终端已配置为支持多行输入(Shift+Enter 和 Ctrl+Enter)的最佳体验。', + // ============================================================================ + // Commands - Hooks + // ============================================================================ + 'Manage Qwen Code hooks': '管理 Qwen Code Hook', + 'List all configured hooks': '列出所有已配置的 Hook', + 'Enable a disabled hook': '启用已禁用的 Hook', + 'Disable an active hook': '禁用已启用的 Hook', + + // ============================================================================ + // Commands - Session Export + // ============================================================================ + 'Export current session message history to a file': + '将当前会话的消息记录导出到文件', + 'Export session to HTML format': '将会话导出为 HTML 文件', + 'Export session to JSON format': '将会话导出为 JSON 文件', + 'Export session to JSONL format (one message per line)': + '将会话导出为 JSONL 文件(每行一条消息)', + 'Export session to markdown format': '将会话导出为 Markdown 文件', + + // ============================================================================ + // Commands - Insights + // ============================================================================ + 'generate personalized programming insights from your chat history': + '根据你的聊天记录生成个性化编程洞察', + + // ============================================================================ + // Commands - Session History + // ============================================================================ + 'Resume a previous session': '恢复先前会话', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + '恢复某次工具调用。这将把对话与文件历史重置到提出该工具调用建议时的状态', 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': '无法检测终端类型。支持的终端:VS Code、Cursor、Windsurf 和 Trae。', 'Terminal "{{terminal}}" is not supported yet.': @@ -702,6 +796,15 @@ export default { "认证 MCP 服务器 '{{name}}' 失败:{{error}}", "Re-discovering tools from '{{name}}'...": "正在重新发现 '{{name}}' 的工具...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "从 '{{name}}' 发现了 {{count}} 个工具。", + 'Authentication complete. Returning to server details...': + '认证完成,正在返回服务器详情...', + 'Authentication successful.': '认证成功。', + 'If the browser does not open, copy and paste this URL into your browser:': + '如果浏览器未自动打开,请复制以下 URL 并粘贴到浏览器中:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ 请确保复制完整的 URL —— 它可能跨越多行。', // ============================================================================ // MCP Management Dialog @@ -731,6 +834,11 @@ export default { Reconnect: '重新连接', Enable: '启用', Disable: '禁用', + Authenticate: '认证', + 'Re-authenticate': '重新认证', + 'Clear Authentication': '清空认证', + disabled: '已禁用', + 'Server:': '服务器:', '(disabled)': '(已禁用)', 'Error:': '错误:', Extension: '扩展', @@ -739,7 +847,6 @@ export default { connected: '已连接', connecting: '连接中', disconnected: '已断开', - error: '错误', // MCP Server List 'User MCPs': '用户 MCP', @@ -751,8 +858,15 @@ export default { '请在设置中添加 MCP 服务器以开始使用。', 'Run qwen --debug to see error logs': '运行 qwen --debug 查看错误日志', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth 认证', + 'Press Enter to start authentication, Esc to go back': + '按 Enter 开始认证,Esc 返回', + 'Authenticating... Please complete the login in your browser.': + '认证中... 请在浏览器中完成登录。', + 'Press Enter or Esc to go back': '按 Enter 或 Esc 返回', + // MCP Server Detail - 'Status:': '状态:', 'Command:': '命令:', 'Working Directory:': '工作目录:', 'Capabilities:': '功能:', @@ -764,6 +878,7 @@ export default { 'open-world': '开放世界', idempotent: '幂等', 'Tools for {{name}}': '{{name}} 的工具', + 'Tools for {{serverName}}': '{{serverName}} 的工具', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail @@ -1359,6 +1474,16 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'API-KEY': 'API-KEY', + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + '粘贴您的百炼 Coding Plan API Key,即可完成设置!', + Custom: '自定义', + 'More instructions about configuring `modelProviders` manually.': + '关于手动配置 `modelProviders` 的更多说明。', + 'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:', + '(Press Escape to go back)': '(按 Escape 键返回)', + '(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)', 'Select Region for Coding Plan': '选择 Coding Plan 区域', 'Choose based on where your account is registered': '请根据您的账号注册地区选择', @@ -1376,4 +1501,29 @@ export default { '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。', 'Tip: Use /model to switch between available Coding Plan models.': '提示:使用 /model 切换可用的 Coding Plan 模型。', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': '请回答以下问题:', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + '无法在非交互模式下询问用户问题。请在交互模式下运行以使用此工具。', + 'User declined to answer the questions.': '用户拒绝回答问题。', + 'User has provided the following answers:': '用户提供了以下答案:', + 'Failed to process user answers:': '处理用户答案失败:', + 'Type something...': '输入内容...', + Submit: '提交', + 'Submit answers': '提交答案', + Cancel: '取消', + 'Your answers:': '您的答案:', + '(not answered)': '(未回答)', + 'Ready to submit your answers?': '准备好提交您的答案了吗?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: 导航 | ←/→: 切换标签页 | Enter: 选择', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: 导航 | ←/→: 切换标签页 | Space/Enter: 切换 | Esc: 取消', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: 导航 | Space/Enter: 切换 | Esc: 取消', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: 导航 | Enter: 选择 | Esc: 取消', }; diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 6a6b33b87..af3c93113 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -20,6 +20,7 @@ import { uiTelemetryService, FatalInputError, ApprovalMode, + SendMessageType, } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; @@ -250,7 +251,7 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Hello World'); expect(mockShutdownTelemetry).toHaveBeenCalled(); @@ -300,21 +301,21 @@ describe('runNonInteractive', () => { outputUpdateHandler: expect.any(Function), }), ); - // Verify first call has isContinuation: false + // Verify first call has type: UserQuery expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( 1, [{ text: 'Use a tool' }], expect.any(AbortSignal), 'prompt-id-2', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); - // Verify second call (after tool execution) has isContinuation: true + // Verify second call (after tool execution) has type: ToolResult expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: 'Tool response' }], expect.any(AbortSignal), 'prompt-id-2', - { isContinuation: true }, + { type: SendMessageType.ToolResult }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Final answer'); }); @@ -383,7 +384,7 @@ describe('runNonInteractive', () => { ], expect.any(AbortSignal), 'prompt-id-3', - { isContinuation: true }, + { type: SendMessageType.ToolResult }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.'); }); @@ -507,7 +508,7 @@ describe('runNonInteractive', () => { processedParts, expect.any(AbortSignal), 'prompt-id-7', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); // 6. Assert the final output is correct @@ -539,7 +540,7 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); // JSON adapter emits array of messages, last one is result with stats @@ -694,7 +695,7 @@ describe('runNonInteractive', () => { [{ text: 'Empty response test' }], expect.any(AbortSignal), 'prompt-id-empty', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); // JSON adapter emits array of messages, last one is result with stats @@ -881,7 +882,7 @@ describe('runNonInteractive', () => { [{ text: 'Prompt from command' }], expect.any(AbortSignal), 'prompt-id-slash', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Response from command'); @@ -941,7 +942,7 @@ describe('runNonInteractive', () => { [{ text: '/unknowncommand' }], expect.any(AbortSignal), 'prompt-id-unknown', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown'); @@ -1299,7 +1300,7 @@ describe('runNonInteractive', () => { [{ text: 'Message from stream-json input' }], expect.any(AbortSignal), 'prompt-envelope', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); }); @@ -1775,7 +1776,7 @@ describe('runNonInteractive', () => { [{ text: 'Simple string content' }], expect.any(AbortSignal), 'prompt-string-content', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); // UserMessage with array of text blocks @@ -1808,7 +1809,7 @@ describe('runNonInteractive', () => { [{ text: 'First part' }, { text: 'Second part' }], expect.any(AbortSignal), 'prompt-blocks-content', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, ); }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 129bec380..e4c22cebb 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -19,6 +19,7 @@ import { uiTelemetryService, parseAndFormatApiError, createDebugLogger, + SendMessageType, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -265,7 +266,11 @@ export async function runNonInteractive( currentMessages[0]?.parts || [], abortController.signal, prompt_id, - { isContinuation: !isFirstTurn }, + { + type: isFirstTurn + ? SendMessageType.UserQuery + : SendMessageType.ToolResult, + }, ); isFirstTurn = false; diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index a26f4dbca..b089fa6c2 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -14,6 +14,7 @@ import { } from '@qwen-code/qwen-code-core'; import { CommandService } from './services/CommandService.js'; import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; +import { BundledSkillLoader } from './services/BundledSkillLoader.js'; import { FileCommandLoader } from './services/FileCommandLoader.js'; import { CommandKind, @@ -197,7 +198,7 @@ function filterCommandsForNonInteractive( allowedBuiltinCommandNames: Set, ): SlashCommand[] { return commands.filter((cmd) => { - if (cmd.kind === CommandKind.FILE) { + if (cmd.kind === CommandKind.FILE || cmd.kind === CommandKind.SKILL) { return true; } @@ -252,6 +253,7 @@ export const handleSlashCommand = async ( // Load all commands to check if the command exists but is not allowed const allLoaders = [ new BuiltinCommandLoader(config), + new BundledSkillLoader(config), new FileCommandLoader(config), ]; @@ -366,8 +368,12 @@ export const getAvailableCommands = async ( // Only load BuiltinCommandLoader if there are allowed built-in commands const loaders = allowedBuiltinSet.size > 0 - ? [new BuiltinCommandLoader(config), new FileCommandLoader(config)] - : [new FileCommandLoader(config)]; + ? [ + new BuiltinCommandLoader(config), + new BundledSkillLoader(config), + new FileCommandLoader(config), + ] + : [new BundledSkillLoader(config), new FileCommandLoader(config)]; const commandService = await CommandService.create(loaders, abortSignal); const commands = commandService.getCommands(); diff --git a/packages/cli/src/services/BundledSkillLoader.test.ts b/packages/cli/src/services/BundledSkillLoader.test.ts new file mode 100644 index 000000000..a3c687a27 --- /dev/null +++ b/packages/cli/src/services/BundledSkillLoader.test.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BundledSkillLoader } from './BundledSkillLoader.js'; +import { CommandKind } from '../ui/commands/types.js'; +import type { Config, SkillConfig } from '@qwen-code/qwen-code-core'; + +function makeSkill(overrides: Partial = {}): SkillConfig { + return { + name: 'review', + description: 'Review code changes', + level: 'bundled', + filePath: '/bundled/review/SKILL.md', + body: 'You are an expert code reviewer.', + ...overrides, + }; +} + +describe('BundledSkillLoader', () => { + let mockConfig: Config; + let mockSkillManager: { + listSkills: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockSkillManager = { + listSkills: vi.fn().mockResolvedValue([]), + }; + mockConfig = { + getSkillManager: vi.fn().mockReturnValue(mockSkillManager), + } as unknown as Config; + }); + + const signal = new AbortController().signal; + + it('should return empty array when config is null', async () => { + const loader = new BundledSkillLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toEqual([]); + }); + + it('should return empty array when SkillManager is not available', async () => { + const config = { + getSkillManager: vi.fn().mockReturnValue(null), + } as unknown as Config; + const loader = new BundledSkillLoader(config); + const commands = await loader.loadCommands(signal); + expect(commands).toEqual([]); + }); + + it('should load bundled skills as slash commands', async () => { + const skill = makeSkill(); + mockSkillManager.listSkills.mockResolvedValue([skill]); + + const loader = new BundledSkillLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('review'); + expect(commands[0].description).toBe('Review code changes'); + expect(commands[0].kind).toBe(CommandKind.SKILL); + expect(mockSkillManager.listSkills).toHaveBeenCalledWith({ + level: 'bundled', + }); + }); + + it('should submit skill body as prompt without args', async () => { + const skill = makeSkill(); + mockSkillManager.listSkills.mockResolvedValue([skill]); + + const loader = new BundledSkillLoader(mockConfig); + const commands = await loader.loadCommands(signal); + const result = await commands[0].action!( + { invocation: { raw: '/review', args: '' } } as never, + '', + ); + + expect(result).toEqual({ + type: 'submit_prompt', + content: [{ text: 'You are an expert code reviewer.' }], + }); + }); + + it('should append raw invocation when args are provided', async () => { + const skill = makeSkill(); + mockSkillManager.listSkills.mockResolvedValue([skill]); + + const loader = new BundledSkillLoader(mockConfig); + const commands = await loader.loadCommands(signal); + const result = await commands[0].action!( + { invocation: { raw: '/review 123', args: '123' } } as never, + '123', + ); + + expect(result).toEqual({ + type: 'submit_prompt', + content: [{ text: 'You are an expert code reviewer.\n\n/review 123' }], + }); + }); + + it('should return empty array when listSkills throws', async () => { + mockSkillManager.listSkills.mockRejectedValue(new Error('load failed')); + + const loader = new BundledSkillLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toEqual([]); + }); + + it('should load multiple bundled skills', async () => { + const skills = [ + makeSkill({ name: 'review', description: 'Review code' }), + makeSkill({ name: 'deploy', description: 'Deploy app' }), + ]; + mockSkillManager.listSkills.mockResolvedValue(skills); + + const loader = new BundledSkillLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(2); + expect(commands.map((c) => c.name)).toEqual(['review', 'deploy']); + }); +}); diff --git a/packages/cli/src/services/BundledSkillLoader.ts b/packages/cli/src/services/BundledSkillLoader.ts new file mode 100644 index 000000000..609ddf90e --- /dev/null +++ b/packages/cli/src/services/BundledSkillLoader.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + appendToLastTextPart, +} from '@qwen-code/qwen-code-core'; +import type { ICommandLoader } from './types.js'; +import type { + SlashCommand, + SlashCommandActionReturn, +} from '../ui/commands/types.js'; +import { CommandKind } from '../ui/commands/types.js'; + +const debugLogger = createDebugLogger('BUNDLED_SKILL_LOADER'); + +/** + * Loads bundled skills as slash commands, making them directly invocable + * via / (e.g., /review). + */ +export class BundledSkillLoader implements ICommandLoader { + constructor(private readonly config: Config | null) {} + + async loadCommands(_signal: AbortSignal): Promise { + const skillManager = this.config?.getSkillManager(); + if (!skillManager) { + debugLogger.debug('SkillManager not available, skipping bundled skills'); + return []; + } + + try { + const skills = await skillManager.listSkills({ level: 'bundled' }); + debugLogger.debug( + `Loaded ${skills.length} bundled skill(s) as slash commands`, + ); + + return skills.map((skill) => ({ + name: skill.name, + description: skill.description, + kind: CommandKind.SKILL, + action: async (context, _args): Promise => { + const content = context.invocation?.args + ? appendToLastTextPart( + [{ text: skill.body }], + context.invocation.raw, + ) + : [{ text: skill.body }]; + + return { + type: 'submit_prompt', + content, + }; + }, + })); + } catch (error) { + debugLogger.error('Failed to load bundled skills:', error); + return []; + } + } +} diff --git a/packages/cli/src/services/insight/generators/DataProcessor.test.ts b/packages/cli/src/services/insight/generators/DataProcessor.test.ts index 1f90dbff5..4b78cf1bb 100644 --- a/packages/cli/src/services/insight/generators/DataProcessor.test.ts +++ b/packages/cli/src/services/insight/generators/DataProcessor.test.ts @@ -24,6 +24,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => { info: vi.fn(), error: vi.fn(), warn: vi.fn(), + debug: vi.fn(), })), }; }); @@ -1137,6 +1138,102 @@ describe('DataProcessor', () => { }); }); + describe('generateQualitativeInsights', () => { + const mockMetrics = { + totalSessions: 5, + totalMessages: 50, + totalHours: 2, + heatmap: { '2025-01-15': 3 }, + topTools: [['read_file', 10]] as Array<[string, number]>, + activeDays: 1, + activeHours: { '10': 5 }, + totalLinesAdded: 100, + totalLinesRemoved: 50, + totalFiles: 10, + streak: { currentStreak: 1, longestStreak: 1, dates: [] }, + } as unknown as Omit; + + const mockFacets: SessionFacets[] = [ + { + session_id: 'test-1', + underlying_goal: 'Fix bug', + goal_categories: { debugging: 1 }, + outcome: 'fully_achieved', + user_satisfaction_counts: { satisfied: 1 }, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'correct_code_edits', + brief_summary: 'Fixed a bug', + }, + ]; + + it('should return partial qualitative data when some LLM calls fail', async () => { + let callIndex = 0; + mockGenerateJson.mockImplementation(() => { + callIndex++; + if (callIndex % 2 === 0) { + return Promise.reject(new Error('LLM timeout')); + } + return Promise.resolve({ intro: 'test', areas: [], opportunities: [] }); + }); + + const result = await ( + dataProcessor as unknown as { + generateQualitativeInsights( + metrics: Omit, + facets: SessionFacets[], + ): Promise< + | import('../types/QualitativeInsightTypes.js').QualitativeInsights + | undefined + >; + } + ).generateQualitativeInsights(mockMetrics, mockFacets); + + expect(result).toBeDefined(); + expect(result!.impressiveWorkflows).toBeDefined(); + expect(result!.projectAreas).toBeUndefined(); + expect(result!.futureOpportunities).toBeDefined(); + expect(result!.frictionPoints).toBeUndefined(); + }); + + it('should return undefined when facets are empty', async () => { + const result = await ( + dataProcessor as unknown as { + generateQualitativeInsights( + metrics: Omit, + facets: SessionFacets[], + ): Promise< + | import('../types/QualitativeInsightTypes.js').QualitativeInsights + | undefined + >; + } + ).generateQualitativeInsights(mockMetrics, []); + + expect(result).toBeUndefined(); + }); + + it('should return full qualitative data when all LLM calls succeed', async () => { + mockGenerateJson.mockResolvedValue({ intro: 'test', areas: [] }); + + const result = await ( + dataProcessor as unknown as { + generateQualitativeInsights( + metrics: Omit, + facets: SessionFacets[], + ): Promise< + | import('../types/QualitativeInsightTypes.js').QualitativeInsights + | undefined + >; + } + ).generateQualitativeInsights(mockMetrics, mockFacets); + + expect(result).toBeDefined(); + expect(mockGenerateJson).toHaveBeenCalledTimes(8); + }); + }); + describe('generateFacets', () => { it('should skip non-conversational sessions', async () => { const userOnlyRecords: ChatRecord[] = [ diff --git a/packages/cli/src/services/insight/generators/DataProcessor.ts b/packages/cli/src/services/insight/generators/DataProcessor.ts index a3cda424e..c77e28a49 100644 --- a/packages/cli/src/services/insight/generators/DataProcessor.ts +++ b/packages/cli/src/services/insight/generators/DataProcessor.ts @@ -388,7 +388,7 @@ export class DataProcessor { const generate = async ( promptTemplate: string, schema: Record, - ): Promise => { + ): Promise => { const prompt = `${promptTemplate}\n\n${commonData}`; try { const result = await this.config.getBaseLlmClient().generateJson({ @@ -400,7 +400,7 @@ export class DataProcessor { return result as T; } catch (error) { logger.error('Failed to generate insight:', error); - throw error; + return undefined; } }; diff --git a/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts b/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts index fc9546b98..aa9bea169 100644 --- a/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts +++ b/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts @@ -71,12 +71,12 @@ export interface InsightAtAGlance { } export interface QualitativeInsights { - impressiveWorkflows: InsightImpressiveWorkflows; - projectAreas: InsightProjectAreas; - futureOpportunities: InsightFutureOpportunities; - frictionPoints: InsightFrictionPoints; - memorableMoment: InsightMemorableMoment; - improvements: InsightImprovements; - interactionStyle: InsightInteractionStyle; - atAGlance: InsightAtAGlance; + impressiveWorkflows?: InsightImpressiveWorkflows; + projectAreas?: InsightProjectAreas; + futureOpportunities?: InsightFutureOpportunities; + frictionPoints?: InsightFrictionPoints; + memorableMoment?: InsightMemorableMoment; + improvements?: InsightImprovements; + interactionStyle?: InsightInteractionStyle; + atAGlance?: InsightAtAGlance; } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7acc11b15..c6bfa67c3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -102,6 +102,7 @@ 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 { @@ -494,6 +495,11 @@ export const AppContainer = (props: AppContainerProps) => { openAgentsManagerDialog, closeAgentsManagerDialog, } = useAgentsManagerDialog(); + const { + isExtensionsManagerDialogOpen, + openExtensionsManagerDialog, + closeExtensionsManagerDialog, + } = useExtensionsManagerDialog(); const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog(); const slashCommandActions = useMemo( @@ -517,6 +523,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openExtensionsManagerDialog, openMcpDialog, openResumeDialog, }), @@ -533,6 +540,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openExtensionsManagerDialog, openMcpDialog, openResumeDialog, ], @@ -1305,7 +1313,8 @@ export const AppContainer = (props: AppContainerProps) => { isAgentsManagerDialogOpen || isMcpDialogOpen || isApprovalModeDialogOpen || - isResumeDialogOpen; + isResumeDialogOpen || + isExtensionsManagerDialogOpen; const { isFeedbackDialogOpen, @@ -1415,6 +1424,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // Extensions manager dialog + isExtensionsManagerDialogOpen, // MCP dialog isMcpDialogOpen, // Feedback dialog @@ -1507,6 +1518,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // Extensions manager dialog + isExtensionsManagerDialogOpen, // MCP dialog isMcpDialogOpen, // Feedback dialog @@ -1550,6 +1563,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Extensions manager dialog + closeExtensionsManagerDialog, // MCP dialog closeMcpDialog, // Resume session dialog @@ -1595,6 +1610,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Extensions manager dialog + closeExtensionsManagerDialog, // MCP dialog closeMcpDialog, // Resume session dialog diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 309e77adf..4469a0759 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -345,7 +345,7 @@ export function AuthDialog(): React.JSX.Element { return ( ({ - loadLastSession: vi.fn(), + loadSession: vi.fn(), })); vi.mock('@qwen-code/qwen-code-core', () => { class SessionService { constructor(_cwd: string) {} - async loadLastSession() { - return mockSessionServiceMocks.loadLastSession(); + async loadSession(_sessionId: string) { + return mockSessionServiceMocks.loadSession(); } } @@ -68,13 +68,14 @@ describe('exportCommand', () => { beforeEach(() => { vi.clearAllMocks(); - mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData); + mockSessionServiceMocks.loadSession.mockResolvedValue(mockSessionData); mockContext = createMockCommandContext({ services: { config: { getWorkingDir: vi.fn().mockReturnValue('/test/dir'), getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getSessionId: vi.fn().mockReturnValue('test-session-id'), }, }, }); @@ -132,7 +133,7 @@ describe('exportCommand', () => { content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), }); - expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled(); expect(collectSessionData).toHaveBeenCalledWith( mockSessionData.conversation, expect.anything(), @@ -191,7 +192,7 @@ describe('exportCommand', () => { }); it('should return error when no session is found', async () => { - mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + mockSessionServiceMocks.loadSession.mockResolvedValue(undefined); const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); if (!mdCommand?.action) { @@ -260,7 +261,7 @@ describe('exportCommand', () => { ), }); - expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled(); expect(collectSessionData).toHaveBeenCalledWith( mockSessionData.conversation, expect.anything(), @@ -323,7 +324,7 @@ describe('exportCommand', () => { }); it('should return error when no session is found', async () => { - mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + mockSessionServiceMocks.loadSession.mockResolvedValue(undefined); const htmlCommand = exportCommand.subCommands?.find( (c) => c.name === 'html', diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index 42af225ac..755a7061e 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -22,6 +22,7 @@ import { toJsonl, generateExportFilename, } from '../utils/export/index.js'; +import { t } from '../../i18n/index.js'; /** * Action for the 'md' subcommand - exports session to markdown. @@ -50,9 +51,10 @@ async function exportMarkdownAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -122,9 +124,10 @@ async function exportHtmlAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -194,9 +197,10 @@ async function exportJsonAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -266,9 +270,10 @@ async function exportJsonlAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -316,30 +321,40 @@ async function exportJsonlAction( */ export const exportCommand: SlashCommand = { name: 'export', - description: 'Export current session message history to a file', + get description() { + return t('Export current session message history to a file'); + }, kind: CommandKind.BUILT_IN, subCommands: [ { name: 'html', - description: 'Export session to HTML format', + get description() { + return t('Export session to HTML format'); + }, kind: CommandKind.BUILT_IN, action: exportHtmlAction, }, { name: 'md', - description: 'Export session to markdown format', + get description() { + return t('Export session to markdown format'); + }, kind: CommandKind.BUILT_IN, action: exportMarkdownAction, }, { name: 'json', - description: 'Export session to JSON format', + get description() { + return t('Export session to JSON format'); + }, kind: CommandKind.BUILT_IN, action: exportJsonAction, }, { name: 'jsonl', - description: 'Export session to JSONL format (one message per line)', + get description() { + return t('Export session to JSONL format (one message per line)'); + }, kind: CommandKind.BUILT_IN, action: exportJsonlAction, }, 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/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 2a5100577..4ca165e35 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -4,186 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - SlashCommand, - CommandContext, - MessageActionReturn, - OpenDialogActionReturn, -} from './types.js'; +import type { SlashCommand, OpenDialogActionReturn } from './types.js'; import { CommandKind } from './types.js'; -import { - getErrorMessage, - MCPOAuthTokenStorage, - MCPOAuthProvider, -} from '@qwen-code/qwen-code-core'; -import { appEvents, AppEvent } from '../../utils/events.js'; import { t } from '../../i18n/index.js'; -const authCommand: SlashCommand = { - name: 'auth', - get description() { - return t('Authenticate with an OAuth-enabled MCP server'); - }, - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - const serverName = args.trim(); - const { config } = context.services; - - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - const mcpServers = config.getMcpServers() || {}; - - if (!serverName) { - // List servers that support OAuth - const oauthServers = Object.entries(mcpServers) - .filter(([_, server]) => server.oauth?.enabled) - .map(([name, _]) => name); - - if (oauthServers.length === 0) { - return { - type: 'message', - messageType: 'info', - content: t('No MCP servers configured with OAuth authentication.'), - }; - } - - return { - type: 'message', - messageType: 'info', - content: `${t('MCP servers with OAuth authentication:')}\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\n${t('Use /mcp auth to authenticate.')}`, - }; - } - - const server = mcpServers[serverName]; - if (!server) { - return { - type: 'message', - messageType: 'error', - content: t("MCP server '{{name}}' not found.", { name: serverName }), - }; - } - - // Always attempt OAuth authentication, even if not explicitly configured - // The authentication process will discover OAuth requirements automatically - - const displayListener = (message: string) => { - context.ui.addItem({ type: 'info', text: message }, Date.now()); - }; - - appEvents.on(AppEvent.OauthDisplayMessage, displayListener); - - try { - context.ui.addItem( - { - type: 'info', - text: t( - "Starting OAuth authentication for MCP server '{{name}}'...", - { - name: serverName, - }, - ), - }, - Date.now(), - ); - - let oauthConfig = server.oauth; - if (!oauthConfig) { - oauthConfig = { enabled: false }; - } - - const mcpServerUrl = server.httpUrl || server.url; - const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); - await authProvider.authenticate( - serverName, - oauthConfig, - mcpServerUrl, - appEvents, - ); - - context.ui.addItem( - { - type: 'info', - text: t( - "Successfully authenticated and refreshed tools for '{{name}}'.", - { - name: serverName, - }, - ), - }, - Date.now(), - ); - - // Trigger tool re-discovery to pick up authenticated server - const toolRegistry = config.getToolRegistry(); - if (toolRegistry) { - context.ui.addItem( - { - type: 'info', - text: t("Re-discovering tools from '{{name}}'...", { - name: serverName, - }), - }, - Date.now(), - ); - await toolRegistry.discoverToolsForServer(serverName); - } - // 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 { - type: 'message', - messageType: 'info', - content: t( - "Successfully authenticated and refreshed tools for '{{name}}'.", - { - name: serverName, - }, - ), - }; - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: t( - "Failed to authenticate with MCP server '{{name}}': {{error}}", - { - name: serverName, - error: getErrorMessage(error), - }, - ), - }; - } finally { - appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); - } - }, - completion: async (context: CommandContext, partialArg: string) => { - const { config } = context.services; - if (!config) return []; - - const mcpServers = config.getMcpServers() || {}; - return Object.keys(mcpServers).filter((name) => - name.startsWith(partialArg), - ); - }, -}; - -const manageCommand: SlashCommand = { - name: 'manage', +export const mcpCommand: SlashCommand = { + name: 'mcp', get description() { return t('Open MCP management dialog'); }, @@ -193,19 +19,3 @@ const manageCommand: SlashCommand = { dialog: 'mcp', }), }; - -export const mcpCommand: SlashCommand = { - name: 'mcp', - get description() { - return t( - 'Open MCP management dialog, or authenticate with OAuth-enabled servers', - ); - }, - kind: CommandKind.BUILT_IN, - 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/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index fce633275..72d83c5aa 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -13,6 +13,7 @@ import { CommandKind, } from './types.js'; import type { Config } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; async function restoreAction( context: CommandContext, @@ -144,8 +145,11 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => { return { name: 'restore', - description: - 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', + get description() { + return t( + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', + ); + }, kind: CommandKind.BUILT_IN, action: restoreAction, completion, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index b84f38b13..76eda2c07 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -149,6 +149,7 @@ export interface OpenDialogActionReturn { | 'permissions' | 'approval-mode' | 'resume' + | 'extensions_manage' | 'mcp'; } @@ -210,6 +211,7 @@ export enum CommandKind { BUILT_IN = 'built-in', FILE = 'file', MCP_PROMPT = 'mcp-prompt', + SKILL = 'skill', } export interface CommandCompletionItem { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 73983c812..193549245 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -103,7 +103,9 @@ export const Composer = () => { )} {/* Exclusive area: only one component visible at a time */} + {/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */} {!showSuggestions && + uiState.streamingState !== StreamingState.WaitingForConfirmation && (showShortcuts ? ( ) : ( diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 764d88950..26390e270 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -34,6 +34,7 @@ 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'; @@ -293,6 +294,14 @@ export const DialogManager = ({ ); } + if (uiState.isExtensionsManagerDialogOpen) { + return ( + + ); + } if (uiState.isMcpDialogOpen) { return ; } diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 99bb053da..72da62aba 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -78,7 +78,7 @@ describe('
', () => { it('renders with border around info panel', () => { const { lastFrame } = render(
); - expect(lastFrame()).toContain('╭'); - expect(lastFrame()).toContain('╯'); + expect(lastFrame()).toContain('┌'); + expect(lastFrame()).toContain('┐'); }); }); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 45fce4385..2d919385f 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -128,7 +128,7 @@ export const Header: React.FC = ({ {/* Right side: Info panel (flexible width, max 60 in two-column layout) */} { }); describe('command search (Ctrl+R when not in shell)', () => { + it('passes newest-first user history to command search', async () => { + props.shellModeActive = false; + props.userMessages = ['oldest', 'middle', 'newest']; + + const { unmount } = renderWithProviders(); + await wait(); + + const commandSearchCall = + mockedUseReverseSearchCompletion.mock.calls.find( + ([, history]) => + Array.isArray(history) && + history.length === 3 && + history.includes('newest'), + ); + + expect(commandSearchCall?.[1]).toEqual(['newest', 'middle', 'oldest']); + unmount(); + }); + it('enters command search on Ctrl+R and shows suggestions', async () => { props.shellModeActive = false; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 42ec7efbb..8b6ff4084 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useCallback, useEffect, useState, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Box, Text } from 'ink'; import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js'; import { theme } from '../semantic-colors.js'; @@ -213,9 +213,14 @@ export const InputPrompt: React.FC = ({ reverseSearchActive, ); + const commandSearchHistory = useMemo( + () => [...userMessages].reverse(), + [userMessages], + ); + const commandSearchCompletion = useReverseSearchCompletion( buffer, - userMessages, + commandSearchHistory, commandSearchActive, ); diff --git a/packages/cli/src/ui/components/PlanSummaryDisplay.tsx b/packages/cli/src/ui/components/PlanSummaryDisplay.tsx index c827b9d86..a856bcdc4 100644 --- a/packages/cli/src/ui/components/PlanSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/PlanSummaryDisplay.tsx @@ -21,12 +21,13 @@ export const PlanSummaryDisplay: React.FC = ({ availableHeight, childWidth, }) => { - const { message, plan } = data; + const { message, plan, rejected } = data; + const messageColor = rejected ? Colors.AccentYellow : Colors.AccentGreen; return ( - + {message} diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx index 29eb3712a..7499f7cea 100644 --- a/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx +++ b/packages/cli/src/ui/components/QwenOAuthProgress.test.tsx @@ -17,18 +17,6 @@ vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); -// Mock qrcode-terminal module -vi.mock('qrcode-terminal', () => ({ - default: { - generate: vi.fn(), - }, -})); - -// Mock ink-spinner -vi.mock('ink-spinner', () => ({ - default: ({ type }: { type: string }) => `MockSpinner(${type})`, -})); - // Mock ink-link vi.mock('ink-link', () => ({ default: ({ children }: { children: React.ReactNode; url: string }) => @@ -95,19 +83,17 @@ describe('QwenOAuthProgress', () => { const { lastFrame } = renderComponent(); const output = lastFrame(); - expect(output).toContain('MockSpinner(dots)'); expect(output).toContain('Waiting for Qwen OAuth authentication...'); - expect(output).toContain('(Press ESC or CTRL+C to cancel)'); + expect(output).toContain('Esc to cancel'); }); - it('should render loading state with gray border', () => { + it('should render loading state with single border', () => { const { lastFrame } = renderComponent(); const output = lastFrame(); - // Should not contain auth flow elements - expect(output).not.toContain('Qwen OAuth Authentication'); - expect(output).not.toContain('Please visit this URL to authorize:'); - // Loading state still shows time remaining with default timeout + // Should contain the auth title even in loading state + expect(output).toContain('Qwen OAuth Authentication'); + // Loading state shows time remaining with default timeout expect(output).toContain('Time remaining:'); }); }); @@ -117,44 +103,20 @@ describe('QwenOAuthProgress', () => { const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth }); const output = lastFrame(); - // Initially no QR code shown until it's generated, but the status area should be visible - expect(output).toContain('MockSpinner(dots)'); expect(output).toContain('Waiting for authorization'); expect(output).toContain('Time remaining: 5:00'); - expect(output).toContain('(Press ESC or CTRL+C to cancel)'); + expect(output).toContain('Esc to cancel'); }); - it('should display correct URL in Static component when QR code is generated', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let qrCallback: any = null; - mockGenerate.mockImplementation((url, options, callback) => { - qrCallback = callback; - }); - + it('should display correct URL in auth URL display', () => { const customAuth = createMockDeviceAuth({ verification_uri_complete: 'https://custom.com/auth?code=XYZ789', }); - const { lastFrame, rerender } = renderComponent({ + const { lastFrame } = renderComponent({ deviceAuth: customAuth, }); - // Manually trigger the QR code callback - if (qrCallback && typeof qrCallback === 'function') { - qrCallback('Mock QR Code Data'); - } - - rerender( - , - ); - expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789'); }); @@ -282,10 +244,11 @@ describe('QwenOAuthProgress', () => { />, ); - // Initial state should have no dots - expect(lastFrame()).toContain('Waiting for authorization'); + // Initial state should show '...' (default value) + const initialOutput = lastFrame(); + expect(initialOutput).toContain('Waiting for authorization'); - // Advance by 500ms to add first dot + // Advance by 500ms to cycle animation vi.advanceTimersByTime(500); rerender( { deviceAuth={mockDeviceAuth} />, ); - expect(lastFrame()).toContain('Waiting for authorization.'); + const after500ms = lastFrame(); + expect(after500ms).toContain('Waiting for authorization'); - // Advance by another 500ms to add second dot + // Advance by another 500ms to continue animation vi.advanceTimersByTime(500); rerender( { deviceAuth={mockDeviceAuth} />, ); - expect(lastFrame()).toContain('Waiting for authorization..'); + const after1000ms = lastFrame(); + expect(after1000ms).toContain('Waiting for authorization'); - // Advance by another 500ms to add third dot + // Advance by another 500ms to complete cycle vi.advanceTimersByTime(500); rerender( { deviceAuth={mockDeviceAuth} />, ); - expect(lastFrame()).toContain('Waiting for authorization...'); - - // Advance by another 500ms to reset dots - vi.advanceTimersByTime(500); - rerender( - , - ); - expect(lastFrame()).toContain('Waiting for authorization'); - }); - }); - - describe('QR Code functionality', () => { - it('should generate QR code when deviceAuth is provided', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - - mockGenerate.mockImplementation((url, options, callback) => { - callback!('Mock QR Code Data'); - }); - - render( - , - ); - - expect(mockGenerate).toHaveBeenCalledWith( - mockDeviceAuth.verification_uri_complete, - { small: true }, - expect.any(Function), - ); - }); - - it('should display QR code in Static component when available', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let qrCallback: any = null; - mockGenerate.mockImplementation((url, options, callback) => { - qrCallback = callback; - }); - - const { lastFrame, rerender } = render( - , - ); - - // Manually trigger the QR code callback - if (qrCallback && typeof qrCallback === 'function') { - qrCallback('Mock QR Code Data'); - } - - rerender( - , - ); - - const output = lastFrame(); - expect(output).toContain('Or scan the QR code below:'); - expect(output).toContain('Mock QR Code Data'); - }); - - it('should handle QR code generation errors gracefully', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - mockGenerate.mockImplementation(() => { - throw new Error('QR Code generation failed'); - }); - - const { lastFrame } = render( - , - ); - - // Should not crash and should not show QR code section since QR generation failed - const output = lastFrame(); - expect(output).not.toContain('Or scan the QR code below:'); - }); - - it('should not generate QR code when deviceAuth is null', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - - render( - , - ); - - expect(mockGenerate).not.toHaveBeenCalled(); + const after1500ms = lastFrame(); + expect(after1500ms).toContain('Waiting for authorization'); }); }); diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.tsx index 69d42818d..7655e7915 100644 --- a/packages/cli/src/ui/components/QwenOAuthProgress.tsx +++ b/packages/cli/src/ui/components/QwenOAuthProgress.tsx @@ -5,14 +5,11 @@ */ import type React from 'react'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; -import Spinner from 'ink-spinner'; import Link from 'ink-link'; -import qrcode from 'qrcode-terminal'; -import { Colors } from '../colors.js'; +import { theme } from '../semantic-colors.js'; import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; @@ -30,98 +27,10 @@ interface QwenOAuthProgressProps { authMessage?: string | null; } -const debugLogger = createDebugLogger('QWEN_OAUTH_PROGRESS'); - -/** - * Static QR Code Display Component - * Renders the QR code and URL once and doesn't re-render unless the URL changes - */ -function QrCodeDisplay({ - verificationUrl, - qrCodeData, -}: { - verificationUrl: string; - qrCodeData: string | null; -}): React.JSX.Element | null { - if (!qrCodeData) { - return null; - } - - return ( - - - {t('Qwen OAuth Authentication')} - - - - {t('Please visit this URL to authorize:')} - - - - - {verificationUrl} - - - - - {t('Or scan the QR code below:')} - - - - {qrCodeData} - - - ); -} - -/** - * Dynamic Status Display Component - * Shows the loading spinner, timer, and status messages - */ -function StatusDisplay({ - timeRemaining, - dots, -}: { - timeRemaining: number; - dots: string; -}): React.JSX.Element { - const formatTime = (seconds: number): string => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; - }; - - return ( - - - - {t('Waiting for authorization')} - {dots} - - - - - - {t('Time remaining:')} {formatTime(timeRemaining)} - - - {t('(Press ESC or CTRL+C to cancel)')} - - - - ); +function formatTime(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; } export function QwenOAuthProgress({ @@ -133,13 +42,11 @@ export function QwenOAuthProgress({ }: QwenOAuthProgressProps): React.JSX.Element { const defaultTimeout = deviceAuth?.expires_in || 300; // Default 5 minutes const [timeRemaining, setTimeRemaining] = useState(defaultTimeout); - const [dots, setDots] = useState(''); - const [qrCodeData, setQrCodeData] = useState(null); + const [dots, setDots] = useState('...'); useKeypress( (key) => { if (authStatus === 'timeout' || authStatus === 'error') { - // Any key press in timeout or error state should trigger cancel to return to auth dialog onCancel(); } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { onCancel(); @@ -148,30 +55,6 @@ export function QwenOAuthProgress({ { isActive: true }, ); - // Generate QR code once when device auth is available - useEffect(() => { - if (!deviceAuth?.verification_uri_complete) { - return; - } - - const generateQR = () => { - try { - qrcode.generate( - deviceAuth.verification_uri_complete, - { small: true }, - (qrcode: string) => { - setQrCodeData(qrcode); - }, - ); - } catch (error) { - debugLogger.error('Failed to generate QR code:', error); - setQrCodeData(null); - } - }; - - generateQR(); - }, [deviceAuth?.verification_uri_complete]); - // Countdown timer useEffect(() => { const timer = setInterval(() => { @@ -187,41 +70,29 @@ export function QwenOAuthProgress({ return () => clearInterval(timer); }, [onTimeout]); - // Animated dots + // Animated dots — cycle through fixed-width patterns to avoid layout shift useEffect(() => { + const dotFrames = ['. ', '.. ', '...']; + let frameIndex = 0; const dotsTimer = setInterval(() => { - setDots((prev) => { - if (prev.length >= 3) return ''; - return prev + '.'; - }); + frameIndex = (frameIndex + 1) % dotFrames.length; + setDots(dotFrames[frameIndex]!); }, 500); return () => clearInterval(dotsTimer); }, []); - // Memoize the QR code display to prevent unnecessary re-renders - const qrCodeDisplay = useMemo(() => { - if (!deviceAuth?.verification_uri_complete) return null; - - return ( - - ); - }, [deviceAuth?.verification_uri_complete, qrCodeData]); - // Handle timeout state if (authStatus === 'timeout') { return ( - + {t('Qwen OAuth Authentication Timeout')} @@ -238,7 +109,7 @@ export function QwenOAuthProgress({ - + {t('Press any key to return to authentication type selection.')} @@ -249,26 +120,26 @@ export function QwenOAuthProgress({ if (authStatus === 'error') { return ( - - Qwen OAuth Authentication Error + + {t('Qwen OAuth Authentication Error')} {authMessage || - 'An error occurred during authentication. Please try again.'} + t('An error occurred during authentication. Please try again.')} - - Press any key to return to authentication type selection. + + {t('Press any key to return to authentication type selection.')} @@ -279,38 +150,61 @@ export function QwenOAuthProgress({ if (!deviceAuth) { return ( - + {t('Qwen OAuth Authentication')} + + + {t('Waiting for Qwen OAuth authentication...')} - - {t('Waiting for Qwen OAuth authentication...')} + {t('Time remaining:')} {formatTime(timeRemaining)} - - - {t('Time remaining:')} {Math.floor(timeRemaining / 60)}: - {(timeRemaining % 60).toString().padStart(2, '0')} - - - {t('(Press ESC or CTRL+C to cancel)')} - + + + {t('Esc to cancel')} ); } return ( - - {/* Static QR Code Display */} - {qrCodeDisplay} + + {t('Qwen OAuth Authentication')} - {/* Dynamic Status Display */} - + + {t('Please visit this URL to authorize:')} + + + + + {deviceAuth.verification_uri_complete} + + + + + + {t('Waiting for authorization')} + {dots} + + + {t('Time remaining:')} {formatTime(timeRemaining)} + + + + + {t('Esc to cancel')} + ); } diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index ff1f95d7f..a22869f78 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -9,6 +9,7 @@ import type React from 'react'; import { useKeypress } from '../hooks/useKeypress.js'; import { ShellExecutionService } from '@qwen-code/qwen-code-core'; import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; export interface ShellInputPromptProps { activeShellPtyId: number | null; @@ -33,6 +34,11 @@ export const ShellInputPrompt: React.FC = ({ if (!focus || !activeShellPtyId) { return; } + // Don't forward Ctrl+F to the PTY — it's used to toggle shell focus. + // Without this, the raw ^F control character gets written to the shell. + if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) { + return; + } if (key.ctrl && key.shift && key.name === 'up') { ShellExecutionService.scrollPty(activeShellPtyId, -1); return; 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..c06f9c85c --- /dev/null +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -0,0 +1,527 @@ +/** + * @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'; +import { useTerminalSize } from '../../hooks/useTerminalSize.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); + const { columns } = useTerminalSize(); + const boxWidth = columns - 4; + + // 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 || successMessage) { + return t('Esc to close'); + } + return t('↑↓ to navigate · Enter to select · 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('↑↓ to navigate · Enter to select · Esc to go back'); + }; + + return ( + + {getNavigationInstructions()} + + ); + }, [getCurrentStep, extensions.length, updateInProgress, successMessage]); + + 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..e1aeaa43b --- /dev/null +++ b/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap @@ -0,0 +1,45 @@ +// 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..0aa566489 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx @@ -0,0 +1,102 @@ +/** + * @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; + onActionSelect: (action: ExtensionAction) => void; +} + +export const ActionSelectionStep = ({ + selectedExtension, + hasUpdateAvailable, + 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, + }, + ]; + return allActions; + }, [hasUpdateAvailable, isActive]); + + const handleActionSelect = (value: ExtensionAction) => { + 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..63a73ddb5 --- /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 ( + + + + {t('{{count}} extensions installed', { + count: extensions.length.toString(), + })} + + + + {extensions.map((extension, index) => + renderExtensionItem(extension, index, index === selectedIndex), + )} + + + ); +}; 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..b69ab9a7d --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx @@ -0,0 +1,70 @@ +/** + * @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; +} + +export function ScopeSelectStep({ + selectedExtension, + mode, + onScopeSelect, +}: 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, + }, + ]; + + const handleSelect = (value: 'user' | 'workspace') => { + 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..90f8bf1a9 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx @@ -0,0 +1,65 @@ +/** + * @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.')} + + + ); +} 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..a872a8859 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap @@ -0,0 +1,33 @@ +// 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" +`; + +exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = ` +"● View Details + Enable Extension + Uninstall Extension" +`; + +exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = ` +"● View Details + Update Extension + Enable Extension + Uninstall Extension" +`; + +exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = ` +"● View Details + Update Extension + Disable Extension + Uninstall Extension" +`; + +exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = ` +"● View Details + Enable Extension + Uninstall Extension" +`; 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..f949d4bee --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap @@ -0,0 +1,32 @@ +// 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`] = ` +"3 extensions installed + +● 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]" +`; + +exports[`ExtensionListStep Snapshots > should render list with single extension 1`] = ` +"1 extensions installed + +● test-extension v1.0.0 (active)" +`; + +exports[`ExtensionListStep Snapshots > should render with checking status 1`] = ` +"1 extensions installed + +● checking-extension v1.0.0 (active) [checking for updates]" +`; + +exports[`ExtensionListStep Snapshots > should render with error status 1`] = ` +"1 extensions installed + +● error-extension v1.0.0 (active) [error]" +`; 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 index a79af049b..94910fd72 100644 --- a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -20,10 +20,12 @@ 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 { AuthenticateStep } from './steps/AuthenticateStep.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { getMCPServerStatus, DiscoveredMCPTool, + MCPOAuthTokenStorage, type MCPServerConfig, type AnyDeclarativeTool, type DiscoveredMCPPrompt, @@ -31,6 +33,7 @@ import { } from '@qwen-code/qwen-code-core'; import { loadSettings, SettingScope } from '../../../config/settings.js'; import { isToolValid, getToolInvalidReasons } from './utils.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; const debugLogger = createDebugLogger('MCP_DIALOG'); @@ -38,6 +41,8 @@ export const MCPManagementDialog: React.FC = ({ onClose, }) => { const config = useConfig(); + const { columns: width } = useTerminalSize(); + const boxWidth = width - 4; const [servers, setServers] = useState([]); const [selectedServerIndex, setSelectedServerIndex] = useState(-1); @@ -91,16 +96,10 @@ export const MCPManagementDialog: React.FC = ({ 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'; + source = 'project'; } else if (userSettings.mcpServers?.[name]) { - scope = 'user'; + source = 'user'; } // Use config.isMcpServerDisabled() to check if server is disabled @@ -111,16 +110,26 @@ export const MCPManagementDialog: React.FC = ({ (t) => !t.name || !t.description, ).length; + // Check if OAuth tokens exist for this server + let hasOAuthTokens = false; + try { + const tokenStorage = new MCPOAuthTokenStorage(); + const credentials = await tokenStorage.getCredentials(name); + hasOAuthTokens = credentials !== null; + } catch { + // Ignore errors when checking token existence + } + serverInfos.push({ name, status, source, - scope, config: serverConfig, toolCount: serverTools.length, invalidToolCount, promptCount: serverPrompts.length, isDisabled, + hasOAuthTokens, }); } @@ -225,6 +234,11 @@ export const MCPManagementDialog: React.FC = ({ handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST); }, [handleNavigateToStep]); + // Authenticate + const handleAuthenticate = useCallback(() => { + handleNavigateToStep(MCP_MANAGEMENT_STEPS.AUTHENTICATE); + }, [handleNavigateToStep]); + // Select tool const handleSelectTool = useCallback( (tool: MCPToolDisplayInfo) => { @@ -247,6 +261,36 @@ export const MCPManagementDialog: React.FC = ({ } }, [fetchServerData]); + // Clear OAuth authentication tokens and disconnect the server + const handleClearAuth = useCallback(async () => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + const tokenStorage = new MCPOAuthTokenStorage(); + await tokenStorage.deleteCredentials(selectedServer.name); + debugLogger.info( + `Cleared OAuth tokens for server '${selectedServer.name}'`, + ); + + // Disconnect the server so it no longer appears as connected + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.disconnectServer(selectedServer.name); + } + + // Reload to update hasOAuthTokens flag and server status + await reloadServers(); + } catch (error) { + debugLogger.error( + `Error clearing OAuth tokens for server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } + }, [config, selectedServer, reloadServers]); + // Reconnect server const handleReconnect = useCallback(async () => { if (!config || !selectedServer) return; @@ -318,17 +362,68 @@ export const MCPManagementDialog: React.FC = ({ }, [config, selectedServer, reloadServers]); // Handle disable/enable action - const handleDisable = useCallback(() => { + const handleDisable = useCallback(async () => { 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); + // Automatically determine the scope and disable without showing selection dialog + try { + setIsLoading(true); + + const server = selectedServer; + const settings = loadSettings(); + + // Determine the scope based on server configuration location + let targetScope: 'user' | 'workspace' = 'user'; + if (server.source === 'extension') { + // Extension servers should not be disabled through user/workspace settings + // Show error message and return + debugLogger.warn( + `Cannot disable extension MCP server '${server.name}'`, + ); + setIsLoading(false); + return; + } else if (server.source === 'project') { + targetScope = 'workspace'; + } + + // Get current exclusion list for the target scope + const scopeSettings = settings.forScope( + targetScope === '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( + targetScope === '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(); + } catch (error) { + debugLogger.error( + `Error disabling server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } } - }, [selectedServer, handleEnableServer, handleNavigateToStep]); + }, [selectedServer, handleEnableServer, config, reloadServers]); // Execute disable after selecting scope const handleSelectDisableScope = useCallback( @@ -383,36 +478,84 @@ export const MCPManagementDialog: React.FC = ({ // 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 ( - + let headerText = ( + - {headerText} + {t('Manage MCP servers')} + + + {servers.length} {servers.length === 1 ? t('server') : t('servers')} ); - }, [getCurrentStep, selectedServer, selectedTool]); + + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + headerText = ( + + + {selectedServer?.name || t('Server Detail')} + + + ); + break; + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + headerText = ( + + + {t('Tools for {{serverName}}', { + serverName: selectedServer?.name || 'Server', + })} + + + ({getServerTools().length}{' '} + {getServerTools().length === 1 ? t('tool') : t('tools')}) + + + ); + break; + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + headerText = ( + + + + {selectedTool?.name || t('Tool Detail')} + + {selectedTool?.annotations?.destructiveHint && ( + {'[destructive]'} + )} + {selectedTool?.annotations?.idempotentHint && ( + {'[idempotent]'} + )} + {selectedTool?.annotations?.readOnlyHint && ( + {'[read-only]'} + )} + {selectedTool?.annotations?.openWorldHint && ( + {'[open-world]'} + )} + + + {selectedTool?.serverName || t('Server')} + + + ); + break; + case MCP_MANAGEMENT_STEPS.AUTHENTICATE: + headerText = ( + + + {t('OAuth Authentication')} + + + ); + break; + case MCP_MANAGEMENT_STEPS.SERVER_LIST: + default: + break; + } + + return headerText; + }, [getCurrentStep, selectedServer, selectedTool, getServerTools, servers]); // Render step content const renderStepContent = useCallback(() => { @@ -435,6 +578,8 @@ export const MCPManagementDialog: React.FC = ({ onViewTools={handleViewTools} onReconnect={handleReconnect} onDisable={handleDisable} + onAuthenticate={handleAuthenticate} + onClearAuth={handleClearAuth} onBack={handleNavigateBack} /> ); @@ -463,6 +608,17 @@ export const MCPManagementDialog: React.FC = ({ ); + case MCP_MANAGEMENT_STEPS.AUTHENTICATE: + return ( + { + handleNavigateBack(); + void reloadServers(); + }} + /> + ); + default: return ( @@ -480,10 +636,13 @@ export const MCPManagementDialog: React.FC = ({ handleViewTools, handleReconnect, handleDisable, + handleAuthenticate, + handleClearAuth, handleNavigateBack, handleSelectTool, handleSelectDisableScope, getServerTools, + reloadServers, ]); // Render step footer @@ -511,6 +670,9 @@ export const MCPManagementDialog: React.FC = ({ case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: footerText = t('Esc to back'); break; + case MCP_MANAGEMENT_STEPS.AUTHENTICATE: + footerText = t('Esc to go back'); + break; default: footerText = t('Esc to close'); } @@ -536,14 +698,15 @@ export const MCPManagementDialog: React.FC = ({ ); return ( - + {renderStepHeader()} {renderStepContent()} diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx new file mode 100644 index 000000000..6e0011a77 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useRef, useEffect } 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 { AuthenticateStepProps } from '../types.js'; +import { useConfig } from '../../../contexts/ConfigContext.js'; +import { + MCPOAuthProvider, + MCPOAuthTokenStorage, + getErrorMessage, +} from '@qwen-code/qwen-code-core'; +import type { OAuthDisplayPayload } from '@qwen-code/qwen-code-core'; +import { appEvents, AppEvent } from '../../../../utils/events.js'; + +type AuthState = 'idle' | 'authenticating' | 'success' | 'error'; + +const AUTO_BACK_DELAY_MS = 2000; + +export const AuthenticateStep: React.FC = ({ + server, + onBack, +}) => { + const config = useConfig(); + const [authState, setAuthState] = useState('idle'); + const [messages, setMessages] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); + const isRunning = useRef(false); + + const runAuthentication = useCallback(async () => { + if (!server || !config || isRunning.current) return; + isRunning.current = true; + + setAuthState('authenticating'); + setMessages([]); + setErrorMessage(null); + + // Listen for OAuth display messages - supports both plain strings and + // structured i18n messages ({ key, params }) emitted by the core layer. + const displayListener = (message: OAuthDisplayPayload) => { + const text = + typeof message === 'string' ? message : t(message.key, message.params); + setMessages((prev) => [...prev, text]); + }; + appEvents.on(AppEvent.OauthDisplayMessage, displayListener); + + try { + setMessages([ + t("Starting OAuth authentication for MCP server '{{name}}'...", { + name: server.name, + }), + ]); + + let oauthConfig = server.config.oauth; + if (!oauthConfig) { + oauthConfig = { enabled: false }; + } + + const mcpServerUrl = server.config.httpUrl || server.config.url; + const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); + await authProvider.authenticate( + server.name, + oauthConfig, + mcpServerUrl, + appEvents, + ); + + setMessages((prev) => [ + ...prev, + t("Successfully authenticated and refreshed tools for '{{name}}'.", { + name: server.name, + }), + ]); + + // Trigger tool re-discovery to pick up authenticated server + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + setMessages((prev) => [ + ...prev, + t("Re-discovering tools from '{{name}}'...", { + name: server.name, + }), + ]); + await toolRegistry.discoverToolsForServer(server.name); + + // Show discovered tool count + const discoveredTools = toolRegistry.getToolsByServer(server.name); + setMessages((prev) => [ + ...prev, + t("Discovered {{count}} tool(s) from '{{name}}'.", { + count: String(discoveredTools.length), + name: server.name, + }), + ]); + } + + // Update the client with the new tools + const geminiClient = config.getGeminiClient(); + if (geminiClient) { + await geminiClient.setTools(); + } + + setMessages((prev) => [ + ...prev, + t('Authentication complete. Returning to server details...'), + ]); + + setAuthState('success'); + } catch (error) { + setErrorMessage(getErrorMessage(error)); + setAuthState('error'); + } finally { + isRunning.current = false; + appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); + } + }, [server, config]); + + useEffect(() => { + runAuthentication(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Auto-navigate back after authentication succeeds + useEffect(() => { + if (authState !== 'success') return; + const timer = setTimeout(() => { + onBack(); + }, AUTO_BACK_DELAY_MS); + return () => clearTimeout(timer); + }, [authState, onBack]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } + }, + { isActive: true }, + ); + + if (!server) { + return ( + + {t('No server selected')} + + ); + } + + return ( + + {/* Server info */} + + + {t('Server:')} {server.name} + + + + {/* Progress messages */} + {messages.length > 0 && ( + + {messages.map((msg, i) => ( + + {msg} + + ))} + + )} + + {/* Error message */} + {authState === 'error' && errorMessage && ( + + {errorMessage} + + )} + + {/* Action hints */} + + {authState === 'authenticating' && ( + + {t('Authenticating... Please complete the login in your browser.')} + + )} + {authState === 'success' && ( + + {t('Authentication successful.')} + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx index 07b8da439..3718f5e87 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState } from 'react'; +import { useMemo } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; @@ -20,62 +20,90 @@ import { // 标签列宽度 const LABEL_WIDTH = 15; -type ServerAction = 'view-tools' | 'reconnect' | 'toggle-disable'; +type ServerAction = + | 'view-tools' + | 'reconnect' + | 'toggle-disable' + | 'authenticate' + | 'clear-auth'; export const ServerDetailStep: React.FC = ({ server, onViewTools, onReconnect, onDisable, + onAuthenticate, + onClearAuth, onBack, }) => { - const [selectedAction, setSelectedAction] = - useState('view-tools'); + const statusColor = server + ? server.isDisabled + ? 'yellow' + : getStatusColor(server.status) + : 'gray'; - const statusColor = server ? getStatusColor(server.status) : 'gray'; + // 根据服务器状态动态生成可用操作 + const actions = useMemo(() => { + const result: Array<{ + key: string; + label: string; + value: ServerAction; + }> = []; - 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, - }, - { + if (!server) { + return result; + } + + // 只在服务器未禁用且有工具时显示"查看工具"选项 + if (!server.isDisabled && (server.toolCount ?? 0) > 0) { + result.push({ + key: 'view-tools', + label: t('View tools'), + value: 'view-tools', + }); + } + + // 只在服务器未禁用且已断开连接时显示"重新连接"选项 + if (!server.isDisabled && server.status === 'disconnected') { + result.push({ + key: 'reconnect', + label: t('Reconnect'), + value: 'reconnect', + }); + } + + // 始终显示启用/禁用选项 + result.push({ key: 'toggle-disable', - get label() { - return server?.isDisabled ? t('Enable') : t('Disable'); - }, - value: 'toggle-disable' as const, - }, - ]; + label: server?.isDisabled ? t('Enable') : t('Disable'), + value: 'toggle-disable', + }); + + // 已认证的服务器显示"重新认证",未认证的显示"认证" + if (!server.isDisabled) { + result.push({ + key: 'authenticate', + label: server.hasOAuthTokens ? t('Re-authenticate') : t('Authenticate'), + value: 'authenticate', + }); + } + + // 只在存储有 OAuth 认证信息时显示“清空认证”选项 + if (!server.isDisabled && server.hasOAuthTokens) { + result.push({ + key: 'clear-auth', + label: t('Clear Authentication'), + value: 'clear-auth', + }); + } + + return result; + }, [server]); 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 }, @@ -107,10 +135,8 @@ export const ServerDetailStep: React.FC = ({ : theme.status.error } > - {getStatusIcon(server.status)} {t(server.status)} - {server.isDisabled && ( - {t('(disabled)')} - )} + {getStatusIcon(server.status)}{' '} + {server.isDisabled ? t('disabled') : t(server.status)} @@ -120,10 +146,10 @@ export const ServerDetailStep: React.FC = ({ {t('Source:')} - - {server.scope === 'user' + + {server.source === 'user' ? t('User Settings') - : server.scope === 'workspace' + : server.source === 'project' ? t('Workspace Settings') : t('Extension')} @@ -150,37 +176,29 @@ export const ServerDetailStep: React.FC = ({ )} - - - {t('Capabilities:')} - + {!server.isDisabled && ( - - {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')} + ) + + )} + + - - - - - {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 && ( @@ -200,7 +218,7 @@ export const ServerDetailStep: React.FC = ({ items={actions} - onHighlight={(value: ServerAction) => setSelectedAction(value)} + showNumbers={false} onSelect={(value: ServerAction) => { switch (value) { case 'view-tools': @@ -212,6 +230,12 @@ export const ServerDetailStep: React.FC = ({ case 'toggle-disable': onDisable?.(); break; + case 'authenticate': + onAuthenticate?.(); + break; + case 'clear-auth': + onClearAuth?.(); + 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 index 35cff6708..bd9c58568 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx @@ -27,7 +27,6 @@ export const ServerListStep: React.FC = ({ [servers], ); - // 动态计算服务器名称列的最大宽度(基于实际内容) const serverNameWidth = useMemo(() => { if (servers.length === 0) return 20; const maxLength = Math.max(...servers.map((s) => s.name.length)); @@ -35,7 +34,6 @@ export const ServerListStep: React.FC = ({ return Math.min(Math.max(maxLength + 2, 20), 35); }, [servers]); - // 计算扁平化的服务器列表用于导航 const flatServers = useMemo(() => { const result: MCPServerDisplayInfo[] = []; for (const group of groupedServers) { @@ -44,7 +42,6 @@ export const ServerListStep: React.FC = ({ return result; }, [groupedServers]); - // 键盘导航 useKeypress( (key) => { if (key.name === 'up') { @@ -71,7 +68,6 @@ export const ServerListStep: React.FC = ({ ); } - // 计算当前选中项在分组中的位置 const getSelectionPosition = (globalIndex: number) => { let currentIndex = 0; for (const group of groupedServers) { @@ -90,18 +86,15 @@ export const ServerListStep: React.FC = ({ return ( - {/* 服务器统计 */} - - - {servers.length} {servers.length === 1 ? t('server') : t('servers')} - - - {/* 分组服务器列表 */} {groupedServers.map((group, groupIndex) => ( - + - {group.displayName} + {` ${group.displayName}`} {group.servers[0]?.configPath && ( {' '} @@ -109,12 +102,14 @@ export const ServerListStep: React.FC = ({ )} - + {group.servers.map((server, itemIndex) => { const isSelected = groupIndex === currentPosition.groupIndex && itemIndex === currentPosition.itemIndex; - const statusColor = getStatusColor(server.status); + const statusColor = server.isDisabled + ? 'yellow' + : getStatusColor(server.status); return ( @@ -149,13 +144,9 @@ export const ServerListStep: React.FC = ({ : theme.status.error } > - {getStatusIcon(server.status)} {t(server.status)} + {getStatusIcon(server.status)}{' '} + {server.isDisabled ? t('disabled') : t(server.status)} - {/* 显示 Scope 和禁用状态 */} - [{server.scope}] - {server.isDisabled && ( - {t('(disabled)')} - )} {/* 显示无效工具警告 */} {!!server.invalidToolCount && server.invalidToolCount > 0 && ( @@ -173,8 +164,8 @@ export const ServerListStep: React.FC = ({ ))} {/* 提示信息 */} - {servers.some((s) => s.status === 'disconnected') && ( - + {servers.some((s) => s.status === 'disconnected' && !s.isDisabled) && ( + ※ {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 index 0bf32b860..d864c5732 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx @@ -10,14 +10,6 @@ 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) + '...'; -}; - /** * 渲染单个参数 */ @@ -28,45 +20,15 @@ const renderParameter = ( ): 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; + // const defaultValue = param['default']; + // const enumValues = param['enum'] as string[] | undefined; + const text = `• ${name}${isRequired ? t('required') : ''}: ${type} ${description ? `- ${description}` : ''}`; 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)} - - - )} + + + {text} + ); }; @@ -82,8 +44,10 @@ const ParametersList: React.FC<{ return ( - {t('Parameters')}: - + + {t('Parameters')}: + + {Object.entries(properties).map(([name, param]) => renderParameter( name, @@ -156,62 +120,20 @@ export const ToolDetailStep: React.FC = ({ {/* 工具描述 */} {tool.description && ( - + + + {t('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 index de9f4fa6c..81d8e2f7c 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx @@ -14,7 +14,6 @@ import { VISIBLE_TOOLS_COUNT } from '../constants.js'; export const ToolListStep: React.FC = ({ tools, - serverName, onSelect, onBack, }) => { @@ -78,24 +77,15 @@ export const ToolListStep: React.FC = ({ 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')); + if (tool.annotations?.destructiveHint) hints.push('destructive'); + if (tool.annotations?.readOnlyHint) hints.push('read-only'); + if (tool.annotations?.openWorldHint) hints.push('open-world'); + if (tool.annotations?.idempotentHint) hints.push('idempotent'); return hints.join(', '); }; return ( - {/* 标题 */} - - {t('Tools for {{name}}', { name: serverName })} - - {' '} - ({tools.length} {tools.length === 1 ? t('tool') : t('tools')}) - - - {/* 工具列表 */} {displayTools.map((tool, index) => { @@ -105,14 +95,13 @@ export const ToolListStep: React.FC = ({ return ( - {/* 选择器和序号 */} - + {/* 选择器 */} + {isSelected ? '❯' : ' '} - {actualIndex + 1}. {/* 工具名称 - 固定宽度 */} diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts index 1133592bb..82d9ab7ba 100644 --- a/packages/cli/src/ui/components/mcp/types.ts +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -18,6 +18,7 @@ export const MCP_MANAGEMENT_STEPS = { DISABLE_SCOPE_SELECT: 'disable-scope-select', TOOL_LIST: 'tool-list', TOOL_DETAIL: 'tool-detail', + AUTHENTICATE: 'authenticate', // OAuth 认证步骤 } as const; export type MCPManagementStep = @@ -33,8 +34,6 @@ export interface MCPServerDisplayInfo { status: MCPServerStatus; /** 来源类型 */ source: 'user' | 'project' | 'extension'; - /** 配置所在的 scope */ - scope: 'user' | 'workspace' | 'extension'; /** 配置文件路径 */ configPath?: string; /** 服务器配置 */ @@ -49,6 +48,8 @@ export interface MCPServerDisplayInfo { errorMessage?: string; /** 是否被禁用(在排除列表中) */ isDisabled: boolean; + /** 是否存储有 OAuth 认证信息 */ + hasOAuthTokens?: boolean; } /** @@ -120,7 +121,7 @@ export interface ServerListStepProps { } /** - * ServerDetailStep组件属性 + * ServerDetailStep 组件属性 */ export interface ServerDetailStepProps { /** 选中的服务器 */ @@ -131,6 +132,10 @@ export interface ServerDetailStepProps { onReconnect?: () => void; /** 禁用服务器回调 */ onDisable?: () => void; + /** OAuth 认证回调 */ + onAuthenticate?: () => void; + /** 清空认证信息回调 */ + onClearAuth?: () => void; /** 返回回调 */ onBack: () => void; } @@ -162,7 +167,7 @@ export interface ToolListStepProps { } /** - * ToolDetailStep组件属性 + * ToolDetailStep 组件属性 */ export interface ToolDetailStepProps { /** 工具信息 */ @@ -171,6 +176,16 @@ export interface ToolDetailStepProps { onBack: () => void; } +/** + * AuthenticateStep 组件属性 + */ +export interface AuthenticateStepProps { + /** 服务器信息 */ + server: MCPServerDisplayInfo | null; + /** 返回回调 */ + onBack: () => void; +} + /** * MCP管理对话框属性 */ diff --git a/packages/cli/src/ui/components/mcp/utils.test.ts b/packages/cli/src/ui/components/mcp/utils.test.ts index 3b058ba55..155195454 100644 --- a/packages/cli/src/ui/components/mcp/utils.test.ts +++ b/packages/cli/src/ui/components/mcp/utils.test.ts @@ -25,7 +25,6 @@ describe('MCP utils', () => { name: 'server1', status: MCPServerStatus.CONNECTED, source: 'user', - scope: 'user', config: { command: 'cmd1' }, toolCount: 1, promptCount: 0, @@ -35,7 +34,6 @@ describe('MCP utils', () => { name: 'server2', status: MCPServerStatus.CONNECTED, source: 'extension', - scope: 'extension', config: { command: 'cmd2' }, toolCount: 2, promptCount: 0, diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx new file mode 100644 index 000000000..2a19e9328 --- /dev/null +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx @@ -0,0 +1,331 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AskUserQuestionDialog } from './AskUserQuestionDialog.js'; +import type { ToolAskUserQuestionConfirmationDetails } from '@qwen-code/qwen-code-core'; +import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core'; +import { renderWithProviders } from '../../../test-utils/render.js'; + +const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + +const createSingleQuestion = ( + overrides: Partial< + ToolAskUserQuestionConfirmationDetails['questions'][0] + > = {}, +): ToolAskUserQuestionConfirmationDetails['questions'][0] => ({ + question: 'What is your favorite color?', + header: 'Color', + options: [ + { label: 'Red', description: 'A warm color' }, + { label: 'Blue', description: 'A cool color' }, + { label: 'Green', description: '' }, + ], + multiSelect: false, + ...overrides, +}); + +const createConfirmationDetails = ( + overrides: Partial = {}, +): ToolAskUserQuestionConfirmationDetails => ({ + type: 'ask_user_question', + title: 'Question', + questions: [createSingleQuestion()], + onConfirm: vi.fn(), + ...overrides, +}); + +describe('', () => { + describe('rendering', () => { + it('renders single question with options', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('What is your favorite color?'); + expect(output).toContain('Red'); + expect(output).toContain('Blue'); + expect(output).toContain('Green'); + expect(output).toContain('A warm color'); + expect(output).toContain('A cool color'); + }); + + it('renders header for single question', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Color'); + }); + + it('renders "Type something..." custom input option', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Type something...'); + }); + + it('renders help text for single select', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Enter: Select'); + expect(lastFrame()).toContain('Esc: Cancel'); + expect(lastFrame()).not.toContain('Switch tabs'); + }); + + it('renders tabs for multiple questions', () => { + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ + header: 'Q2', + question: 'Second question?', + }), + ], + }); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('Q1'); + expect(output).toContain('Q2'); + expect(output).toContain('Submit'); + expect(output).toContain('Switch tabs'); + }); + + it('renders multi-select with checkboxes', () => { + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('[ ]'); + expect(output).toContain('Space: Toggle'); + expect(output).toContain('Enter: Confirm'); + }); + }); + + describe('single-select interaction', () => { + it('selects an option with Enter and submits immediately for single question', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Press Enter to select the first option (Red) + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Red' } }, + ); + unmount(); + }); + + it('navigates with number keys', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Press '2' to select Blue + stdin.write('2'); + await wait(); + + // Press Enter + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Blue' } }, + ); + unmount(); + }); + + it('cancels with Escape', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\u001B'); // Escape + await wait(); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); + unmount(); + }); + }); + + describe('multi-select interaction', () => { + it('toggles options with Space', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Space to toggle first option + stdin.write(' '); + await wait(); + + // Should show checked state + expect(lastFrame()).toContain('[✓]'); + unmount(); + }); + }); + + describe('multiple questions', () => { + it('shows Submit tab for multiple questions', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to submit tab (right arrow twice: Q1 -> Q2 -> Submit) + stdin.write('\u001B[C'); // Right + await wait(); + stdin.write('\u001B[C'); // Right + await wait(); + + const output = lastFrame(); + expect(output).toContain('Submit answers'); + expect(output).toContain('Cancel'); + expect(output).toContain('Your answers'); + unmount(); + }); + + it('shows unanswered questions as (not answered) in Submit tab', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate directly to submit tab without answering anything + stdin.write('\u001B[C'); // Right + await wait(); + stdin.write('\u001B[C'); // Right + await wait(); + + expect(lastFrame()).toContain('(not answered)'); + unmount(); + }); + }); + + describe('focus behavior', () => { + it('does not respond to keys when isFocused is false', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\r'); // Enter + await wait(); + stdin.write('\u001B'); // Escape + await wait(); + + expect(onConfirm).not.toHaveBeenCalled(); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx new file mode 100644 index 000000000..421ec82c9 --- /dev/null +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx @@ -0,0 +1,572 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { + type ToolAskUserQuestionConfirmationDetails, + ToolConfirmationOutcome, + type ToolConfirmationPayload, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { TextInput } from '../shared/TextInput.js'; +import { t } from '../../../i18n/index.js'; + +interface AskUserQuestionDialogProps { + confirmationDetails: ToolAskUserQuestionConfirmationDetails; + isFocused?: boolean; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + +export const AskUserQuestionDialog: React.FC = ({ + confirmationDetails, + isFocused = true, + onConfirm, +}) => { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [selectedOptions, setSelectedOptions] = useState< + Record + >({}); + const [customInputValues, setCustomInputValues] = useState< + Record + >({}); + const [selectedIndex, setSelectedIndex] = useState(0); + const [multiSelectedOptions, setMultiSelectedOptions] = useState< + Record + >({}); + const [customInputChecked, setCustomInputChecked] = useState< + Record + >({}); + + const hasMultipleQuestions = confirmationDetails.questions.length > 1; + const totalTabs = hasMultipleQuestions + ? confirmationDetails.questions.length + 1 + : confirmationDetails.questions.length; // +1 for Submit tab + const isSubmitTab = + hasMultipleQuestions && currentQuestionIndex === totalTabs - 1; + + const currentQuestion = isSubmitTab + ? null + : confirmationDetails.questions[currentQuestionIndex]; + const isMultiSelect = currentQuestion?.multiSelect ?? false; + // Options + custom input ("Other") + const totalOptions = currentQuestion ? currentQuestion.options.length + 1 : 2; + + // Check if the custom input option is selected + const isCustomInputSelected = + !isSubmitTab && + currentQuestion && + selectedIndex === currentQuestion.options.length; + + const currentCustomInputValue = customInputValues[currentQuestionIndex] ?? ''; + const isCustomInputAnswer = + !isSubmitTab && + currentQuestion && + !isMultiSelect && + selectedOptions[currentQuestionIndex] !== undefined && + !currentQuestion.options.some( + (opt) => opt.label === selectedOptions[currentQuestionIndex], + ); + + // Compute the current answer for a question, considering multi-select state + const getAnswerForQuestion = (idx: number): string | undefined => { + const q = confirmationDetails.questions[idx]; + if (q?.multiSelect) { + const selections = [...(multiSelectedOptions[idx] ?? [])]; + const customValue = (customInputValues[idx] ?? '').trim(); + if (customInputChecked[idx] && customValue) { + selections.push(customValue); + } + return selections.length > 0 ? selections.join(', ') : undefined; + } + return selectedOptions[idx]; + }; + + const handleSubmit = async () => { + const answers: Record = {}; + confirmationDetails.questions.forEach((_, idx) => { + const answer = getAnswerForQuestion(idx); + if (answer !== undefined) { + answers[idx] = answer; + } + }); + + await onConfirm(ToolConfirmationOutcome.ProceedOnce, { answers }); + }; + + const handleMultiSelectSubmit = () => { + if (!currentQuestion) return; + const selections = [...(multiSelectedOptions[currentQuestionIndex] ?? [])]; + const customValue = currentCustomInputValue.trim(); + if (customInputChecked[currentQuestionIndex] && customValue) { + selections.push(customValue); + } + if (selections.length === 0) return; + + const value = selections.join(', '); + const updated = { ...selectedOptions, [currentQuestionIndex]: value }; + setSelectedOptions(updated); + + if (!hasMultipleQuestions) { + void onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { [currentQuestionIndex]: value }, + }); + } else { + if (currentQuestionIndex < totalTabs - 1) { + setTimeout(() => { + setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1)); + setSelectedIndex(0); + }, 150); + } + } + }; + + const handleCustomInputSubmit = () => { + const trimmedValue = currentCustomInputValue.trim(); + + if (isMultiSelect) { + // Toggle custom input checked state + if (!trimmedValue) return; + setCustomInputChecked((prev) => ({ + ...prev, + [currentQuestionIndex]: !prev[currentQuestionIndex], + })); + return; + } + + if (!trimmedValue) return; + + const updated = { + ...selectedOptions, + [currentQuestionIndex]: trimmedValue, + }; + setSelectedOptions(updated); + + // If single question, submit immediately + if (!hasMultipleQuestions) { + void onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { + [currentQuestionIndex]: trimmedValue, + }, + }); + } else { + // Auto-advance to next tab + if (currentQuestionIndex < totalTabs - 1) { + setTimeout(() => { + setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1)); + setSelectedIndex(0); + }, 150); + } + } + }; + + // Handle navigation and selection + useKeypress( + (key) => { + if (!isFocused) return; + + // When custom input is focused, still allow up/down navigation, tab switch and escape + if (isCustomInputSelected) { + if (key.name === 'up') { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + return; + } + if (key.name === 'down') { + setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1)); + return; + } + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + void onConfirm(ToolConfirmationOutcome.Cancel); + return; + } + return; + } + + const input = key.sequence; + + // Tab navigation (left/right arrows) + if (key.name === 'left' && hasMultipleQuestions) { + if (currentQuestionIndex > 0) { + setCurrentQuestionIndex(currentQuestionIndex - 1); + setSelectedIndex(0); + } + return; + } + if (key.name === 'right' && hasMultipleQuestions) { + if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setSelectedIndex(0); + } + return; + } + + // Option navigation (up/down arrows) + if (key.name === 'up') { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + return; + } + if (key.name === 'down') { + setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1)); + return; + } + + // Number key selection + const numKey = parseInt(input || '', 10); + if (!isNaN(numKey) && numKey >= 1 && numKey <= totalOptions) { + setSelectedIndex(numKey - 1); + return; + } + + // Space to toggle multi-select + if (key.name === 'space' && isMultiSelect && currentQuestion) { + if (selectedIndex < currentQuestion.options.length) { + const option = currentQuestion.options[selectedIndex]; + if (option) { + const current = multiSelectedOptions[currentQuestionIndex] ?? []; + const isChecked = current.includes(option.label); + const updated = isChecked + ? current.filter((l) => l !== option.label) + : [...current, option.label]; + setMultiSelectedOptions((prev) => ({ + ...prev, + [currentQuestionIndex]: updated, + })); + } + } + return; + } + + // Enter to select + if (key.name === 'return') { + // Handle Submit tab + if (isSubmitTab) { + if (selectedIndex === 0) { + // Submit + void handleSubmit(); + } else { + // Cancel + void onConfirm(ToolConfirmationOutcome.Cancel); + } + return; + } + + // Handle multi-select: Enter advances to next question / submits + if (isMultiSelect && currentQuestion) { + // Custom input is handled by TextInput's onSubmit + if (selectedIndex === currentQuestion.options.length) { + return; + } + handleMultiSelectSubmit(); + return; + } + + // Handle question options (not custom input - that's handled by TextInput) + if (currentQuestion && selectedIndex < currentQuestion.options.length) { + const option = currentQuestion.options[selectedIndex]; + if (option) { + const updated = { + ...selectedOptions, + [currentQuestionIndex]: option.label, + }; + setSelectedOptions(updated); + + // If single question, submit immediately + if (!hasMultipleQuestions) { + void onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { [currentQuestionIndex]: option.label }, + }); + } else { + // Auto-advance to next tab after selection + if (currentQuestionIndex < totalTabs - 1) { + setTimeout(() => { + setCurrentQuestionIndex((prev) => + Math.min(prev + 1, totalTabs - 1), + ); + setSelectedIndex(0); + }, 150); + } + } + } + } + return; + } + + // Cancel + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + void onConfirm(ToolConfirmationOutcome.Cancel); + return; + } + }, + { isActive: isFocused }, + ); + + // Submit tab (for multiple questions) + if (isSubmitTab) { + return ( + + {/* Tabs */} + + {confirmationDetails.questions.map((q, idx) => { + const isAnswered = getAnswerForQuestion(idx) !== undefined; + return ( + + + {isAnswered ? ' ' : ' '} + {q.header} + {isAnswered ? ' ✓' : ''} + + + ); + })} + + + ▸ {t('Submit')} + + + + + {/* Show selected answers */} + + {t('Your answers:')} + {confirmationDetails.questions.map((q, idx) => { + const answer = getAnswerForQuestion(idx); + return ( + + + {q.header}:{' '} + {answer ? ( + {answer} + ) : ( + {t('(not answered)')} + )} + + + ); + })} + + + + {t('Ready to submit your answers?')} + + + {/* Submit/Cancel options */} + + + + {selectedIndex === 0 ? '❯ ' : ' '}1. {t('Submit answers')} + + + + + {selectedIndex === 1 ? '❯ ' : ' '}2. {t('Cancel')} + + + + + + + {t('↑/↓: Navigate | ←/→: Switch tabs | Enter: Select')} + + + + ); + } + + // Question tab + return ( + + {/* Tabs for multiple questions */} + {hasMultipleQuestions && ( + + {confirmationDetails.questions.map((q, idx) => { + const isAnswered = getAnswerForQuestion(idx) !== undefined; + return ( + + + {idx === currentQuestionIndex ? '▸ ' : ' '} + {q.header} + {isAnswered ? ' ✓' : ''} + + + ); + })} + + {t('Submit')} + + + )} + + {/* Question */} + + {!hasMultipleQuestions && ( + + + {currentQuestion!.header} + + + )} + {currentQuestion!.question} + + + {/* Options */} + + {currentQuestion!.options.map((opt, index) => { + const isSelected = selectedIndex === index; + const isMultiChecked = + isMultiSelect && + (multiSelectedOptions[currentQuestionIndex] ?? []).includes( + opt.label, + ); + const isAnswered = + !isMultiSelect && + selectedOptions[currentQuestionIndex] === opt.label; + const isHighlighted = isSelected || isAnswered || isMultiChecked; + // Calculate prefix width for description alignment: + // 2 (cursor) + checkbox (4 if multi) + number + ". " (2) + const prefixWidth = + 2 + (isMultiSelect ? 4 : 0) + String(index + 1).length + 2; + return ( + + + + {isSelected ? '❯ ' : ' '} + {isMultiSelect ? (isMultiChecked ? '[✓] ' : '[ ] ') : ''} + {index + 1}. {opt.label} + {isAnswered ? ' ✓' : ''} + + + {opt.description && ( + + {opt.description} + + )} + + ); + })} + + {/* Type something option/input */} + + {isCustomInputSelected ? ( + // Inline TextInput replaces the option text + + + ❯{' '} + {isMultiSelect + ? customInputChecked[currentQuestionIndex] + ? '[✓] ' + : '[ ] ' + : ''} + {currentQuestion!.options.length + 1}.{' '} + + { + const oldValue = + customInputValues[currentQuestionIndex] ?? ''; + if (isMultiSelect && value !== oldValue) { + setCustomInputChecked((prevChecked) => ({ + ...prevChecked, + [currentQuestionIndex]: value.trim().length > 0, + })); + } + setCustomInputValues((prev) => ({ + ...prev, + [currentQuestionIndex]: value, + })); + }} + onSubmit={handleCustomInputSubmit} + placeholder={t('Type something...')} + isActive={true} + inputWidth={50} + /> + + ) : ( + // Show typed value or placeholder when not selected + + + {' '} + {isMultiSelect + ? customInputChecked[currentQuestionIndex] + ? '[✓] ' + : '[ ] ' + : ''} + {currentQuestion!.options.length + 1}.{' '} + {currentCustomInputValue || t('Type something...')} + {isCustomInputAnswer ? ' ✓' : ''} + + + )} + + + + {/* Help text */} + + + + {hasMultipleQuestions + ? isMultiSelect + ? t( + '↑/↓: Navigate | ←/→: Switch tabs | Space: Toggle | Enter: Confirm | Esc: Cancel', + ) + : t( + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select | Esc: Cancel', + ) + : isMultiSelect + ? t( + '↑/↓: Navigate | Space: Toggle | Enter: Confirm | Esc: Cancel', + ) + : t('↑/↓: Navigate | Enter: Select | Esc: Cancel')} + + + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index b285b0a35..34eb34cac 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -25,6 +25,7 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { theme } from '../../semantic-colors.js'; import { t } from '../../../i18n/index.js'; +import { AskUserQuestionDialog } from './AskUserQuestionDialog.js'; export interface ToolConfirmationMessageProps { confirmationDetails: ToolCallConfirmationDetails; @@ -345,6 +346,15 @@ export const ToolConfirmationMessage: React.FC< )} ); + } else if (confirmationDetails.type === 'ask_user_question') { + // Use dedicated dialog for ask_user_question type + return ( + + ); } else { // mcp tool confirmation const mcpProps = confirmationDetails as ToolMcpConfirmationDetails; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index bbebc1361..a5931119b 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -6,7 +6,7 @@ import type React from 'react'; import { useMemo } from 'react'; -import { Box, Text } from 'ink'; +import { Box } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; @@ -136,13 +136,6 @@ export const ToolGroupMessage: React.FC = ({ contentWidth={innerWidth} /> )} - {tool.outputFile && ( - - - Output too long and was saved to: {tool.outputFile} - - - )} ); })} diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 0c44a8ed9..e5f846601 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -300,4 +300,55 @@ describe('', () => { ); expect(lastFrame()).toContain('MockAnsiOutput:hello'); }); + + it('renders rejected plan content with plan text still visible', () => { + const planResultDisplay = { + type: 'plan_summary' as const, + message: 'Plan was rejected. Remaining in plan mode.', + plan: '# My Plan\n- Step 1: Do something\n- Step 2: Do another thing', + rejected: true, + }; + + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + + const output = lastFrame(); + expect(output).toContain('Plan was rejected. Remaining in plan mode.'); + expect(output).toContain('MockMarkdown:# My Plan'); + expect(output).toContain('- Step 1: Do something'); + expect(output).toContain('- Step 2: Do another thing'); + }); + + it('renders approved plan content with approval message', () => { + const planResultDisplay = { + type: 'plan_summary' as const, + message: 'User approved the plan.', + plan: '# My Plan\n- Step 1\n- Step 2', + }; + + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + + const output = lastFrame(); + expect(output).toContain('User approved the plan.'); + expect(output).toContain('MockMarkdown:# My Plan'); + expect(output).toContain('- Step 1'); + expect(output).toContain('- Step 2'); + }); }); diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 40d471296..01ebc2fa0 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -26,6 +26,7 @@ export interface TextInputProps { isActive?: boolean; // when false, ignore keypresses validationErrors?: string[]; inputWidth?: number; + initialCursorOffset?: number; } export function TextInput({ @@ -37,6 +38,7 @@ export function TextInput({ isActive = true, validationErrors = [], inputWidth = 80, + initialCursorOffset, }: TextInputProps) { const allowMultiline = height > 1; @@ -51,6 +53,7 @@ export function TextInput({ const buffer = useTextBuffer({ initialText: value || '', + initialCursorOffset, viewport: { height, width: inputWidth }, isValidPath: () => false, onChange: stableOnChange, diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index baed1c192..369c7fff5 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1840,7 +1840,7 @@ export function useTextBuffer({ process.env['VISUAL'] ?? process.env['EDITOR'] ?? (process.platform === 'win32' ? 'notepad' : 'vi'); - const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-')); + const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'qwen-edit-')); const filePath = pathMod.join(tmpDir, 'buffer.txt'); fs.writeFileSync(filePath, text, 'utf8'); diff --git a/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx b/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx index 0cc899b87..58f0cf7d2 100644 --- a/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx +++ b/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx @@ -94,7 +94,7 @@ export function CreationSummary({ } // Check length warnings - if (state.generatedDescription.length > 300) { + if (state.generatedDescription.length > 1000) { allWarnings.push( t('Description is over {{length}} characters', { length: state.generatedDescription.length.toString(), diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index edf25bead..b662ec7ed 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -1367,6 +1367,75 @@ describe('KeypressContext - Kitty Protocol', () => { }), ); }); + + it('drops unsupported Kitty CSI-u keys without blocking later input', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[57358u`)); // CAPS_LOCK + act(() => + stdin.pressKey({ + name: 'a', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'a', + }), + ); + + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'a', + sequence: 'a', + }), + ); + }); + + it('recovers plain text that arrives in the same chunk after an unsupported CSI-u key', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => + stdin.pressKey({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[57358ua', + }), + ); + + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'a', + sequence: 'a', + kittyProtocol: true, + }), + ); + }); + + it('drops unsupported CSI-u variants with event metadata and keeps parsing', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[57358;1:1u\x1b[100u`)); + + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'd', + sequence: 'd', + kittyProtocol: true, + }), + ); + }); }); describe('Kitty keypad private-use keys', () => { diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 791602f6a..97db27563 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -178,6 +178,25 @@ export function KeypressProvider({ let rawDataBuffer = Buffer.alloc(0); let rawFlushTimeout: NodeJS.Timeout | null = null; + const createPrintableKey = (char: string): Key => { + const printableName = + char === ' ' + ? 'space' + : /^[A-Za-z]$/.test(char) + ? char.toLowerCase() + : char; + + return { + name: printableName, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: char, + kittyProtocol: true, + }; + }; + // Parse a single complete kitty sequence from the start (prefix) of the // buffer and return both the Key and the number of characters consumed. // This lets us "peel off" one complete event when multiple sequences arrive @@ -415,22 +434,11 @@ export function KeypressProvider({ 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, + ...createPrintableKey(String.fromCodePoint(keyCode)), meta: alt, shift, - paste: false, - sequence: char, - kittyProtocol: true, }, length: m[0].length, }; @@ -490,6 +498,42 @@ export function KeypressProvider({ return null; }; + const getCompleteCsiSequenceLength = (buffer: string): number | null => { + if (!buffer.startsWith(`${ESC}[`)) { + return null; + } + + for (let i = 2; i < buffer.length; i++) { + const code = buffer.charCodeAt(i); + if (code >= 0x40 && code <= 0x7e) { + return i + 1; + } + if (code < 0x20 || code > 0x3f) { + return 0; + } + } + + return null; + }; + + const parsePlainTextPrefix = ( + buffer: string, + ): { key: Key; length: number } | null => { + if (!buffer || buffer.startsWith(ESC)) { + return null; + } + + const [char] = Array.from(buffer); + if (!char) { + return null; + } + + return { + key: createPrintableKey(char), + length: char.length, + }; + }; + const broadcast = (key: Key) => { for (const handler of subscribers) { handler(key); @@ -653,47 +697,82 @@ export function KeypressProvider({ // start of the buffer. This handles batched inputs cleanly. If the // prefix is incomplete or invalid, skip to the next CSI introducer // (ESC[) so that a following valid sequence can still be parsed. - let parsedAny = false; + let bufferedInputHandled = false; while (kittySequenceBuffer) { const parsed = parseKittyPrefix(kittySequenceBuffer); - if (!parsed) { - // Look for the next potential CSI start beyond index 0 - const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1); - if (nextStart > 0) { - if (debugKeystrokeLogging) { + if (parsed) { + if (debugKeystrokeLogging) { + const parsedSequence = kittySequenceBuffer.slice( + 0, + parsed.length, + ); + if (kittySequenceBuffer.length > parsed.length) { debugLogger.debug( - '[DEBUG] Skipping incomplete/invalid CSI prefix:', - kittySequenceBuffer.slice(0, nextStart), + '[DEBUG] Kitty sequence parsed successfully (prefix):', + parsedSequence, + ); + } else { + debugLogger.debug( + '[DEBUG] Kitty sequence parsed successfully:', + parsedSequence, ); } - kittySequenceBuffer = kittySequenceBuffer.slice(nextStart); - continue; } - break; + // Consume the parsed prefix and broadcast it. + kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length); + broadcast(parsed.key); + bufferedInputHandled = true; + continue; } - if (debugKeystrokeLogging) { - const parsedSequence = kittySequenceBuffer.slice( - 0, - parsed.length, + + const completeUnsupportedCsiLength = + getCompleteCsiSequenceLength(kittySequenceBuffer); + if (completeUnsupportedCsiLength) { + if (debugKeystrokeLogging) { + debugLogger.debug( + '[DEBUG] Dropping unsupported complete CSI sequence:', + kittySequenceBuffer.slice(0, completeUnsupportedCsiLength), + ); + } + kittySequenceBuffer = kittySequenceBuffer.slice( + completeUnsupportedCsiLength, ); - if (kittySequenceBuffer.length > parsed.length) { + bufferedInputHandled = true; + continue; + } + + const plainTextPrefix = parsePlainTextPrefix(kittySequenceBuffer); + if (plainTextPrefix) { + if (debugKeystrokeLogging) { debugLogger.debug( - '[DEBUG] Kitty sequence parsed successfully (prefix):', - parsedSequence, - ); - } else { - debugLogger.debug( - '[DEBUG] Kitty sequence parsed successfully:', - parsedSequence, + '[DEBUG] Recovered plain text after kitty sequence:', + plainTextPrefix.key.sequence, ); } + kittySequenceBuffer = kittySequenceBuffer.slice( + plainTextPrefix.length, + ); + broadcast(plainTextPrefix.key); + bufferedInputHandled = true; + continue; } - // Consume the parsed prefix and broadcast it. - kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length); - broadcast(parsed.key); - parsedAny = true; + + // Look for the next potential CSI start beyond index 0 + const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1); + if (nextStart > 0) { + if (debugKeystrokeLogging) { + debugLogger.debug( + '[DEBUG] Skipping incomplete/invalid CSI prefix:', + kittySequenceBuffer.slice(0, nextStart), + ); + } + kittySequenceBuffer = kittySequenceBuffer.slice(nextStart); + bufferedInputHandled = true; + continue; + } + break; } - if (parsedAny) return; + if (bufferedInputHandled) return; if (config?.getDebugMode() || debugKeystrokeLogging) { const codes = Array.from(kittySequenceBuffer).map((ch) => diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 85ff046c7..19464cccc 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -74,6 +74,8 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // Extensions manager dialog + closeExtensionsManagerDialog: () => void; // MCP dialog closeMcpDialog: () => void; // Resume session dialog diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 84f9e6052..0d461e70c 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -125,6 +125,8 @@ export interface UIState { // Subagent dialogs isSubagentCreateDialogOpen: boolean; isAgentsManagerDialogOpen: boolean; + // Extensions manager dialog + isExtensionsManagerDialogOpen: boolean; // MCP dialog isMcpDialogOpen: boolean; // Feedback dialog diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 1bbdd05fd..82cd52060 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -31,6 +31,7 @@ import type { LoadedSettings } from '../../config/settings.js'; import { type CommandContext, type SlashCommand } from '../commands/types.js'; import { CommandService } from '../../services/CommandService.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; +import { BundledSkillLoader } from '../../services/BundledSkillLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; @@ -78,6 +79,7 @@ interface SlashCommandProcessorActions { addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; openAgentsManagerDialog: () => void; + openExtensionsManagerDialog: () => void; openMcpDialog: () => void; } @@ -310,6 +312,7 @@ export const useSlashCommandProcessor = ( const loaders = [ new McpPromptLoader(config), new BuiltinCommandLoader(config), + new BundledSkillLoader(config), new FileCommandLoader(config), ]; const commandService = await CommandService.create( @@ -486,6 +489,9 @@ export const useSlashCommandProcessor = ( 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/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.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 42f28f5e2..33680358e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -28,6 +28,7 @@ import { ApprovalMode, AuthType, GeminiEventType as ServerGeminiEventType, + SendMessageType, ToolErrorType, ToolConfirmationOutcome, } from '@qwen-code/qwen-code-core'; @@ -482,7 +483,7 @@ describe('useGeminiStream', () => { expectedMergedResponse, expect.any(AbortSignal), 'prompt-id-2', - { isContinuation: true }, + { type: SendMessageType.ToolResult }, ); }); @@ -806,7 +807,7 @@ describe('useGeminiStream', () => { toolCallResponseParts, expect.any(AbortSignal), 'prompt-id-4', - { isContinuation: true }, + { type: SendMessageType.ToolResult }, ); }); @@ -1122,7 +1123,7 @@ describe('useGeminiStream', () => { 'This is the actual prompt from the command file.', expect.any(AbortSignal), expect.any(String), - undefined, + { type: SendMessageType.UserQuery }, ); expect(mockScheduleToolCalls).not.toHaveBeenCalled(); @@ -1149,7 +1150,7 @@ describe('useGeminiStream', () => { '', expect.any(AbortSignal), expect.any(String), - undefined, + { type: SendMessageType.UserQuery }, ); }); }); @@ -1168,7 +1169,7 @@ describe('useGeminiStream', () => { '// This is a line comment', expect.any(AbortSignal), expect.any(String), - undefined, + { type: SendMessageType.UserQuery }, ); }); }); @@ -1187,7 +1188,7 @@ describe('useGeminiStream', () => { '/* This is a block comment */', expect.any(AbortSignal), expect.any(String), - undefined, + { type: SendMessageType.UserQuery }, ); }); }); @@ -2091,7 +2092,7 @@ describe('useGeminiStream', () => { processedQueryParts, // Argument 1: The parts array directly expect.any(AbortSignal), // Argument 2: An AbortSignal expect.any(String), // Argument 3: The prompt_id string - undefined, // Argument 4: Options (undefined for normal prompts) + { type: SendMessageType.UserQuery }, // Argument 4: The options ); }); @@ -2244,6 +2245,7 @@ describe('useGeminiStream', () => { it('should show a retry countdown and update pending history over time', async () => { vi.useFakeTimers(); try { + let continueToRetryAttempt: (() => void) | undefined; let resolveStream: (() => void) | undefined; mockSendMessageStream.mockReturnValue( (async function* () { @@ -2256,6 +2258,9 @@ describe('useGeminiStream', () => { delayMs: 3000, }, }; + await new Promise((resolve) => { + continueToRetryAttempt = resolve; + }); yield { type: ServerGeminiEventType.Retry, }; @@ -2330,6 +2335,12 @@ describe('useGeminiStream', () => { '2s', ); + continueToRetryAttempt?.(); + + await act(async () => { + await Promise.resolve(); + }); + resolveStream?.(); await act(async () => { @@ -2347,6 +2358,103 @@ describe('useGeminiStream', () => { } }); + it('should clear retry errors after auto-retry succeeds once the countdown has elapsed', async () => { + vi.useFakeTimers(); + try { + let continueAfterCountdown: (() => void) | undefined; + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Retry, + retryInfo: { + message: '[API Error: Rate limit exceeded]', + attempt: 1, + maxRetries: 3, + delayMs: 1000, + }, + }; + await new Promise((resolve) => { + continueAfterCountdown = resolve; + }); + yield { + type: ServerGeminiEventType.Retry, + }; + yield { + type: ServerGeminiEventType.Text, + value: 'Success after retry', + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + act(() => { + void result.current.submitQuery('Trigger retry after countdown'); + }); + + let errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ) as { hint?: string } | undefined; + for (let attempts = 0; attempts < 5 && !errorItem; attempts++) { + await act(async () => { + await Promise.resolve(); + }); + errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ) as { hint?: string } | undefined; + } + expect(errorItem?.hint).toContain('1s'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + const staleErrorBeforeRetryCompletes = + result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ) as { hint?: string } | undefined; + expect(staleErrorBeforeRetryCompletes?.hint).toContain('0s'); + + await act(async () => { + continueAfterCountdown?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const remainingError = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ); + expect(remainingError).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + it('should memoize pendingHistoryItems', () => { mockUseReactToolScheduler.mockReturnValue([ [], @@ -2526,6 +2634,77 @@ describe('useGeminiStream', () => { expect.any(String), ); }); + + it('should clear static error when starting a new query', async () => { + // First, mock a stream that yields an error (static error without countdown) + mockSendMessageStream.mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.Error, + value: { error: { message: 'First error' } }, + }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + // Submit first query that will fail + await act(async () => { + await result.current.submitQuery('First query'); + }); + + // Verify error appears in pending history items + await waitFor(() => { + const errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === 'error', + ); + expect(errorItem).toBeDefined(); + }); + + // Now mock a successful stream for the second query + mockSendMessageStream.mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.Text, + value: 'Success response', + }; + })(), + ); + + // Submit second query + await act(async () => { + await result.current.submitQuery('Second query'); + }); + + // Verify the error is cleared (no longer in pending history items) + await waitFor(() => { + const errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === 'error', + ); + expect(errorItem).toBeUndefined(); + }); + }); }); describe('Concurrent Execution Prevention', () => { @@ -2598,7 +2777,7 @@ describe('useGeminiStream', () => { 'First query', expect.any(AbortSignal), expect.any(String), - undefined, + { type: SendMessageType.UserQuery }, ); // Verify only the first query was added to history @@ -2650,14 +2829,14 @@ describe('useGeminiStream', () => { 'First query', expect.any(AbortSignal), expect.any(String), - undefined, + { type: SendMessageType.UserQuery }, ); expect(mockSendMessageStream).toHaveBeenNthCalledWith( 2, 'Second query', expect.any(AbortSignal), expect.any(String), - undefined, + { type: SendMessageType.UserQuery }, ); }); @@ -2680,7 +2859,7 @@ describe('useGeminiStream', () => { 'Second query', expect.any(AbortSignal), expect.any(String), - undefined, + { type: SendMessageType.UserQuery }, ); }); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 173065f41..75a1c5364 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -19,14 +19,17 @@ import type { } from '@qwen-code/qwen-code-core'; import { GeminiEventType as ServerGeminiEventType, + SendMessageType, createDebugLogger, getErrorMessage, isNodeError, MessageSenderType, logUserPrompt, + logUserRetry, GitService, UnauthorizedError, UserPromptEvent, + UserRetryEvent, logConversationFinishedEvent, ConversationFinishedEvent, ApprovalMode, @@ -1034,7 +1037,8 @@ export const useGeminiStream = ( // Show retry info if available (rate-limit / throttling errors) if (event.retryInfo) { startRetryCountdown(event.retryInfo); - } else if (!pendingRetryCountdownItemRef.current) { + } else { + // The retry attempt is starting now, so any prior retry UI is stale. clearRetryCountdown(); } break; @@ -1075,26 +1079,28 @@ export const useGeminiStream = ( setThought, pendingHistoryItemRef, setPendingHistoryItem, - pendingRetryCountdownItemRef, ], ); const submitQuery = useCallback( async ( query: PartListUnion, - options?: { isContinuation: boolean; skipPreparation?: boolean }, + submitType: SendMessageType = SendMessageType.UserQuery, prompt_id?: string, ) => { // Prevent concurrent executions of submitQuery, but allow continuations // which are part of the same logical flow (tool responses) - if (isSubmittingQueryRef.current && !options?.isContinuation) { + if ( + isSubmittingQueryRef.current && + submitType !== SendMessageType.ToolResult + ) { return; } if ( (streamingState === StreamingState.Responding || streamingState === StreamingState.WaitingForConfirmation) && - !options?.isContinuation + submitType !== SendMessageType.ToolResult ) return; @@ -1104,11 +1110,16 @@ export const useGeminiStream = ( const userMessageTimestamp = Date.now(); // Reset quota error flag when starting a new query (not a continuation) - if (!options?.isContinuation) { + if (submitType !== SendMessageType.ToolResult) { setModelSwitchedFromQuotaError(false); // Commit any pending retry error to history (without hint) since the - // user is starting a new conversation turn - if (pendingRetryCountdownItemRef.current) { + // user is starting a new conversation turn. + // Clear both countdown-based errors AND static errors (those without + // an active countdown timer, e.g. "Press Ctrl+Y to retry"). + if ( + pendingRetryCountdownItemRef.current || + pendingRetryErrorItemRef.current + ) { clearRetryCountdown(); } } @@ -1122,14 +1133,15 @@ export const useGeminiStream = ( } return promptIdContext.run(prompt_id, async () => { - const { queryToSend, shouldProceed } = options?.skipPreparation - ? { queryToSend: query, shouldProceed: true } - : await prepareQueryForGemini( - query, - userMessageTimestamp, - abortSignal, - prompt_id!, - ); + const { queryToSend, shouldProceed } = + submitType === SendMessageType.Retry + ? { queryToSend: query, shouldProceed: true } + : await prepareQueryForGemini( + query, + userMessageTimestamp, + abortSignal, + prompt_id!, + ); if (!shouldProceed || queryToSend === null) { isSubmittingQueryRef.current = false; @@ -1137,7 +1149,7 @@ export const useGeminiStream = ( } // Check image format support for non-continuations - if (!options?.isContinuation) { + if (submitType === SendMessageType.UserQuery) { const formatCheck = checkImageFormatsSupport(queryToSend); if (formatCheck.hasUnsupportedFormats) { addItem( @@ -1154,7 +1166,7 @@ export const useGeminiStream = ( lastPromptRef.current = finalQueryToSend; lastPromptErroredRef.current = false; - if (!options?.isContinuation) { + if (submitType === SendMessageType.UserQuery) { // trigger new prompt event for session stats in CLI startNewPrompt(); @@ -1175,6 +1187,10 @@ export const useGeminiStream = ( setThought(null); } + if (submitType === SendMessageType.Retry) { + logUserRetry(config, new UserRetryEvent(prompt_id)); + } + setIsResponding(true); setInitError(null); @@ -1183,7 +1199,7 @@ export const useGeminiStream = ( finalQueryToSend, abortSignal, prompt_id!, - options, + { type: submitType }, ); const processingStatus = await processGeminiStreamEvents( @@ -1203,7 +1219,8 @@ export const useGeminiStream = ( } // Only clear auto-retry countdown errors (those with an active timer). // Do NOT clear static error+hint from handleErrorEvent — those should - // remain visible until the user presses Ctrl+Y to retry. + // remain visible until the user presses Ctrl+Y to retry or starts + // a new conversation turn (cleared in submitQuery). if (retryCountdownTimerRef.current) { clearRetryCountdown(); } @@ -1250,6 +1267,7 @@ export const useGeminiStream = ( handleLoopDetectedEvent, clearRetryCountdown, pendingRetryCountdownItemRef, + pendingRetryErrorItemRef, setPendingRetryErrorItem, ], ); @@ -1269,7 +1287,7 @@ export const useGeminiStream = ( * * When conditions are met: * - Clears any pending auto-retry countdown to avoid duplicate retries - * - Re-submits the last query with skipPreparation: true for faster retry + * - Re-submits the last query with isRetry: true, reusing the same prompt_id * * This function is exposed via UIActionsContext and triggered by InputPrompt * when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts). @@ -1294,24 +1312,10 @@ export const useGeminiStream = ( return; } - // Commit the error to history (without hint) before clearing - const errorItem = pendingRetryErrorItemRef.current; - if (errorItem) { - addItem({ type: errorItem.type, text: errorItem.text }, Date.now()); - } clearRetryCountdown(); - await submitQuery(lastPrompt, { - isContinuation: false, - skipPreparation: true, - }); - }, [ - streamingState, - addItem, - clearRetryCountdown, - submitQuery, - pendingRetryErrorItemRef, - ]); + await submitQuery(lastPrompt, SendMessageType.Retry); + }, [streamingState, addItem, clearRetryCountdown, submitQuery]); const handleApprovalModeChange = useCallback( async (newApprovalMode: ApprovalMode) => { @@ -1456,13 +1460,7 @@ export const useGeminiStream = ( return; } - submitQuery( - responsesToSend, - { - isContinuation: true, - }, - prompt_ids[0], - ); + submitQuery(responsesToSend, SendMessageType.ToolResult, prompt_ids[0]); }, [ isResponding, diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 56992f678..966c6adff 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -252,7 +252,6 @@ export function mapToDisplay( status: mapCoreStatusToDisplayStatus(trackedCall.status), resultDisplay: trackedCall.response.resultDisplay, confirmationDetails: undefined, - outputFile: trackedCall.response.outputFile, }; case 'error': return { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index d2483f371..8f4c41f6d 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -68,7 +68,6 @@ export interface IndividualToolCallDisplay { confirmationDetails: ToolCallConfirmationDetails | undefined; renderOutputAsMarkdown?: boolean; ptyId?: number; - outputFile?: string; } export interface CompressionProps { 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-open.sb b/packages/cli/src/utils/sandbox-macos-permissive-open.sb index b0da94f7f..bc2087481 100644 --- a/packages/cli/src/utils/sandbox-macos-permissive-open.sb +++ b/packages/cli/src/utils/sandbox-macos-permissive-open.sb @@ -22,4 +22,6 @@ (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/null") -) \ No newline at end of file + (literal "/dev/ptmx") + (regex #"^/dev/ttys[0-9]*$") +) diff --git a/packages/core/package.json b/packages/core/package.json index 43219cbcc..88e4c5c1d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.12.0", + "version": "0.13.0", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 828ef9c3e..3024bd2df 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -248,6 +248,26 @@ describe('Server Config (config.ts)', () => { ); }); + it('should store a system prompt override', () => { + const config = new Config({ + ...baseParams, + systemPrompt: 'You are a custom system prompt.', + }); + + expect(config.getSystemPrompt()).toBe('You are a custom system prompt.'); + expect(config.getAppendSystemPrompt()).toBeUndefined(); + }); + + it('should store an appended system prompt', () => { + const config = new Config({ + ...baseParams, + appendSystemPrompt: 'Be extra concise.', + }); + + expect(config.getAppendSystemPrompt()).toBe('Be extra concise.'); + expect(config.getSystemPrompt()).toBeUndefined(); + }); + describe('initialize', () => { it('should throw an error if checkpointing is enabled and GitService fails', async () => { const gitError = new Error('Git is not installed'); @@ -1047,10 +1067,10 @@ describe('Server Config (config.ts)', () => { expect(config.getTruncateToolOutputThreshold()).toBe(50000); }); - it('should return infinity when truncation is disabled', () => { + it('should return infinity when threshold is zero or negative', () => { const customParams = { ...baseParams, - enableToolOutputTruncation: false, + truncateToolOutputThreshold: 0, }; const config = new Config(customParams); expect(config.getTruncateToolOutputThreshold()).toBe( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d8af76fea..e65516925 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -37,11 +37,11 @@ import { type FileSystemService, StandardFileSystemService, type FileEncodingType, - FileEncoding, } from '../services/fileSystemService.js'; import { GitService } from '../services/gitService.js'; // Tools +import { AskUserQuestionTool } from '../tools/askUserQuestion.js'; import { EditTool } from '../tools/edit.js'; import { ExitPlanModeTool } from '../tools/exitPlanMode.js'; import { GlobTool } from '../tools/glob.js'; @@ -95,6 +95,7 @@ import { // Utils import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import { FileExclusions } from '../utils/ignorePatterns.js'; +import { shouldDefaultToNodePty } from '../utils/shell-utils.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { isToolEnabled, type ToolName } from '../utils/tool-utils.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -193,10 +194,6 @@ export interface ChatCompressionSettings { contextPercentageThreshold?: number; } -export interface SummarizeToolOutputSettings { - tokenBudget?: number; -} - export interface TelemetrySettings { enabled?: boolean; target?: TelemetryTarget; @@ -296,6 +293,8 @@ export interface ConfigParameters { debugMode: boolean; includePartialMessages?: boolean; question?: string; + systemPrompt?: string; + appendSystemPrompt?: string; coreTools?: string[]; allowedTools?: string[]; excludeTools?: string[]; @@ -337,7 +336,6 @@ export interface ConfigParameters { allowedMcpServers?: string[]; excludedMcpServers?: string[]; noBrowser?: boolean; - summarizeToolOutput?: Record; folderTrustFeature?: boolean; folderTrust?: boolean; ideMode?: boolean; @@ -373,7 +371,6 @@ export interface ConfigParameters { skipLoopDetection?: boolean; truncateToolOutputThreshold?: number; truncateToolOutputLines?: number; - enableToolOutputTruncation?: boolean; eventEmitter?: EventEmitter; output?: OutputSettings; inputFormat?: InputFormat; @@ -449,6 +446,8 @@ export class Config { private readonly outputFormat: OutputFormat; private readonly includePartialMessages: boolean; private readonly question: string | undefined; + private readonly systemPrompt: string | undefined; + private readonly appendSystemPrompt: string | undefined; private readonly coreTools: string[] | undefined; private readonly allowedTools: string[] | undefined; private readonly excludeTools: string[] | undefined; @@ -496,9 +495,6 @@ export class Config { private readonly listExtensions: boolean; private readonly overrideExtensions?: string[]; - private readonly summarizeToolOutput: - | Record - | undefined; private readonly cliVersion?: string; private readonly experimentalZedIntegration: boolean = false; private readonly chatRecordingEnabled: boolean; @@ -528,10 +524,9 @@ export class Config { private readonly fileExclusions: FileExclusions; private readonly truncateToolOutputThreshold: number; private readonly truncateToolOutputLines: number; - private readonly enableToolOutputTruncation: boolean; private readonly eventEmitter?: EventEmitter; private readonly channel: string | undefined; - private readonly defaultFileEncoding: FileEncodingType; + private readonly defaultFileEncoding: FileEncodingType | undefined; private readonly enableHooks: boolean; private readonly hooks?: Record; private readonly hooksConfig?: Record; @@ -559,6 +554,8 @@ export class Config { this.outputFormat = normalizedOutputFormat ?? OutputFormat.TEXT; this.includePartialMessages = params.includePartialMessages ?? false; this.question = params.question; + this.systemPrompt = params.systemPrompt; + this.appendSystemPrompt = params.appendSystemPrompt; this.coreTools = params.coreTools; this.allowedTools = params.allowedTools; this.excludeTools = params.excludeTools; @@ -612,7 +609,6 @@ export class Config { this.listExtensions = params.listExtensions ?? false; this.overrideExtensions = params.overrideExtensions; this.noBrowser = params.noBrowser ?? false; - this.summarizeToolOutput = params.summarizeToolOutput; this.folderTrustFeature = params.folderTrustFeature ?? false; this.folderTrust = params.folderTrust ?? false; this.ideMode = params.ideMode ?? false; @@ -635,7 +631,8 @@ export class Config { this.webSearch = params.webSearch; this.useRipgrep = params.useRipgrep ?? true; this.useBuiltinRipgrep = params.useBuiltinRipgrep ?? true; - this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? true; + this.shouldUseNodePtyShell = + params.shouldUseNodePtyShell ?? shouldDefaultToNodePty(); this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.shellExecutionConfig = { terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80, @@ -648,9 +645,8 @@ export class Config { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD; this.truncateToolOutputLines = params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES; - this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true; this.channel = params.channel; - this.defaultFileEncoding = params.defaultFileEncoding ?? FileEncoding.UTF8; + this.defaultFileEncoding = params.defaultFileEncoding; this.storage = new Storage(this.targetDir); this.inputFormat = params.inputFormat ?? InputFormat.TEXT; this.fileExclusions = new FileExclusions(this); @@ -1205,6 +1201,14 @@ export class Config { return this.question; } + getSystemPrompt(): string | undefined { + return this.systemPrompt; + } + + getAppendSystemPrompt(): string | undefined { + return this.appendSystemPrompt; + } + getCoreTools(): string[] | undefined { return this.coreTools; } @@ -1596,12 +1600,6 @@ export class Config { return this.getNoBrowser() || !shouldAttemptBrowserLaunch(); } - getSummarizeToolOutputConfig(): - | Record - | undefined { - return this.summarizeToolOutput; - } - // Web search provider configuration getWebSearchConfig() { return this.webSearch; @@ -1662,7 +1660,7 @@ export class Config { * Get the default file encoding for new files. * @returns FileEncodingType */ - getDefaultFileEncoding(): FileEncodingType { + getDefaultFileEncoding(): FileEncodingType | undefined { return this.defaultFileEncoding; } @@ -1730,15 +1728,8 @@ export class Config { return this.skipStartupContext; } - getEnableToolOutputTruncation(): boolean { - return this.enableToolOutputTruncation; - } - getTruncateToolOutputThreshold(): number { - if ( - !this.enableToolOutputTruncation || - this.truncateToolOutputThreshold <= 0 - ) { + if (this.truncateToolOutputThreshold <= 0) { return Number.POSITIVE_INFINITY; } @@ -1746,7 +1737,7 @@ export class Config { } getTruncateToolOutputLines(): number { - if (!this.enableToolOutputTruncation || this.truncateToolOutputLines <= 0) { + if (this.truncateToolOutputLines <= 0) { return Number.POSITIVE_INFINITY; } @@ -1892,6 +1883,7 @@ export class Config { registerCoreTool(ShellTool, this); registerCoreTool(MemoryTool); registerCoreTool(TodoWriteTool, this); + registerCoreTool(AskUserQuestionTool, this); !this.sdkMode && registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); // Conditionally register web search tool if web search provider is configured diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 2163ccb0c..504dd7e9e 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -63,6 +63,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -84,7 +87,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -282,6 +285,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -303,7 +309,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -511,6 +517,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -532,7 +541,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -725,6 +734,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -746,7 +758,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -939,6 +951,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -960,7 +975,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -1153,6 +1168,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -1174,7 +1192,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -1367,6 +1385,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -1388,7 +1409,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -1581,6 +1602,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -1602,7 +1626,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -1795,6 +1819,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -1816,7 +1843,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -2009,6 +2036,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -2030,7 +2060,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -2246,6 +2276,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -2267,7 +2300,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -2546,6 +2579,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -2567,7 +2603,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -2783,6 +2819,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -2804,7 +2843,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -3079,6 +3118,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -3100,7 +3142,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -3293,6 +3335,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -3314,7 +3359,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 3f0e17197..16cf3622f 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -328,6 +328,170 @@ describe('AnthropicContentGenerator', () => { expect.not.objectContaining({ thinking: expect.anything() }), ); }); + + describe('output token limits', () => { + it('caps configured samplingParams.max_tokens to model output limit', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model: 'claude-sonnet-4', + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-sonnet-4', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 200_000 }, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hello', + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.objectContaining({ max_tokens: 65536 }), + ); + }); + + it('caps request.config.maxOutputTokens to model output limit when config max_tokens is missing', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model: 'claude-sonnet-4', + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-sonnet-4', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hello', + config: { maxOutputTokens: 100_000 }, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.objectContaining({ max_tokens: 65536 }), + ); + }); + + it('uses conservative default when max_tokens is not explicitly configured', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model: 'claude-sonnet-4', + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-sonnet-4', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hello', + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.objectContaining({ max_tokens: 32000 }), + ); + }); + + it('respects configured max_tokens for unknown models', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model: 'unknown-model', + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'unknown-model', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 100_000 }, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hello', + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.objectContaining({ max_tokens: 100_000 }), + ); + }); + + it('treats null maxOutputTokens as not configured', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model: 'claude-sonnet-4', + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-sonnet-4', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hello', + config: { maxOutputTokens: null as unknown as undefined }, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.objectContaining({ max_tokens: 32000 }), + ); + }); + }); }); describe('countTokens', () => { diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 3fcd4b96d..e3c61893e 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -31,6 +31,11 @@ import { AnthropicContentConverter } from './converter.js'; import { buildRuntimeFetchOptions } from '../../utils/runtimeFetchOptions.js'; import { DEFAULT_TIMEOUT } from '../openaiContentGenerator/constants.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; +import { + tokenLimit, + DEFAULT_OUTPUT_TOKEN_LIMIT, + hasExplicitOutputLimit, +} from '../tokenLimits.js'; const debugLogger = createDebugLogger('ANTHROPIC'); @@ -223,8 +228,18 @@ export class AnthropicContentGenerator implements ContentGenerator { return configValue !== undefined ? configValue : requestValue; }; + // Apply output token limit logic consistent with OpenAI providers + const userMaxTokens = getParam('max_tokens', 'maxOutputTokens'); + const modelId = this.contentGeneratorConfig.model; + const modelLimit = tokenLimit(modelId, 'output'); + const isKnownModel = hasExplicitOutputLimit(modelId); + const maxTokens = - getParam('max_tokens', 'maxOutputTokens') ?? 10_000; + userMaxTokens !== undefined && userMaxTokens !== null + ? isKnownModel + ? Math.min(userMaxTokens, modelLimit) + : userMaxTokens + : Math.min(modelLimit, DEFAULT_OUTPUT_TOKEN_LIMIT); return { max_tokens: maxTokens, diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 8121e1464..2d197fa20 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -15,7 +15,7 @@ import { } from 'vitest'; import type { Content, GenerateContentResponse, Part } from '@google/genai'; -import { GeminiClient } from './client.js'; +import { GeminiClient, SendMessageType } from './client.js'; import { findCompressSplitPoint } from '../services/chatCompressionService.js'; import { AuthType, @@ -31,7 +31,7 @@ import { Turn, type ChatCompressionInfo, } from './turn.js'; -import { getCoreSystemPrompt } from './prompts.js'; +import { getCoreSystemPrompt, getCustomSystemPrompt } from './prompts.js'; import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { setSimulate429 } from '../utils/testUtils.js'; @@ -314,6 +314,8 @@ describe('Gemini Client (client.ts)', () => { getVertexAI: vi.fn().mockReturnValue(false), getUserAgent: vi.fn().mockReturnValue('test-agent'), getUserMemory: vi.fn().mockReturnValue(''), + getSystemPrompt: vi.fn().mockReturnValue(undefined), + getAppendSystemPrompt: vi.fn().mockReturnValue(undefined), getFullContext: vi.fn().mockReturnValue(false), getSessionId: vi.fn().mockReturnValue('test-session-id'), getProxy: vi.fn().mockReturnValue(undefined), @@ -1551,7 +1553,7 @@ Other open files: [{ text: 'Start conversation' }], signal, 'prompt-id-3', - { isContinuation: false }, + { type: SendMessageType.UserQuery }, Number.MAX_SAFE_INTEGER, // Bypass the MAX_TURNS protection ); @@ -2304,6 +2306,70 @@ Other open files: // Assert - loop detection methods should not be called when skipLoopDetection is true expect(ldMock.addAndCheck).not.toHaveBeenCalled(); }); + + describe('retry sendMessageType', () => { + it('should call stripOrphanedUserEntriesFromHistory before executing', async () => { + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), + stripOrphanedUserEntriesFromHistory: vi.fn(), + }; + client['chat'] = mockChat as GeminiChat; + + const mockStream = (async function* () { + yield { type: 'content', value: 'retry response' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + // Act: send with retry type + const stream = client.sendMessageStream( + [{ text: 'second message' }], + new AbortController().signal, + 'prompt-retry', + { type: SendMessageType.Retry }, + ); + for await (const _ of stream) { + /* consume */ + } + + // Assert: the cleanup method was called + expect( + mockChat.stripOrphanedUserEntriesFromHistory, + ).toHaveBeenCalledOnce(); + }); + + it('should not increment sessionTurnCount for retry', async () => { + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), + stripOrphanedUserEntriesFromHistory: vi.fn(), + }; + client['chat'] = mockChat as GeminiChat; + + const mockStream = (async function* () { + yield { type: 'content', value: 'ok' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const turnCountBefore = client['sessionTurnCount']; + + const stream = client.sendMessageStream( + [{ text: 'retry' }], + new AbortController().signal, + 'prompt-retry-3', + { type: SendMessageType.Retry }, + ); + for await (const _ of stream) { + /* consume */ + } + + expect(client['sessionTurnCount']).toBe(turnCountBefore); + }); + }); }); describe('generateContent', () => { @@ -2362,6 +2428,104 @@ Other open files: ); }); + it('should use config system prompt override when provided', async () => { + const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; + const abortSignal = new AbortController().signal; + + vi.spyOn(client['config'], 'getSystemPrompt').mockReturnValue( + 'Override prompt', + ); + vi.spyOn(client['config'], 'getUserMemory').mockReturnValue( + 'Saved memory', + ); + vi.mocked(getCustomSystemPrompt).mockReturnValueOnce( + 'Override prompt with memory', + ); + + await client.generateContent( + contents, + {}, + abortSignal, + DEFAULT_QWEN_FLASH_MODEL, + ); + + expect(getCustomSystemPrompt).toHaveBeenCalledWith( + 'Override prompt', + 'Saved memory', + undefined, + ); + expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + systemInstruction: 'Override prompt with memory', + }), + }), + 'test-session-id', + ); + }); + + it('should append config appendSystemPrompt to the core system prompt', async () => { + const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; + const abortSignal = new AbortController().signal; + + vi.mocked(getCoreSystemPrompt).mockClear(); + vi.spyOn(client['config'], 'getAppendSystemPrompt').mockReturnValue( + 'Be extra concise.', + ); + + await client.generateContent( + contents, + {}, + abortSignal, + DEFAULT_QWEN_FLASH_MODEL, + ); + + expect(getCoreSystemPrompt).toHaveBeenCalledWith( + '', + 'test-model', + 'Be extra concise.', + ); + }); + + it('should append config appendSystemPrompt after a config system prompt override', async () => { + const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; + const abortSignal = new AbortController().signal; + + vi.spyOn(client['config'], 'getSystemPrompt').mockReturnValue( + 'Override prompt', + ); + vi.spyOn(client['config'], 'getAppendSystemPrompt').mockReturnValue( + 'Focus on findings only.', + ); + vi.spyOn(client['config'], 'getUserMemory').mockReturnValue( + 'Saved memory', + ); + vi.mocked(getCustomSystemPrompt).mockReturnValueOnce( + 'Override prompt with memory and append', + ); + + await client.generateContent( + contents, + {}, + abortSignal, + DEFAULT_QWEN_FLASH_MODEL, + ); + + expect(getCustomSystemPrompt).toHaveBeenCalledWith( + 'Override prompt', + 'Saved memory', + 'Focus on findings only.', + ); + expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + systemInstruction: 'Override prompt with memory and append', + }), + }), + 'test-session-id', + ); + }); + // Note: there is currently no "fallback mode" model routing; the model used // is always the one explicitly requested by the caller. }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 5c7cfb2a8..c9f67e072 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -85,6 +85,17 @@ import type { StopHookOutput } from '../hooks/types.js'; const MAX_TURNS = 100; +export enum SendMessageType { + UserQuery = 'userQuery', + ToolResult = 'toolResult', + Retry = 'retry', + Hook = 'hook', +} + +export interface SendMessageOptions { + type: SendMessageType; +} + export class GeminiClient { private chat?: GeminiChat; private sessionTurnCount = 0; @@ -152,6 +163,10 @@ export class GeminiClient { this.getChat().stripThoughtsFromHistory(); } + private stripOrphanedUserEntriesFromHistory() { + this.getChat().stripOrphanedUserEntriesFromHistory(); + } + setHistory(history: Content[]) { this.getChat().setHistory(history); this.forceFullIdeContext = true; @@ -183,6 +198,26 @@ export class GeminiClient { }); } + private getMainSessionSystemInstruction(): string { + const userMemory = this.config.getUserMemory(); + const overrideSystemPrompt = this.config.getSystemPrompt(); + const appendSystemPrompt = this.config.getAppendSystemPrompt(); + + if (overrideSystemPrompt) { + return getCustomSystemPrompt( + overrideSystemPrompt, + userMemory, + appendSystemPrompt, + ); + } + + return getCoreSystemPrompt( + userMemory, + this.config.getModel(), + appendSystemPrompt, + ); + } + async startChat(extraHistory?: Content[]): Promise { this.forceFullIdeContext = true; this.hasFailedCompressionAttempt = false; @@ -194,9 +229,7 @@ export class GeminiClient { const history = await getInitialChatHistory(this.config, extraHistory); try { - const userMemory = this.config.getUserMemory(); - const model = this.config.getModel(); - const systemInstruction = getCoreSystemPrompt(userMemory, model); + const systemInstruction = this.getMainSessionSystemInstruction(); return new GeminiChat( this.config, @@ -414,13 +447,19 @@ export class GeminiClient { request: PartListUnion, signal: AbortSignal, prompt_id: string, - options?: { isContinuation: boolean }, + options?: SendMessageOptions, turns: number = MAX_TURNS, ): AsyncGenerator { + const messageType = options?.type ?? SendMessageType.UserQuery; + + if (messageType === SendMessageType.Retry) { + this.stripOrphanedUserEntriesFromHistory(); + } + // Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled) const hooksEnabled = this.config.getEnableHooks(); const messageBus = this.config.getMessageBus(); - if (hooksEnabled && messageBus) { + if (messageType !== SendMessageType.Retry && hooksEnabled && messageBus) { const promptText = partToString(request); const response = await messageBus.request< HookExecutionRequest, @@ -462,7 +501,7 @@ export class GeminiClient { } } - if (!options?.isContinuation) { + if (messageType === SendMessageType.UserQuery) { this.loopDetector.reset(prompt_id); this.lastPromptId = prompt_id; @@ -472,14 +511,18 @@ export class GeminiClient { // strip thoughts from history before sending the message this.stripThoughtsFromHistory(); } - this.sessionTurnCount++; - if ( - this.config.getMaxSessionTurns() > 0 && - this.sessionTurnCount > this.config.getMaxSessionTurns() - ) { - yield { type: GeminiEventType.MaxSessionTurns }; - return new Turn(this.getChat(), prompt_id); + if (messageType !== SendMessageType.Retry) { + this.sessionTurnCount++; + + if ( + this.config.getMaxSessionTurns() > 0 && + this.sessionTurnCount > this.config.getMaxSessionTurns() + ) { + yield { type: GeminiEventType.MaxSessionTurns }; + return new Turn(this.getChat(), prompt_id); + } } + // Ensure turns never exceeds MAX_TURNS to prevent infinite loops const boundedTurns = Math.min(turns, MAX_TURNS); if (!boundedTurns) { @@ -543,7 +586,7 @@ export class GeminiClient { // append system reminders to the request let requestToSent = await flatMapTextParts(request, async (text) => [text]); - if (!options?.isContinuation) { + if (messageType === SendMessageType.UserQuery) { const systemReminders = []; // add subagent system reminder if there are subagents @@ -636,7 +679,7 @@ export class GeminiClient { continueRequest, signal, prompt_id, - { isContinuation: true }, + { type: SendMessageType.Hook }, boundedTurns - 1, ); } @@ -690,7 +733,7 @@ export class GeminiClient { const userMemory = this.config.getUserMemory(); const finalSystemInstruction = generationConfig.systemInstruction ? getCustomSystemPrompt(generationConfig.systemInstruction, userMemory) - : getCoreSystemPrompt(userMemory, this.config.getModel()); + : this.getMainSessionSystemInstruction(); const requestConfig: GenerateContentConfig = { abortSignal, diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 1f810430f..3411fff50 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import type { Mock } from 'vitest'; import type { Config, @@ -29,7 +29,6 @@ import type { ToolCall, WaitingToolCall } from './coreToolScheduler.js'; import { CoreToolScheduler, convertToFunctionResponse, - truncateAndSaveToFile, } from './coreToolScheduler.js'; import type { Part, PartListUnion } from '@google/genai'; import { @@ -37,13 +36,6 @@ import { MockTool, MOCK_TOOL_SHOULD_CONFIRM_EXECUTE, } from '../test-utils/mock-tool.js'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; - -vi.mock('fs/promises', () => ({ - writeFile: vi.fn(), -})); - class TestApprovalTool extends BaseDeclarativeTool<{ id: string }, ToolResult> { static readonly Name = 'testApprovalTool'; @@ -2290,223 +2282,304 @@ describe('CoreToolScheduler Sequential Execution', () => { }); }); -describe('truncateAndSaveToFile', () => { - const mockWriteFile = vi.mocked(fs.writeFile); - const THRESHOLD = 40_000; - const TRUNCATE_LINES = 1000; +describe('CoreToolScheduler plan mode with ask_user_question', () => { + function createAskUserQuestionMockTool() { + let wasAnswered = false; + let userAnswers: Record = {}; - beforeEach(() => { - vi.clearAllMocks(); + return new MockTool({ + name: 'ask_user_question', + shouldConfirmExecute: async () => ({ + type: 'ask_user_question' as const, + title: 'Please answer the following question(s):', + questions: [ + { + question: 'Which approach do you prefer?', + header: 'Approach', + options: [ + { label: 'Option A', description: 'First approach' }, + { label: 'Option B', description: 'Second approach' }, + ], + multiSelect: false, + }, + ], + onConfirm: async ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + if ( + outcome === ToolConfirmationOutcome.ProceedOnce || + outcome === ToolConfirmationOutcome.ProceedAlways + ) { + wasAnswered = true; + userAnswers = payload?.answers ?? {}; + } else { + wasAnswered = false; + } + }, + }), + execute: async () => { + if (!wasAnswered) { + return { + llmContent: 'User declined to answer the questions.', + returnDisplay: 'User declined to answer the questions.', + }; + } + const answersContent = Object.entries(userAnswers) + .map(([key, value]) => `**Question ${key}**: ${value}`) + .join('\n'); + return { + llmContent: `User has provided the following answers:\n\n${answersContent}`, + returnDisplay: `User has provided the following answers:\n\n${answersContent}`, + }; + }, + }); + } + + function createPlanModeScheduler( + tool: MockTool, + onAllToolCallsComplete: ReturnType, + onToolCallsUpdate: ReturnType, + ) { + const mockToolRegistry = { + getTool: () => tool, + getToolByName: () => tool, + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByDisplayName: () => tool, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.PLAN, + getAllowedTools: () => [], + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'gemini', + }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + getToolRegistry: () => mockToolRegistry, + getUseModelRouter: () => false, + getGeminiClient: () => null, + isInteractive: () => true, + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getChatRecordingService: () => undefined, + } as unknown as Config; + + return new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + } + + it('should enter awaiting_approval for ask_user_question in plan mode', async () => { + const mockTool = createAskUserQuestionMockTool(); + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + const scheduler = createPlanModeScheduler( + mockTool, + onAllToolCallsComplete, + onToolCallsUpdate, + ); + + const abortController = new AbortController(); + const request = { + callId: '1', + name: 'ask_user_question', + args: { + questions: [ + { + question: 'Which approach?', + header: 'Approach', + options: [ + { label: 'A', description: 'First' }, + { label: 'B', description: 'Second' }, + ], + multiSelect: false, + }, + ], + }, + isClientInitiated: false, + prompt_id: 'prompt-plan-ask', + }; + + await scheduler.schedule([request], abortController.signal); + + // Should enter awaiting_approval, NOT be directly scheduled + const awaitingCall = await waitForStatus( + onToolCallsUpdate, + 'awaiting_approval', + ); + expect(awaitingCall).toBeDefined(); + expect(awaitingCall.status).toBe('awaiting_approval'); }); - it('should return content unchanged if below threshold', async () => { - const content = 'Short content'; - const callId = 'test-call-id'; - const projectTempDir = '/tmp'; - - const result = await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, + it('should execute successfully when user answers in plan mode', async () => { + const mockTool = createAskUserQuestionMockTool(); + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + const scheduler = createPlanModeScheduler( + mockTool, + onAllToolCallsComplete, + onToolCallsUpdate, ); - expect(result).toEqual({ content }); - expect(mockWriteFile).not.toHaveBeenCalled(); - }); + const abortController = new AbortController(); + const request = { + callId: '1', + name: 'ask_user_question', + args: { + questions: [ + { + question: 'Which approach?', + header: 'Approach', + options: [ + { label: 'A', description: 'First' }, + { label: 'B', description: 'Second' }, + ], + multiSelect: false, + }, + ], + }, + isClientInitiated: false, + prompt_id: 'prompt-plan-ask-answer', + }; - it('should truncate content by lines when content has many lines', async () => { - // Create content that exceeds 100,000 character threshold with many lines - const lines = Array(2000).fill('x'.repeat(100)); // 100 chars per line * 2000 lines = 200,000 chars - const content = lines.join('\n'); - const callId = 'test-call-id'; - const projectTempDir = '/tmp'; + await scheduler.schedule([request], abortController.signal); - mockWriteFile.mockResolvedValue(undefined); + const awaitingCall = (await waitForStatus( + onToolCallsUpdate, + 'awaiting_approval', + )) as WaitingToolCall; - const result = await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, + // Simulate user answering the question + await awaitingCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'Option A' } }, ); - expect(result.outputFile).toBe( - path.join(projectTempDir, `${callId}.output`), - ); - expect(mockWriteFile).toHaveBeenCalledWith( - path.join(projectTempDir, `${callId}.output`), - content, - ); + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); - // Should contain the first and last lines with 1/5 head and 4/5 tail - const head = Math.floor(TRUNCATE_LINES / 5); - const beginning = lines.slice(0, head); - const end = lines.slice(-(TRUNCATE_LINES - head)); - const expectedTruncated = - beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n'); - - expect(result.content).toContain( - 'Tool output was too large and has been truncated', - ); - expect(result.content).toContain('Truncated part of the output:'); - expect(result.content).toContain(expectedTruncated); - }); - - it('should wrap and truncate content when content has few but long lines', async () => { - const content = 'a'.repeat(200_000); // A single very long line - const callId = 'test-call-id'; - const projectTempDir = '/tmp'; - const wrapWidth = 120; - - mockWriteFile.mockResolvedValue(undefined); - - // Manually wrap the content to generate the expected file content - const wrappedLines: string[] = []; - for (let i = 0; i < content.length; i += wrapWidth) { - wrappedLines.push(content.substring(i, i + wrapWidth)); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls[0].status).toBe('success'); + if (completedCalls[0].status === 'success') { + expect(completedCalls[0].response.resultDisplay).toContain( + 'User has provided the following answers', + ); } - const expectedFileContent = wrappedLines.join('\n'); - - const result = await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, - ); - - expect(result.outputFile).toBe( - path.join(projectTempDir, `${callId}.output`), - ); - // Check that the file was written with the wrapped content - expect(mockWriteFile).toHaveBeenCalledWith( - path.join(projectTempDir, `${callId}.output`), - expectedFileContent, - ); - - // Should contain the first and last lines with 1/5 head and 4/5 tail of the wrapped content - const head = Math.floor(TRUNCATE_LINES / 5); - const beginning = wrappedLines.slice(0, head); - const end = wrappedLines.slice(-(TRUNCATE_LINES - head)); - const expectedTruncated = - beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n'); - expect(result.content).toContain( - 'Tool output was too large and has been truncated', - ); - expect(result.content).toContain('Truncated part of the output:'); - expect(result.content).toContain(expectedTruncated); }); - it('should handle file write errors gracefully', async () => { - const content = 'a'.repeat(2_000_000); - const callId = 'test-call-id'; - const projectTempDir = '/tmp'; - - mockWriteFile.mockRejectedValue(new Error('File write failed')); - - const result = await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, + it('should block non-ask_user_question tools that need confirmation in plan mode', async () => { + const editTool = new MockTool({ + name: 'write_file', + shouldConfirmExecute: MOCK_TOOL_SHOULD_CONFIRM_EXECUTE, + }); + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + const scheduler = createPlanModeScheduler( + editTool, + onAllToolCallsComplete, + onToolCallsUpdate, ); - expect(result.outputFile).toBeUndefined(); - expect(result.content).toContain( - '[Note: Could not save full output to file]', - ); - expect(mockWriteFile).toHaveBeenCalled(); - }); + const abortController = new AbortController(); + const request = { + callId: '1', + name: 'write_file', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-plan-blocked', + }; - it('should save to correct file path with call ID', async () => { - const content = 'a'.repeat(200_000); - const callId = 'unique-call-123'; - const projectTempDir = '/custom/temp/dir'; - const wrapWidth = 120; + await scheduler.schedule([request], abortController.signal); - mockWriteFile.mockResolvedValue(undefined); + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); - // Manually wrap the content to generate the expected file content - const wrappedLines: string[] = []; - for (let i = 0; i < content.length; i += wrapWidth) { - wrappedLines.push(content.substring(i, i + wrapWidth)); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls[0].status).toBe('error'); + if (completedCalls[0].status === 'error') { + expect(completedCalls[0].response.resultDisplay).toBe( + 'Plan mode blocked a non-read-only tool call.', + ); } - const expectedFileContent = wrappedLines.join('\n'); - - const result = await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, - ); - - const expectedPath = path.join(projectTempDir, `${callId}.output`); - expect(result.outputFile).toBe(expectedPath); - expect(mockWriteFile).toHaveBeenCalledWith( - expectedPath, - expectedFileContent, - ); }); - it('should include helpful instructions in truncated message', async () => { - const content = 'a'.repeat(2_000_000); - const callId = 'test-call-id'; - const projectTempDir = '/tmp'; - - mockWriteFile.mockResolvedValue(undefined); - - const result = await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, + it('should handle user cancellation of ask_user_question in plan mode', async () => { + const mockTool = createAskUserQuestionMockTool(); + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + const scheduler = createPlanModeScheduler( + mockTool, + onAllToolCallsComplete, + onToolCallsUpdate, ); - expect(result.content).toContain( - 'Tool output was too large and has been truncated', - ); - expect(result.content).toContain('The full output has been saved to:'); - expect(result.content).toContain( - 'To read the complete output, use the read_file tool with the absolute file path above', - ); - expect(result.content).toContain( - 'The truncated output below shows the beginning and end of the content', - ); - }); + const abortController = new AbortController(); + const request = { + callId: '1', + name: 'ask_user_question', + args: { + questions: [ + { + question: 'Which approach?', + header: 'Approach', + options: [ + { label: 'A', description: 'First' }, + { label: 'B', description: 'Second' }, + ], + multiSelect: false, + }, + ], + }, + isClientInitiated: false, + prompt_id: 'prompt-plan-ask-cancel', + }; - it('should sanitize callId to prevent path traversal', async () => { - const content = 'a'.repeat(200_000); - const callId = '../../../../../etc/passwd'; - const projectTempDir = '/tmp/safe_dir'; - const wrapWidth = 120; + await scheduler.schedule([request], abortController.signal); - mockWriteFile.mockResolvedValue(undefined); + const awaitingCall = (await waitForStatus( + onToolCallsUpdate, + 'awaiting_approval', + )) as WaitingToolCall; - // Manually wrap the content to generate the expected file content - const wrappedLines: string[] = []; - for (let i = 0; i < content.length; i += wrapWidth) { - wrappedLines.push(content.substring(i, i + wrapWidth)); - } - const expectedFileContent = wrappedLines.join('\n'); - - await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, + // Simulate user cancelling + await awaitingCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, ); - const expectedPath = path.join(projectTempDir, 'passwd.output'); - expect(mockWriteFile).toHaveBeenCalledWith( - expectedPath, - expectedFileContent, - ); + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls[0].status).toBe('cancelled'); }); }); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 3cdc8232f..7a8ab2895 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -25,12 +25,8 @@ import { ToolConfirmationOutcome, ApprovalMode, logToolCall, - ReadFileTool, ToolErrorType, ToolCallEvent, - ShellTool, - logToolOutputTruncated, - ToolOutputTruncatedEvent, InputFormat, Kind, SkillTool, @@ -49,8 +45,6 @@ import { modifyWithEditor, } from '../tools/modifiable-tool.js'; import * as Diff from 'diff'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; import { doesToolInvocationMatch } from '../utils/tool-utils.js'; import levenshtein from 'fast-levenshtein'; import { getPlanModeSystemReminder } from './prompts.js'; @@ -306,67 +300,6 @@ const createErrorResponse = ( contentLength: error.message.length, }); -export async function truncateAndSaveToFile( - content: string, - callId: string, - projectTempDir: string, - threshold: number, - truncateLines: number, -): Promise<{ content: string; outputFile?: string }> { - if (content.length <= threshold) { - return { content }; - } - - let lines = content.split('\n'); - let fileContent = content; - - // If the content is long but has few lines, wrap it to enable line-based truncation. - if (lines.length <= truncateLines) { - const wrapWidth = 120; // A reasonable width for wrapping. - const wrappedLines: string[] = []; - for (const line of lines) { - if (line.length > wrapWidth) { - for (let i = 0; i < line.length; i += wrapWidth) { - wrappedLines.push(line.substring(i, i + wrapWidth)); - } - } else { - wrappedLines.push(line); - } - } - lines = wrappedLines; - fileContent = lines.join('\n'); - } - - const head = Math.floor(truncateLines / 5); - const beginning = lines.slice(0, head); - const end = lines.slice(-(truncateLines - head)); - const truncatedContent = - beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n'); - - // Sanitize callId to prevent path traversal. - const safeFileName = `${path.basename(callId)}.output`; - const outputFile = path.join(projectTempDir, safeFileName); - try { - await fs.writeFile(outputFile, fileContent); - - return { - content: `Tool output was too large and has been truncated. -The full output has been saved to: ${outputFile} -To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. -The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed. -This allows you to efficiently examine different parts of the output without loading the entire file. -Truncated part of the output: -${truncatedContent}`, - outputFile, - }; - } catch (_error) { - return { - content: - truncatedContent + `\n[Note: Could not save full output to file]`, - }; - } -} - interface CoreToolSchedulerOptions { config: Config; outputUpdateHandler?: OutputUpdateHandler; @@ -509,6 +442,7 @@ export class CoreToolScheduler { : undefined; // Preserve diff for cancelled edit operations + // Preserve plan content for cancelled plan operations let resultDisplay: ToolResultDisplay | undefined = undefined; if (currentCall.status === 'awaiting_approval') { const waitingCall = currentCall as WaitingToolCall; @@ -520,6 +454,13 @@ export class CoreToolScheduler { waitingCall.confirmationDetails.originalContent, newContent: waitingCall.confirmationDetails.newContent, }; + } else if (waitingCall.confirmationDetails.type === 'plan') { + resultDisplay = { + type: 'plan_summary', + message: 'Plan was rejected. Remaining in plan mode.', + plan: waitingCall.confirmationDetails.plan, + rejected: true, + }; } } else if (currentCall.status === 'executing') { // If the tool was streaming live output, preserve the latest @@ -873,7 +814,13 @@ export class CoreToolScheduler { this.config.getApprovalMode() === ApprovalMode.PLAN; const isExitPlanModeTool = reqInfo.name === 'exit_plan_mode'; - if (isPlanMode && !isExitPlanModeTool) { + // ask_user_question needs the confirmation flow even in plan mode + // so the user can actually answer the questions + const isAskUserQuestionTool = + confirmationDetails && + confirmationDetails.type === 'ask_user_question'; + + if (isPlanMode && !isExitPlanModeTool && !isAskUserQuestionTool) { if (confirmationDetails) { this.setStatusInternal(reqInfo.callId, 'error', { callId: reqInfo.callId, @@ -890,8 +837,14 @@ export class CoreToolScheduler { this.setStatusInternal(reqInfo.callId, 'scheduled'); } } else if ( - this.config.getApprovalMode() === ApprovalMode.YOLO || - doesToolInvocationMatch(toolCall.tool, invocation, allowedTools) + (this.config.getApprovalMode() === ApprovalMode.YOLO || + doesToolInvocationMatch( + toolCall.tool, + invocation, + allowedTools, + )) && + // Even in YOLO mode, ask_user_question tool requires user confirmation to ensure the user always has a chance to respond to questions + confirmationDetails.type !== 'ask_user_question' ) { this.setToolCallOutcome( reqInfo.callId, @@ -1201,43 +1154,9 @@ export class CoreToolScheduler { } if (toolResult.error === undefined) { - let content = toolResult.llmContent; - let outputFile: string | undefined = undefined; + const content = toolResult.llmContent; const contentLength = typeof content === 'string' ? content.length : undefined; - if ( - typeof content === 'string' && - toolName === ShellTool.Name && - this.config.getEnableToolOutputTruncation() && - this.config.getTruncateToolOutputThreshold() > 0 && - this.config.getTruncateToolOutputLines() > 0 - ) { - const originalContentLength = content.length; - const threshold = this.config.getTruncateToolOutputThreshold(); - const lines = this.config.getTruncateToolOutputLines(); - const truncatedResult = await truncateAndSaveToFile( - content, - callId, - this.config.storage.getProjectTempDir(), - threshold, - lines, - ); - content = truncatedResult.content; - outputFile = truncatedResult.outputFile; - - if (outputFile) { - logToolOutputTruncated( - this.config, - new ToolOutputTruncatedEvent(scheduledCall.request.prompt_id, { - toolName, - originalContentLength, - truncatedContentLength: content.length, - threshold, - lines, - }), - ); - } - } const response = convertToFunctionResponse(toolName, callId, content); const successResponse: ToolCallResponseInfo = { @@ -1246,7 +1165,6 @@ export class CoreToolScheduler { resultDisplay: toolResult.returnDisplay, error: undefined, errorType: undefined, - outputFile, contentLength, }; this.setStatusInternal(callId, 'success', successResponse); diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 4f69b62eb..8422968e7 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -1718,4 +1718,73 @@ describe('GeminiChat', async () => { ]); }); }); + + describe('stripOrphanedUserEntriesFromHistory', () => { + it('should pop a single trailing user entry', () => { + chat.setHistory([ + { role: 'user', parts: [{ text: 'first message' }] }, + { role: 'model', parts: [{ text: 'first response' }] }, + { role: 'user', parts: [{ text: 'orphaned message' }] }, + ]); + + chat.stripOrphanedUserEntriesFromHistory(); + + expect(chat.getHistory()).toEqual([ + { role: 'user', parts: [{ text: 'first message' }] }, + { role: 'model', parts: [{ text: 'first response' }] }, + ]); + }); + + it('should pop multiple trailing user entries', () => { + chat.setHistory([ + { role: 'user', parts: [{ text: 'query' }] }, + { + role: 'model', + parts: [{ functionCall: { name: 'tool', args: {} } }], + }, + { role: 'user', parts: [{ text: 'IDE context' }] }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool', + response: { result: 'ok' }, + }, + }, + ], + }, + ]); + + chat.stripOrphanedUserEntriesFromHistory(); + + expect(chat.getHistory()).toEqual([ + { role: 'user', parts: [{ text: 'query' }] }, + { + role: 'model', + parts: [{ functionCall: { name: 'tool', args: {} } }], + }, + ]); + }); + + it('should be a no-op when last entry is a model response', () => { + const history = [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'hi' }] }, + ]; + chat.setHistory([...history]); + + chat.stripOrphanedUserEntriesFromHistory(); + + expect(chat.getHistory()).toEqual(history); + }); + + it('should handle empty history', () => { + chat.setHistory([]); + + chat.stripOrphanedUserEntriesFromHistory(); + + expect(chat.getHistory()).toEqual([]); + }); + }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index f58bcdb61..03b78f06c 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -571,6 +571,20 @@ export class GeminiChat { .filter((content) => content.parts && content.parts.length > 0); } + /** + * Pop all orphaned trailing user entries from chat history. + * In a valid conversation the last entry is always a model response; + * any trailing user entries are leftovers from a request that failed. + */ + stripOrphanedUserEntriesFromHistory(): void { + while ( + this.history.length > 0 && + this.history[this.history.length - 1]!.role === 'user' + ) { + this.history.pop(); + } + } + setTools(tools: Tool[]): void { this.generationConfig.tools = tools; } diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 989b61c37..29bcf99b8 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -94,7 +94,6 @@ describe('executeToolCall', () => { callId: 'call1', error: undefined, errorType: undefined, - outputFile: undefined, resultDisplay: 'Success!', contentLength: typeof toolResult.llmContent === 'string' @@ -299,7 +298,6 @@ describe('executeToolCall', () => { callId: 'call6', error: undefined, errorType: undefined, - outputFile: undefined, resultDisplay: 'Image processed', contentLength: undefined, responseParts: [ diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 115d6dc0d..46e84e672 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -1014,6 +1014,20 @@ describe('OpenAIContentConverter', () => { }); }); + describe('convertOpenAIResponseToGemini', () => { + it('should handle empty choices array without crashing', () => { + const response = converter.convertOpenAIResponseToGemini({ + object: 'chat.completion', + id: 'chatcmpl-empty', + created: 123, + model: 'test-model', + choices: [], + } as unknown as OpenAI.Chat.ChatCompletion); + + expect(response.candidates).toEqual([]); + }); + }); + describe('OpenAI -> Gemini reasoning content', () => { it('should convert reasoning_content to a thought part for non-streaming responses', () => { const response = converter.convertOpenAIResponseToGemini({ diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index d90737d10..91d0b31fb 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -821,42 +821,60 @@ export class OpenAIContentConverter { convertOpenAIResponseToGemini( openaiResponse: OpenAI.Chat.ChatCompletion, ): GenerateContentResponse { - const choice = openaiResponse.choices[0]; + const choice = openaiResponse.choices?.[0]; const response = new GenerateContentResponse(); - const parts: Part[] = []; + if (choice) { + const parts: Part[] = []; - // Handle reasoning content (thoughts) - const reasoningText = - (choice.message as ExtendedCompletionMessage).reasoning_content ?? - (choice.message as ExtendedCompletionMessage).reasoning; - if (reasoningText) { - parts.push({ text: reasoningText, thought: true }); - } + // Handle reasoning content (thoughts) + const reasoningText = + (choice.message as ExtendedCompletionMessage).reasoning_content ?? + (choice.message as ExtendedCompletionMessage).reasoning; + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } - // Handle text content - if (choice.message.content) { - parts.push({ text: choice.message.content }); - } + // Handle text content + if (choice.message.content) { + parts.push({ text: choice.message.content }); + } - // Handle tool calls - if (choice.message.tool_calls) { - for (const toolCall of choice.message.tool_calls) { - if (toolCall.function) { - let args: Record = {}; - if (toolCall.function.arguments) { - args = safeJsonParse(toolCall.function.arguments, {}); + // Handle tool calls + if (choice.message.tool_calls) { + for (const toolCall of choice.message.tool_calls) { + if (toolCall.function) { + let args: Record = {}; + if (toolCall.function.arguments) { + args = safeJsonParse(toolCall.function.arguments, {}); + } + + parts.push({ + functionCall: { + id: toolCall.id, + name: toolCall.function.name, + args, + }, + }); } - - parts.push({ - functionCall: { - id: toolCall.id, - name: toolCall.function.name, - args, - }, - }); } } + + response.candidates = [ + { + content: { + parts, + role: 'model' as const, + }, + finishReason: this.mapOpenAIFinishReasonToGemini( + choice.finish_reason || 'stop', + ), + index: 0, + safetyRatings: [], + }, + ]; + } else { + response.candidates = []; } response.responseId = openaiResponse.id; @@ -864,20 +882,6 @@ export class OpenAIContentConverter { ? openaiResponse.created.toString() : new Date().getTime().toString(); - response.candidates = [ - { - content: { - parts, - role: 'model' as const, - }, - finishReason: this.mapOpenAIFinishReasonToGemini( - choice.finish_reason || 'stop', - ), - index: 0, - safetyRatings: [], - }, - ]; - response.modelVersion = this.model; response.promptFeedback = { safetyRatings: [] }; diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 5c6cdc682..4e2d42bd8 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -255,9 +255,23 @@ export class ContentGenerationPipeline { .candidates?.[0]?.finishReason; if (isFinishChunk) { - // This is a finish reason chunk - collectedGeminiResponses.push(response); - setPendingFinish(response); + if (hasPendingFinish) { + // Duplicate finish chunk (e.g. from OpenRouter providers that send two + // finish_reason chunks for tool calls). The streaming tool call parser + // was already reset after the first finish chunk, so the second one + // carries no functionCall parts. Merge only usageMetadata and keep the + // candidates (including functionCall parts) from the first finish chunk. + const lastResponse = + collectedGeminiResponses[collectedGeminiResponses.length - 1]; + if (response.usageMetadata) { + lastResponse.usageMetadata = response.usageMetadata; + } + setPendingFinish(lastResponse); + } else { + // This is a finish reason chunk + collectedGeminiResponses.push(response); + setPendingFinish(response); + } return false; // Don't yield yet, wait for potential subsequent chunks to merge } else if (hasPendingFinish) { // We have a pending finish chunk, merge this chunk's data into it diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index e1ecb61b6..c64ee436d 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -789,7 +789,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { expect(result.max_tokens).toBe(1000); // Should remain unchanged }); - it('should not add max_tokens when not present in request', () => { + it('should set conservative max_tokens default when not present in request', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { model: 'qwen3-max', messages: [{ role: 'user', content: 'Hello' }], @@ -798,31 +798,35 @@ describe('DashScopeOpenAICompatibleProvider', () => { const result = provider.buildRequest(request, 'test-prompt-id'); - expect(result.max_tokens).toBeUndefined(); // Should remain undefined + // Should set conservative default (min of model limit and DEFAULT_OUTPUT_TOKEN_LIMIT) + // qwen3-max has 64K output limit, so min(64K, 32K) = 32K + expect(result.max_tokens).toBe(32000); }); - it('should handle null max_tokens parameter', () => { + it('should set conservative max_tokens when null is provided', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { model: 'qwen3-max', messages: [{ role: 'user', content: 'Hello' }], - max_tokens: null, + max_tokens: null as unknown as undefined, }; const result = provider.buildRequest(request, 'test-prompt-id'); - expect(result.max_tokens).toBeNull(); // Should remain null + // null is treated as not configured, so set conservative default + expect(result.max_tokens).toBe(32000); }); - it('should use default output limit for unknown models', () => { + it('should respect user max_tokens for unknown models', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { model: 'unknown-model', messages: [{ role: 'user', content: 'Hello' }], - max_tokens: 10000, // Exceeds the default limit + max_tokens: 40000, // User explicitly sets 40K }; const result = provider.buildRequest(request, 'test-prompt-id'); - expect(result.max_tokens).toBe(8192); // Should be limited to default output limit (8K) + // Unknown models: respect user's configuration (backend may support it) + expect(result.max_tokens).toBe(40000); }); it('should preserve other request parameters when limiting max_tokens', () => { diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index a889401cf..a94ad0be3 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -9,27 +9,20 @@ import { DEFAULT_DASHSCOPE_BASE_URL, } from '../constants.js'; import type { - OpenAICompatibleProvider, DashScopeRequestMetadata, ChatCompletionContentPartTextWithCache, ChatCompletionContentPartWithCache, ChatCompletionToolWithCache, } from './types.js'; import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js'; -import { tokenLimit } from '../../tokenLimits.js'; - -export class DashScopeOpenAICompatibleProvider - implements OpenAICompatibleProvider -{ - private contentGeneratorConfig: ContentGeneratorConfig; - private cliConfig: Config; +import { DefaultOpenAICompatibleProvider } from './default.js'; +export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider { constructor( contentGeneratorConfig: ContentGeneratorConfig, cliConfig: Config, ) { - this.cliConfig = cliConfig; - this.contentGeneratorConfig = contentGeneratorConfig; + super(contentGeneratorConfig, cliConfig); } static isDashScopeProvider( @@ -44,7 +37,7 @@ export class DashScopeOpenAICompatibleProvider return /([\w-]+\.)?dashscope(-intl)?\.aliyuncs\.com/i.test(baseUrl); } - buildHeaders(): Record { + override buildHeaders(): Record { const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { authType, customHeaders } = this.contentGeneratorConfig; @@ -60,7 +53,7 @@ export class DashScopeOpenAICompatibleProvider : defaultHeaders; } - buildClient(): OpenAI { + override buildClient(): OpenAI { const { apiKey, baseUrl = DEFAULT_DASHSCOPE_BASE_URL, @@ -98,7 +91,7 @@ export class DashScopeOpenAICompatibleProvider * @param userPromptId - Unique identifier for the user prompt for session tracking * @returns Configured request with DashScope-specific parameters applied */ - buildRequest( + override buildRequest( request: OpenAI.Chat.ChatCompletionCreateParams, userPromptId: string, ): OpenAI.Chat.ChatCompletionCreateParams { @@ -116,8 +109,9 @@ export class DashScopeOpenAICompatibleProvider tools = updatedTools; } - // Apply output token limits based on model capabilities - // This ensures max_tokens doesn't exceed the model's maximum output limit + // Apply output token limits using parent class logic + // Uses conservative default (min of model limit and DEFAULT_OUTPUT_TOKEN_LIMIT) + // to preserve input quota when user hasn't explicitly configured max_tokens const requestWithTokenLimits = this.applyOutputTokenLimit(request); const extraBody = this.contentGeneratorConfig.extra_body; @@ -155,7 +149,7 @@ export class DashScopeOpenAICompatibleProvider }; } - getDefaultGenerationConfig(): GenerateContentConfig { + override getDefaultGenerationConfig(): GenerateContentConfig { return { temperature: 0.3, }; @@ -316,41 +310,6 @@ export class DashScopeOpenAICompatibleProvider return false; } - /** - * Apply output token limit to a request's max_tokens parameter. - * - * Ensures that existing max_tokens parameters don't exceed the model's maximum output - * token limit. Only modifies max_tokens when already present in the request. - * - * @param request - The chat completion request parameters - * @returns The request with max_tokens adjusted to respect the model's limits (if present) - */ - private applyOutputTokenLimit< - T extends { max_tokens?: number | null; model: string }, - >(request: T): T { - const currentMaxTokens = request.max_tokens; - - // Only process if max_tokens is already present in the request - if (currentMaxTokens === undefined || currentMaxTokens === null) { - return request; // No max_tokens parameter, return unchanged - } - - // Dynamically calculate output token limit using tokenLimit function - // This ensures we always use the latest model-specific limits without relying on user configuration - const modelLimit = tokenLimit(request.model, 'output'); - - // If max_tokens exceeds the model limit, cap it to the model's limit - if (currentMaxTokens > modelLimit) { - return { - ...request, - max_tokens: modelLimit, - }; - } - - // If max_tokens is within the limit, return the request unchanged - return request; - } - /** * Check if cache control should be disabled based on configuration. * diff --git a/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts b/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts index 9a69cd326..f4ced4c45 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type OpenAI from 'openai'; import { DeepSeekOpenAICompatibleProvider } from './deepseek.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import type { Config } from '../../../config/config.js'; @@ -17,6 +18,7 @@ vi.mock('openai', () => ({ })); describe('DeepSeekOpenAICompatibleProvider', () => { + let provider: DeepSeekOpenAICompatibleProvider; let mockContentGeneratorConfig: ContentGeneratorConfig; let mockCliConfig: Config; @@ -32,6 +34,11 @@ describe('DeepSeekOpenAICompatibleProvider', () => { mockCliConfig = { getCliVersion: vi.fn().mockReturnValue('1.0.0'), } as unknown as Config; + + provider = new DeepSeekOpenAICompatibleProvider( + mockContentGeneratorConfig, + mockCliConfig, + ); }); describe('isDeepSeekProvider', () => { @@ -54,12 +61,102 @@ describe('DeepSeekOpenAICompatibleProvider', () => { }); }); + describe('buildRequest', () => { + const userPromptId = 'prompt-123'; + + it('converts array content into a string', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'deepseek-chat', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' world' }, + ], + }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + expect(result.messages).toHaveLength(1); + expect(result.messages?.[0]).toEqual({ + role: 'user', + content: 'Hello\n\n world', + }); + expect(originalRequest.messages?.[0].content).toEqual([ + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' world' }, + ]); + }); + + it('leaves string content unchanged', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'deepseek-chat', + messages: [ + { + role: 'user', + content: 'Hello world', + }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + expect(result.messages?.[0].content).toBe('Hello world'); + }); + + it('handles plain string parts in the content array', () => { + const originalRequest = { + model: 'deepseek-chat', + messages: [ + { + role: 'user' as const, + content: [ + 'Hello', + { type: 'text' as const, text: ' world' }, + ] as unknown as OpenAI.Chat.ChatCompletionContentPart[], + }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + expect(result.messages?.[0]).toEqual({ + role: 'user', + content: 'Hello\n\n world', + }); + }); + + it('replaces non-text parts with a placeholder', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'deepseek-chat', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello ' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/image.png' }, + }, + ], + }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + expect(result.messages?.[0]).toEqual({ + role: 'user', + content: 'Hello \n\n[Unsupported content type: image_url]', + }); + }); + }); + describe('getDefaultGenerationConfig', () => { it('returns temperature 0', () => { - const provider = new DeepSeekOpenAICompatibleProvider( - mockContentGeneratorConfig, - mockCliConfig, - ); expect(provider.getDefaultGenerationConfig()).toEqual({ temperature: 0, }); diff --git a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts index 0e246725f..e34dc724d 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type OpenAI from 'openai'; import type { Config } from '../../../config/config.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import { DefaultOpenAICompatibleProvider } from './default.js'; @@ -25,6 +26,63 @@ export class DeepSeekOpenAICompatibleProvider extends DefaultOpenAICompatiblePro return baseUrl.toLowerCase().includes('api.deepseek.com'); } + /** + * DeepSeek's API requires message content to be a plain string, not an + * array of content parts. Flatten any text-part arrays into joined strings + * and reject non-text parts that DeepSeek cannot handle. + */ + override buildRequest( + request: OpenAI.Chat.ChatCompletionCreateParams, + userPromptId: string, + ): OpenAI.Chat.ChatCompletionCreateParams { + const baseRequest = super.buildRequest(request, userPromptId); + if (!baseRequest.messages?.length) { + return baseRequest; + } + + const messages = baseRequest.messages.map((message) => { + if (!('content' in message)) { + return message; + } + + const { content } = message; + + if ( + typeof content === 'string' || + content === null || + content === undefined + ) { + return message; + } + + if (!Array.isArray(content)) { + return message; + } + + const text = content + .map((part) => { + if (typeof part === 'string') { + return part; + } + if (part.type === 'text') { + return part.text ?? ''; + } + return `[Unsupported content type: ${part.type}]`; + }) + .join('\n\n'); + + return { + ...message, + content: text, + } as OpenAI.Chat.ChatCompletionMessageParam; + }); + + return { + ...baseRequest, + messages, + }; + } + override getDefaultGenerationConfig(): GenerateContentConfig { return { temperature: 0, diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts index cc227b464..ce46a3621 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts @@ -193,6 +193,76 @@ describe('DefaultOpenAICompatibleProvider', () => { expect(result).not.toBe(originalRequest); // Should be a new object }); + it('should set conservative max_tokens default when not configured', () => { + const requestWithoutMaxTokens: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello' }], + }; + + const result = provider.buildRequest( + requestWithoutMaxTokens, + 'prompt-id', + ); + + // Should set conservative default (min of model limit and DEFAULT_OUTPUT_TOKEN_LIMIT) + // GPT-4 has 16K output limit, so min(16K, 32K) = 16K + expect(result.max_tokens).toBe(16384); + }); + + it('should respect user max_tokens for unknown models (deployment aliases, self-hosted)', () => { + // Unknown models: user config is respected entirely (backend may support larger limits) + const request: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'unknown-model', + messages: [{ role: 'user', content: 'Hello' }], + max_tokens: 100000, + }; + + const result = provider.buildRequest(request, 'prompt-id'); + + // User's 100K setting is preserved for unknown models + expect(result.max_tokens).toBe(100000); + }); + + it('should use conservative default for unknown models when max_tokens not configured', () => { + // Unknown models without user config: use DEFAULT_OUTPUT_TOKEN_LIMIT + const request: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'custom-deployment-alias', + messages: [{ role: 'user', content: 'Hello' }], + }; + + const result = provider.buildRequest(request, 'prompt-id'); + + // Uses conservative default (32K) + expect(result.max_tokens).toBe(32000); + }); + + it('should cap max_tokens for known models to avoid API errors', () => { + // Known models (GPT-4): user config is capped at model limit + const request: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello' }], + max_tokens: 100000, // Exceeds GPT-4's 16K limit + }; + + const result = provider.buildRequest(request, 'prompt-id'); + + // Capped to GPT-4's output limit (16K) + expect(result.max_tokens).toBe(16384); + }); + + it('should treat null max_tokens as not configured', () => { + const request: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello' }], + max_tokens: null as unknown as undefined, + }; + + const result = provider.buildRequest(request, 'prompt-id'); + + // GPT-4 has 16K output limit, so conservative default is still 16K + expect(result.max_tokens).toBe(16384); + }); + it('should preserve all sampling parameters', () => { const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { model: 'gpt-3.5-turbo', @@ -230,7 +300,10 @@ describe('DefaultOpenAICompatibleProvider', () => { const result = provider.buildRequest(minimalRequest, 'prompt-id'); - expect(result).toEqual(minimalRequest); + // Should set conservative max_tokens default + expect(result.model).toBe('gpt-4'); + expect(result.messages).toEqual(minimalRequest.messages); + expect(result.max_tokens).toBe(16384); // GPT-4 has 16K limit, min(16K, 32K) = 16K }); it('should handle streaming requests', () => { @@ -242,8 +315,11 @@ describe('DefaultOpenAICompatibleProvider', () => { const result = provider.buildRequest(streamingRequest, 'prompt-id'); - expect(result).toEqual(streamingRequest); + // Should set conservative max_tokens default while preserving stream + expect(result.model).toBe('gpt-4'); + expect(result.messages).toEqual(streamingRequest.messages); expect(result.stream).toBe(true); + expect(result.max_tokens).toBe(16384); // GPT-4 has 16K limit, min(16K, 32K) = 16K }); it('should not modify the original request object', () => { @@ -287,6 +363,7 @@ describe('DefaultOpenAICompatibleProvider', () => { expect(result).toEqual({ ...originalRequest, + max_tokens: 16384, // GPT-4 has 16K limit, min(16K, 32K) = 16K custom_param: 'custom_value', nested: { key: 'value' }, }); @@ -301,7 +378,11 @@ describe('DefaultOpenAICompatibleProvider', () => { const result = provider.buildRequest(originalRequest, 'prompt-id'); - expect(result).toEqual(originalRequest); + // Should preserve original params and set conservative max_tokens default + expect(result.model).toBe('gpt-4'); + expect(result.messages).toEqual(originalRequest.messages); + expect(result.temperature).toBe(0.7); + expect(result.max_tokens).toBe(16384); // GPT-4 has 16K limit, min(16K, 32K) = 16K expect(result).not.toHaveProperty('custom_param'); }); }); diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 783c962d1..ec7f6946a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -5,6 +5,11 @@ import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js'; import type { OpenAICompatibleProvider } from './types.js'; import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js'; +import { + tokenLimit, + DEFAULT_OUTPUT_TOKEN_LIMIT, + hasExplicitOutputLimit, +} from '../../tokenLimits.js'; /** * Default provider for standard OpenAI-compatible APIs @@ -65,9 +70,13 @@ export class DefaultOpenAICompatibleProvider _userPromptId: string, ): OpenAI.Chat.ChatCompletionCreateParams { const extraBody = this.contentGeneratorConfig.extra_body; - // Default provider doesn't need special enhancements, just pass through all parameters + + // Apply output token limits to ensure max_tokens is set appropriately + // This prevents occupying too much context window with output reservation + const requestWithTokenLimits = this.applyOutputTokenLimit(request); + return { - ...request, // Preserve all original parameters including sampling params + ...requestWithTokenLimits, ...(extraBody ? extraBody : {}), }; } @@ -75,4 +84,70 @@ export class DefaultOpenAICompatibleProvider getDefaultGenerationConfig(): GenerateContentConfig { return {}; } + + /** + * Apply output token limit to a request's max_tokens parameter. + * + * Purpose: + * Some APIs (e.g., OpenAI-compatible) default to a very small max_tokens value, + * which can cause responses to be truncated mid-output. This function ensures + * a reasonable default is set while respecting user configuration. + * + * Logic: + * 1. If user explicitly configured max_tokens: + * - For known models (in OUTPUT_PATTERNS): use the user's value, but cap at + * model's max output limit to avoid API errors + * (input + max_output > contextWindowSize would cause 400 errors on some APIs) + * - For unknown models (deployment aliases, self-hosted): respect user's + * configured value entirely (backend may support larger limits) + * 2. If user didn't configure max_tokens: + * - Use min(modelLimit, DEFAULT_OUTPUT_TOKEN_LIMIT) + * - This provides a conservative default (32K) that avoids truncating output + * while preserving input quota (not occupying too much context window) + * 3. If model has no specific limit (tokenLimit returns default): + * - Still apply DEFAULT_OUTPUT_TOKEN_LIMIT as safeguard + * + * Examples: + * - User sets 4K, known model limit 64K → uses 4K (respects user preference) + * - User sets 100K, known model limit 64K → uses 64K (capped to avoid API error) + * - User sets 100K, unknown model → uses 100K (respects user, backend may support it) + * - User not set, model limit 64K → uses 32K (conservative default) + * - User not set, model limit 8K → uses 8K (model limit is lower) + * + * @param request - The chat completion request parameters + * @returns The request with max_tokens adjusted according to the logic + */ + protected applyOutputTokenLimit< + T extends { max_tokens?: number | null; model: string }, + >(request: T): T { + const userMaxTokens = request.max_tokens; + + // Get model-specific output limit and check if model is known + const modelLimit = tokenLimit(request.model, 'output'); + const isKnownModel = hasExplicitOutputLimit(request.model); + + // Determine the effective max_tokens + let effectiveMaxTokens: number; + + if (userMaxTokens !== undefined && userMaxTokens !== null) { + // User explicitly configured max_tokens + if (isKnownModel) { + // Known model: respect user config but cap at model limit to avoid API errors + effectiveMaxTokens = Math.min(userMaxTokens, modelLimit); + } else { + // Unknown model (deployment aliases, self-hosted): respect user's value + // The backend may support larger limits than our default + effectiveMaxTokens = userMaxTokens; + } + } else { + // User didn't configure, use conservative default: + // min(model-specific limit, DEFAULT_OUTPUT_TOKEN_LIMIT) + effectiveMaxTokens = Math.min(modelLimit, DEFAULT_OUTPUT_TOKEN_LIMIT); + } + + return { + ...request, + max_tokens: effectiveMaxTokens, + }; + } } diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 176efeb60..b0947e98f 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -80,6 +80,35 @@ describe('Core System Prompt (prompts.ts)', () => { expect(prompt).toMatchSnapshot(); // Snapshot the combined prompt }); + it('should append extra system prompt instructions after user memory when provided', () => { + vi.stubEnv('SANDBOX', undefined); + const memory = 'Remember the project conventions.'; + const appendInstruction = 'Always answer in exactly one sentence.'; + const prompt = getCoreSystemPrompt(memory, undefined, appendInstruction); + + expect(prompt).toContain(`\n\n---\n\n${memory}`); + expect(prompt).toContain(`\n\n---\n\n${appendInstruction}`); + expect(prompt.indexOf(memory)).toBeLessThan( + prompt.indexOf(appendInstruction), + ); + }); + + it('should append extra instructions after a custom system prompt and user memory', () => { + const customInstruction = 'You are a release manager.'; + const userMemory = 'The repo uses pnpm.'; + const appendInstruction = 'Only report blocking issues.'; + + const result = getCustomSystemPrompt( + customInstruction, + userMemory, + appendInstruction, + ); + + expect(result).toBe( + [customInstruction, userMemory, appendInstruction].join('\n\n---\n\n'), + ); + }); + it('should include sandbox-specific instructions when SANDBOX env var is set', () => { vi.stubEnv('SANDBOX', 'true'); // Generic sandbox value const prompt = getCoreSystemPrompt(); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 5e13cf208..178372b48 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -72,11 +72,13 @@ export function resolvePathFromEnv(envVar?: string): { * * @param customInstruction - Custom system instruction (ContentUnion from @google/genai) * @param userMemory - User memory to append - * @returns Processed custom system instruction with user memory appended + * @param appendInstruction - Extra instructions to append after user memory + * @returns Processed custom system instruction with user memory and extra append instructions applied */ export function getCustomSystemPrompt( customInstruction: GenerateContentConfig['systemInstruction'], userMemory?: string, + appendInstruction?: string, ): string { // Extract text from custom instruction let instructionText = ''; @@ -100,17 +102,20 @@ export function getCustomSystemPrompt( } // Append user memory using the same pattern as getCoreSystemPrompt - const memorySuffix = - userMemory && userMemory.trim().length > 0 - ? `\n\n---\n\n${userMemory.trim()}` - : ''; + const memorySuffix = buildSystemPromptSuffix(userMemory); - return `${instructionText}${memorySuffix}`; + return `${instructionText}${memorySuffix}${buildSystemPromptSuffix(appendInstruction)}`; +} + +function buildSystemPromptSuffix(text?: string): string { + const trimmed = text?.trim(); + return trimmed ? `\n\n---\n\n${trimmed}` : ''; } export function getCoreSystemPrompt( userMemory?: string, model?: string, + appendInstruction?: string, ): string { // if QWEN_SYSTEM_MD is set (and not 0|false), override system prompt from file // default path is .qwen/system.md but can be modified via custom path in QWEN_SYSTEM_MD @@ -200,6 +205,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ${ToolNames.ASK_USER_QUESTION} tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -221,7 +229,7 @@ IMPORTANT: Always use the ${ToolNames.TODO_WRITE} tool to plan and track tasks t **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are '${ToolNames.WRITE_FILE}', '${ToolNames.EDIT}' and '${ToolNames.SHELL}'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ${ToolNames.ASK_USER_QUESTION} tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -335,10 +343,11 @@ Your core function is efficient and safe assistance. Balance extreme conciseness const memorySuffix = userMemory && userMemory.trim().length > 0 - ? `\n\n---\n\n${userMemory.trim()}` + ? buildSystemPromptSuffix(userMemory) : ''; + const appendSuffix = buildSystemPromptSuffix(appendInstruction); - return `${basePrompt}${memorySuffix}`; + return `${basePrompt}${memorySuffix}${appendSuffix}`; } /** @@ -852,7 +861,7 @@ export function getPlanModeSystemReminder(planOnly = false): string { return ` Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should: 1. Answer the user's query comprehensively -2. When you're done researching, present your plan ${planOnly ? 'directly' : `by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan`}. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. +2. When you're done researching, present your plan ${planOnly ? 'directly' : `by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan`}. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. Use ${ToolNames.ASK_USER_QUESTION} if you need to clarify approaches. `; } diff --git a/packages/core/src/core/tokenLimits.test.ts b/packages/core/src/core/tokenLimits.test.ts index edea10a10..bc59a6332 100644 --- a/packages/core/src/core/tokenLimits.test.ts +++ b/packages/core/src/core/tokenLimits.test.ts @@ -108,11 +108,11 @@ describe('tokenLimit', () => { }); describe('OpenAI', () => { - it('should return 400K for GPT-5.x (latest)', () => { - expect(tokenLimit('gpt-5')).toBe(400000); - expect(tokenLimit('gpt-5-mini')).toBe(400000); - expect(tokenLimit('gpt-5.2')).toBe(400000); - expect(tokenLimit('gpt-5.2-pro')).toBe(400000); + it('should return 272K for GPT-5.x (latest)', () => { + expect(tokenLimit('gpt-5')).toBe(272000); + expect(tokenLimit('gpt-5-mini')).toBe(272000); + expect(tokenLimit('gpt-5.2')).toBe(272000); + expect(tokenLimit('gpt-5.2-pro')).toBe(272000); }); it('should return 128K for legacy GPT (fallback)', () => { @@ -284,12 +284,14 @@ describe('tokenLimit with output type', () => { describe('other output limits', () => { it('should return correct output limits for DeepSeek', () => { expect(tokenLimit('deepseek-reasoner', 'output')).toBe(65536); + expect(tokenLimit('deepseek-r1', 'output')).toBe(65536); + expect(tokenLimit('deepseek-r1-0528', 'output')).toBe(65536); expect(tokenLimit('deepseek-chat', 'output')).toBe(8192); }); it('should return correct output limits for GLM', () => { - expect(tokenLimit('glm-5', 'output')).toBe(16384); - expect(tokenLimit('glm-4.7', 'output')).toBe(16384); + expect(tokenLimit('glm-5', 'output')).toBe(131072); + expect(tokenLimit('glm-4.7', 'output')).toBe(131072); }); it('should return correct output limits for MiniMax', () => { diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index d038133cb..2e923ab73 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -9,7 +9,7 @@ type TokenCount = number; export type TokenLimitType = 'input' | 'output'; export const DEFAULT_TOKEN_LIMIT: TokenCount = 131_072; // 128K (power-of-two) -export const DEFAULT_OUTPUT_TOKEN_LIMIT: TokenCount = 8_192; // 8K tokens +export const DEFAULT_OUTPUT_TOKEN_LIMIT: TokenCount = 32_000; // 32K tokens /** * Accurate numeric limits: @@ -23,6 +23,7 @@ const LIMITS = { '128k': 131_072, '200k': 200_000, // vendor-declared decimal, used by OpenAI, Anthropic, etc. '256k': 262_144, + '272k': 272_000, // vendor-declared decimal, GPT-5.x input (400K total - 128K output) '400k': 400_000, // vendor-declared decimal, used by OpenAI GPT-5.x '512k': 524_288, '1m': 1_000_000, @@ -87,7 +88,7 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ // ------------------- // OpenAI // ------------------- - [/^gpt-5/, LIMITS['400k']], // GPT-5.x: 400K + [/^gpt-5/, LIMITS['272k']], // GPT-5.x: 272K input (400K total - 128K output) [/^gpt-/, LIMITS['128k']], // GPT fallback (4o, 4.1, etc.): 128K [/^o\d/, LIMITS['200k']], // o-series (o3, o4-mini, etc.): 200K @@ -165,14 +166,16 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ [/^qwen3\.5/, LIMITS['64k']], [/^coder-model$/, LIMITS['64k']], [/^qwen3-max/, LIMITS['64k']], + [/^qwen/, LIMITS['8k']], // Qwen fallback (VL, turbo, plus, etc.): 8K // DeepSeek [/^deepseek-reasoner/, LIMITS['64k']], + [/^deepseek-r1/, LIMITS['64k']], [/^deepseek-chat/, LIMITS['8k']], // Zhipu GLM - [/^glm-5/, LIMITS['16k']], - [/^glm-4\.7/, LIMITS['16k']], + [/^glm-5/, LIMITS['128k']], + [/^glm-4\.7/, LIMITS['128k']], // MiniMax [/^minimax-m2\.5/i, LIMITS['64k']], @@ -181,6 +184,19 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ [/^kimi-k2\.5/, LIMITS['32k']], ]; +/** + * Check if a model has an explicitly defined output token limit. + * This distinguishes between models with known limits in OUTPUT_PATTERNS + * and unknown models that would fallback to DEFAULT_OUTPUT_TOKEN_LIMIT. + * + * @param model - The model name to check + * @returns true if the model has an explicit output limit definition, false if it uses the default fallback + */ +export function hasExplicitOutputLimit(model: Model): boolean { + const norm = normalize(model); + return OUTPUT_PATTERNS.some(([regex]) => regex.test(norm)); +} + /** * Return the token limit for a model string based on the specified type. * diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 08f379d68..2037081ff 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -109,7 +109,6 @@ export interface ToolCallResponseInfo { resultDisplay: ToolResultDisplay | undefined; error: Error | undefined; errorType: ToolErrorType | undefined; - outputFile?: string | undefined; contentLength?: number; } diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 98639b197..6c333c9aa 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -92,7 +92,7 @@ export interface ClaudeMarketplaceConfig { } const CLAUDE_TOOLS_MAPPING: Record = { - AskUserQuestion: 'None', + AskUserQuestion: 'AskUserQuestion', Bash: 'Shell', BashOutput: 'None', Edit: 'Edit', diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 629de747a..3af573ac7 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -624,7 +624,10 @@ export class ExtensionManager { const extension: Extension = { id: getExtensionId(config, installMetadata), name: config.name, - version: config.version, + version: + config.version || + installMetadata?.marketplaceConfig?.metadata?.version || + '1.0.0', path: effectiveExtensionPath, installMetadata, isActive: this.isEnabled(config.name, this.workspaceDir), diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index 8c31b1284..c197c34fe 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -56,6 +56,7 @@ describe('git extension helpers', () => { }); it('should clone, fetch and checkout a repo', async () => { + mockPlatform.mockReturnValue('linux'); const installMetadata = { source: 'http://my-repo.com', ref: 'my-ref', @@ -79,6 +80,50 @@ describe('git extension helpers', () => { expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD'); }); + it('should use core.symlinks=false on Windows to avoid permission errors', async () => { + mockPlatform.mockReturnValue('win32'); + const installMetadata = { + source: 'http://my-repo.com', + ref: 'my-ref', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + + await cloneFromGit(installMetadata, destination); + + expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [ + '-c', + 'core.symlinks=false', + '--depth', + '1', + ]); + }); + + it('should use core.symlinks=true on non-Windows platforms', async () => { + mockPlatform.mockReturnValue('darwin'); + const installMetadata = { + source: 'http://my-repo.com', + ref: 'my-ref', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + + await cloneFromGit(installMetadata, destination); + + expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [ + '-c', + 'core.symlinks=true', + '--depth', + '1', + ]); + }); + it('should use HEAD if ref is not provided', async () => { const installMetadata = { source: 'http://my-repo.com', diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 5ef49d35b..e0f448b90 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -75,9 +75,12 @@ export async function cloneFromGit( // We let git handle the source as is. } } + // On Windows, symlinks require elevated privileges by default, so we + // disable them to avoid "Permission denied" errors during checkout. + const symlinkValue = os.platform() === 'win32' ? 'false' : 'true'; await git.clone(sourceUrl, './', [ '-c', - 'core.symlinks=true', + `core.symlinks=${symlinkValue}`, '--depth', '1', ]); @@ -172,6 +175,7 @@ export async function checkForExtensionUpdate( } if ( !installMetadata || + installMetadata.originSource === 'Claude' || (installMetadata.type !== 'git' && installMetadata.type !== 'github-release') ) { diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 88788fc57..a483ccb38 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -40,6 +40,7 @@ vi.mock('node:fs', async (importOriginal) => { readFile: vi.fn(), readdir: vi.fn(), stat: vi.fn(), + unlink: vi.fn(), }, realpathSync: (p: string) => p, existsSync: vi.fn().mockReturnValue(false), @@ -49,10 +50,7 @@ vi.mock('node:dns', async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as object), - promises: { - ...actual.promises, - lookup: vi.fn(), - }, + lookup: vi.fn(), }; }); vi.mock('./process-utils.js'); @@ -84,6 +82,10 @@ describe('IdeClient', () => { // Mock dependencies vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir'); + vi.mocked(fs.existsSync).mockImplementation((filePath: fs.PathLike) => { + const file = String(filePath); + return file !== '/.dockerenv' && file !== '/run/.containerenv'; + }); vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode); vi.mocked(getIdeProcessInfo).mockResolvedValue({ pid: 12345, @@ -218,10 +220,18 @@ describe('IdeClient', () => { vi.mocked(fs.existsSync).mockImplementation( (filePath: fs.PathLike) => filePath === '/.dockerenv', ); - (dns.promises.lookup as unknown as Mock).mockResolvedValue({ - address: '192.168.65.254', - family: 4, - }); + (dns.lookup as unknown as Mock).mockImplementation( + ( + _hostname: string, + callback: ( + err: Error | null, + address?: string, + family?: number, + ) => void, + ) => { + callback(null, '192.168.65.254', 4); + }, + ); mockClient.connect .mockRejectedValueOnce(new Error('localhost unreachable')) .mockResolvedValueOnce(undefined); @@ -248,6 +258,85 @@ describe('IdeClient', () => { delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); + it('should try a newer lock-file port when the configured port is stale', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1111'; + const primaryConfig = { + port: '1111', + authToken: 'stale-token', + workspacePath: '/test/workspace', + }; + const fallbackConfig = { + port: '2222', + authToken: 'fresh-token', + workspacePath: '/test/workspace', + }; + vi.mocked(fs.promises.readFile).mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file === path.join('/home/test', '.qwen', 'ide', '1111.lock')) { + return JSON.stringify(primaryConfig); + } + if (file === path.join('/home/test', '.qwen', 'ide', '2222.lock')) { + return JSON.stringify(fallbackConfig); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['1111.lock', '2222.lock']); + ( + vi.mocked(fs.promises.stat) as Mock< + (path: fs.PathLike) => Promise + > + ).mockImplementation(async (filePath: fs.PathLike) => { + const now = Date.now(); + const file = String(filePath); + return { + mtimeMs: file.endsWith('2222.lock') ? now : now - 1000, + } as fs.Stats; + }); + vi.mocked(fs.existsSync).mockImplementation( + (filePath: fs.PathLike) => String(filePath) === '/test/workspace', + ); + mockClient.request.mockResolvedValue({ tools: [] }); + mockClient.connect + .mockRejectedValueOnce(new Error('stale port')) + .mockResolvedValueOnce(undefined); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith( + 1, + new URL('http://127.0.0.1:1111/mcp'), + expect.objectContaining({ + requestInit: { + headers: { + Authorization: 'Bearer stale-token', + }, + }, + }), + ); + expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith( + 2, + new URL('http://127.0.0.1:2222/mcp'), + expect.objectContaining({ + requestInit: { + headers: { + Authorization: 'Bearer fresh-token', + }, + }, + }), + ); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; + }); + it('should connect using stdio when stdio config is in environment variables', async () => { vi.mocked(fs.promises.readFile).mockRejectedValue( new Error('File not found'), @@ -342,6 +431,24 @@ describe('IdeClient', () => { delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); + it('should not scan the lock directory when the env port lock file exists', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234'; + const config = { port: '1234', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + + const ideClient = await IdeClient.getInstance(); + vi.mocked(fs.promises.readdir).mockClear(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(config); + expect(fs.promises.readdir).not.toHaveBeenCalled(); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; + }); + it('should return undefined if no config files are found', async () => { vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found')); @@ -424,6 +531,102 @@ describe('IdeClient', () => { delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); + it('should keep a live lock file even when it is older than 7 days', async () => { + const liveConfig = { + port: '1000', + workspacePath: '/test/workspace', + ppid: 4242, + }; + const oldTime = Date.now() - 8 * 24 * 60 * 60 * 1000; + + vi.mocked(fs.promises.readFile).mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) { + throw new Error('not found'); + } + if (file === path.join('/home/test', '.qwen', 'ide', '1000.lock')) { + return JSON.stringify(liveConfig); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['1000.lock']); + ( + vi.mocked(fs.promises.stat) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue({ mtimeMs: oldTime } as fs.Stats); + vi.spyOn(process, 'kill').mockImplementation(() => true); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(liveConfig); + expect(fs.promises.unlink).not.toHaveBeenCalled(); + }); + + it('should keep incomplete old lock files when there is no stronger stale signal', async () => { + const latestConfig = { + port: '2000', + workspacePath: '/test/workspace', + }; + const now = Date.now(); + const staleTime = now - 7 * 24 * 60 * 60 * 1000 - 1000; + + vi.mocked(fs.promises.readFile).mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) { + throw new Error('not found'); + } + if (file === path.join('/home/test', '.qwen', 'ide', '1000.lock')) { + return JSON.stringify({ port: '1000' }); + } + if (file === path.join('/home/test', '.qwen', 'ide', '2000.lock')) { + return JSON.stringify(latestConfig); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['1000.lock', '2000.lock']); + ( + vi.mocked(fs.promises.stat) as Mock< + (path: fs.PathLike) => Promise + > + ).mockImplementation(async (filePath: fs.PathLike) => { + const file = String(filePath); + return { + mtimeMs: file.endsWith('1000.lock') ? staleTime : now, + } as fs.Stats; + }); + vi.mocked(fs.existsSync).mockImplementation( + (filePath: fs.PathLike) => String(filePath) === '/test/workspace', + ); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(fs.promises.unlink).not.toHaveBeenCalled(); + expect(result).toEqual(latestConfig); + }); + it('should scan IDE lock directory when env and legacy config are unavailable', async () => { const latestConfig = { port: '2000', @@ -458,9 +661,10 @@ describe('IdeClient', () => { (path: fs.PathLike) => Promise > ).mockImplementation(async (filePath: fs.PathLike) => { + const now = Date.now(); const file = String(filePath); return { - mtimeMs: file.endsWith('2000.lock') ? 2000 : 1000, + mtimeMs: file.endsWith('2000.lock') ? now : now - 1000, } as fs.Stats; }); @@ -509,9 +713,10 @@ describe('IdeClient', () => { (path: fs.PathLike) => Promise > ).mockImplementation(async (filePath: fs.PathLike) => { + const now = Date.now(); const file = String(filePath); return { - mtimeMs: file.endsWith('2000.lock') ? 2000 : 1000, + mtimeMs: file.endsWith('2000.lock') ? now : now - 1000, } as fs.Stats; }); @@ -647,14 +852,18 @@ describe('IdeClient', () => { }); describe('getIdeServerHost', () => { - const dnsLookupMock = dns.promises.lookup as unknown as Mock; + const dnsLookupMock = dns.lookup as unknown as Mock; function mockDnsResolvable(reachable: boolean): void { - if (reachable) { - dnsLookupMock.mockResolvedValue({ address: '192.168.65.254', family: 4 }); - } else { - dnsLookupMock.mockRejectedValue(new Error('ENOTFOUND')); - } + dnsLookupMock.mockImplementation( + (_hostname: string, callback: (err: Error | null) => void) => { + if (reachable) { + callback(null); + } else { + callback(new Error('ENOTFOUND')); + } + }, + ); } beforeEach(() => { @@ -682,7 +891,10 @@ describe('getIdeServerHost', () => { const host = await getIdeServerHost(); expect(host).toBe('host.docker.internal'); - expect(dnsLookupMock).toHaveBeenCalledWith('host.docker.internal'); + expect(dnsLookupMock).toHaveBeenCalledWith( + 'host.docker.internal', + expect.any(Function), + ); }); it('should fall back to 127.0.0.1 when in a container but host.docker.internal is not reachable', async () => { @@ -694,7 +906,10 @@ describe('getIdeServerHost', () => { const host = await getIdeServerHost(); expect(host).toBe('127.0.0.1'); - expect(dnsLookupMock).toHaveBeenCalledWith('host.docker.internal'); + expect(dnsLookupMock).toHaveBeenCalledWith( + 'host.docker.internal', + expect.any(Function), + ); }); it('should detect container via /run/.containerenv', async () => { @@ -727,15 +942,19 @@ describe('getIdeServerHost', () => { vi.mocked(fs.existsSync).mockImplementation( (filePath: fs.PathLike) => filePath === '/.dockerenv', ); - // Simulate dns.promises.lookup that never resolves - dnsLookupMock.mockReturnValue(new Promise(() => {})); + dnsLookupMock.mockImplementation(() => { + // Never call the callback to simulate a hung lookup. + }); const hostPromise = getIdeServerHost(); await vi.advanceTimersByTimeAsync(3000); const host = await hostPromise; expect(host).toBe('127.0.0.1'); - expect(dnsLookupMock).toHaveBeenCalledWith('host.docker.internal'); + expect(dnsLookupMock).toHaveBeenCalledWith( + 'host.docker.internal', + expect.any(Function), + ); }); it('should perform only one DNS lookup when called concurrently', async () => { @@ -746,13 +965,9 @@ describe('getIdeServerHost', () => { // Simulate a slow DNS lookup dnsLookupMock.mockImplementation( - () => - new Promise((resolve) => - setTimeout( - () => resolve({ address: '192.168.65.254', family: 4 }), - 50, - ), - ), + (_hostname: string, callback: (err: Error | null) => void) => { + setTimeout(() => callback(null), 50); + }, ); const promises = Array.from({ length: 5 }, () => getIdeServerHost()); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b4835e30e..d51607eef 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -62,6 +62,19 @@ type ConnectionConfig = { stdio?: StdioConfig; }; +type IdeConnectionConfig = ConnectionConfig & { + workspacePath?: string; + ideInfo?: IdeInfo; + ppid?: number; +}; + +type ParsedConnectionLockFile = { + file: string; + fullPath: string; + mtimeMs: number; + parsed: IdeConnectionConfig; +}; + function getRealPath(path: string): string { try { return fs.realpathSync(path); @@ -85,9 +98,7 @@ export class IdeClient { }; private currentIde: IdeInfo | undefined; private ideProcessInfo: { pid: number; command: string } | undefined; - private connectionConfig: - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined; + private connectionConfig: IdeConnectionConfig | undefined; private authToken: string | undefined; private diffResponses = new Map void>(); private statusListeners = new Set<(state: IDEConnectionState) => void>(); @@ -172,6 +183,10 @@ export class IdeClient { if (connected) { return; } + const fallbackConnected = await this.tryFallbackPorts(); + if (fallbackConnected) { + return; + } } if (this.connectionConfig.stdio) { const connected = await this.establishStdioConnection( @@ -570,10 +585,10 @@ export class IdeClient { } private async getConnectionConfigFromFile(): Promise< - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined + IdeConnectionConfig | undefined > { const portFromEnv = this.getPortFromEnv(); + if (portFromEnv) { try { const ideDir = Storage.getGlobalIdeDir(); @@ -591,37 +606,20 @@ export class IdeClient { return legacyConfig; } - // Scan lock directory as a last resort when neither env var nor legacy - // file is available (e.g. code-server where the env var is not injected). - // Configs are sorted by modification time (most recent first). Pick the - // first one whose workspace matches the current working directory. - if (!portFromEnv) { - const ideDir = Storage.getGlobalIdeDir(); - const configs = await this.getAllConnectionConfigs(ideDir); - if (configs.length > 0) { - debugLogger.debug( - `Discovered ${configs.length} IDE lock file(s) via directory scan`, - ); - const cwd = process.cwd(); - const match = configs.find( - (c) => - c.workspacePath !== undefined && - IdeClient.validateWorkspacePath(c.workspacePath, cwd).isValid, - ); - return match; - } - } - - return undefined; + const ideDir = Storage.getGlobalIdeDir(); + const configs = await this.getAllConnectionConfigs(ideDir); + const cwd = process.cwd(); + return configs.find( + (config) => + config.workspacePath !== undefined && + IdeClient.validateWorkspacePath(config.workspacePath, cwd).isValid, + ); } // Legacy connection files were written in the global temp directory. private async getLegacyConnectionConfig( portFromEnv?: string, - ): Promise< - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined - > { + ): Promise { if (this.ideProcessInfo) { try { const portFile = path.join( @@ -656,15 +654,13 @@ export class IdeClient { protected async getAllConnectionConfigs( ideDir: string, - ): Promise< - ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }> - > { - const fileRegex = new RegExp('^\\d+\\.lock$'); + ): Promise { + const fileRegex = /^\d+\.lock$/; let lockFiles: string[]; try { - lockFiles = (await fs.promises.readdir(ideDir)).filter((file) => - fileRegex.test(file), - ); + lockFiles = (await fs.promises.readdir(ideDir)) + .map((file) => file.toString()) + .filter((file) => fileRegex.test(file)); } catch (e) { debugLogger.debug('Failed to read IDE connection directory:', e); return []; @@ -677,27 +673,131 @@ export class IdeClient { const stat = await fs.promises.stat(fullPath); const content = await fs.promises.readFile(fullPath, 'utf8'); try { - const parsed = JSON.parse(content); - return { file, mtimeMs: stat.mtimeMs, parsed }; - } catch (e) { - debugLogger.debug('Failed to parse JSON from lock file: ', e); - return { file, mtimeMs: stat.mtimeMs, parsed: undefined }; + return { + file, + fullPath, + mtimeMs: stat.mtimeMs, + parsed: JSON.parse(content) as IdeConnectionConfig, + }; + } catch (error) { + debugLogger.debug('Failed to parse JSON from lock file: ', error); + return undefined; } - } catch (e) { - // If we can't stat/read the file, treat it as very old so it doesn't - // win ties, and skip parsing by returning undefined content. - debugLogger.debug('Failed to read/stat IDE lock file:', e); - return { file, mtimeMs: -Infinity, parsed: undefined }; + } catch (error) { + debugLogger.debug('Failed to read/stat IDE lock file:', error); + return undefined; } }), ); - return fileContents - .filter(({ parsed }) => parsed !== undefined) + const parsedLockFiles = fileContents.filter( + (lockFile): lockFile is ParsedConnectionLockFile => + lockFile !== undefined, + ); + const activeLockFiles = await Promise.all( + parsedLockFiles.map(async (lockFile) => ({ + lockFile, + isStale: await this.cleanupStaleLockFile(lockFile), + })), + ); + + const staleCount = activeLockFiles.filter(({ isStale }) => isStale).length; + if (staleCount > 0) { + debugLogger.debug( + `[cleanupStaleLockFiles] Cleaned up ${staleCount} stale lock file(s)`, + ); + } + + return activeLockFiles + .filter(({ isStale }) => !isStale) + .map(({ lockFile }) => lockFile) .sort((a, b) => b.mtimeMs - a.mtimeMs) .map(({ parsed }) => parsed); } + private async cleanupStaleLockFile({ + file, + fullPath, + parsed, + }: ParsedConnectionLockFile): Promise { + try { + if (parsed.ppid) { + try { + process.kill(parsed.ppid, 0); + return false; + } catch { + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - ppid ${parsed.ppid} no longer exists`, + ); + await fs.promises.unlink(fullPath); + return true; + } + } + + if (parsed.workspacePath) { + if (fs.existsSync(parsed.workspacePath)) { + return false; + } + + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - workspace doesn't exist`, + ); + await fs.promises.unlink(fullPath); + return true; + } + + return false; + } catch (error) { + debugLogger.debug( + `[cleanupStaleLockFiles] Error checking lock file "${file}":`, + error, + ); + return false; + } + } + + private async tryFallbackPorts(): Promise { + const cwd = process.cwd(); + const currentPort = this.connectionConfig?.port; + const configs = await this.getAllConnectionConfigs( + Storage.getGlobalIdeDir(), + ); + const workspaceMatches: IdeConnectionConfig[] = []; + const otherConfigs: IdeConnectionConfig[] = []; + + for (const config of configs) { + if (!config.port || config.port === currentPort) { + continue; + } + + if ( + config.workspacePath !== undefined && + IdeClient.validateWorkspacePath(config.workspacePath, cwd).isValid + ) { + workspaceMatches.push(config); + } else { + otherConfigs.push(config); + } + } + + for (const config of [...workspaceMatches, ...otherConfigs]) { + const port = config.port; + if (!port) { + continue; + } + if (config.authToken) { + this.authToken = config.authToken; + } + const connected = await this.establishHttpConnection(port); + if (connected) { + this.connectionConfig = config; + return true; + } + } + + return false; + } + private createProxyAwareFetch(ideHost: string) { // Ignore proxy for IDE server host to allow connecting to the ide mcp // server even when HTTP_PROXY is set @@ -929,21 +1029,26 @@ export function _resetCachedIdeServerHost(): void { /** * Check if a hostname is DNS-resolvable, with a timeout guard. + * Uses callback-based dns.lookup() for better compatibility across + * different Node.js environments (e.g., VSCode, Cursor). */ async function isHostResolvable(hostname: string): Promise { - try { - const timeout = new Promise((_, reject) => { - const timer = setTimeout( - () => reject(new Error('DNS lookup timeout')), - DNS_LOOKUP_TIMEOUT_MS, - ); - timer.unref?.(); + return new Promise((resolve) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + resolve(false); + }, DNS_LOOKUP_TIMEOUT_MS); + timeout.unref?.(); + + dns.lookup(hostname, (err) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(!err); }); - await Promise.race([dns.promises.lookup(hostname), timeout]); - return true; - } catch { - return false; - } + }); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0dcce945..e1fe65d2f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,7 +11,6 @@ // Core configuration export * from './config/config.js'; export { Storage } from './config/storage.js'; -export * from './utils/configResolver.js'; // Model configuration export { @@ -60,103 +59,28 @@ export * from './core/nonInteractiveToolExecutor.js'; export * from './core/prompts.js'; export * from './core/tokenLimits.js'; export * from './core/turn.js'; -export * from './core/geminiRequest.js'; -export * from './core/coreToolScheduler.js'; -export * from './core/nonInteractiveToolExecutor.js'; -export * from './tools/tool-names.js'; // ============================================================================ // Tools // ============================================================================ -// Export utilities -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'; -export * from './utils/gitUtils.js'; -export * from './utils/editor.js'; -export * from './utils/quotaErrorDetection.js'; -export * from './utils/fileUtils.js'; -export * from './utils/retry.js'; -export * from './utils/shell-utils.js'; -export * from './utils/tool-utils.js'; -export * from './utils/terminalSerializer.js'; -export * from './utils/systemEncoding.js'; -export * from './utils/textUtils.js'; -export * from './utils/formatters.js'; -export * from './utils/generateContentResponseUtilities.js'; -export * from './utils/ripgrepUtils.js'; -export * from './utils/filesearch/fileSearch.js'; -export * from './utils/errorParsing.js'; -export * from './utils/workspaceContext.js'; -export * from './utils/ignorePatterns.js'; -export * from './utils/partUtils.js'; -export * from './utils/subagentGenerator.js'; -export * from './utils/projectSummary.js'; -export * from './utils/promptIdContext.js'; -export * from './utils/thoughtUtils.js'; -export * from './utils/toml-to-markdown-converter.js'; -export * from './utils/yaml-parser.js'; - -// Config resolution utilities -export * from './utils/configResolver.js'; - -// Export services -export * from './services/fileDiscoveryService.js'; -export * from './services/gitService.js'; -export * from './services/chatRecordingService.js'; -export * from './services/sessionService.js'; -export * from './services/fileSystemService.js'; - -// Export IDE specific logic -export * from './ide/ide-client.js'; -export * from './ide/ideContext.js'; -export * from './ide/ide-installer.js'; -export { IDE_DEFINITIONS, type IdeInfo } from './ide/detect-ide.js'; -export * from './ide/constants.js'; -export * from './ide/types.js'; - -// Export Shell Execution Service -export * from './services/shellExecutionService.js'; - -// Export base tool definitions -export * from './tools/tools.js'; +// Tool names and registry +export * from './tools/tool-names.js'; export * from './tools/tool-error.js'; export * from './tools/tool-registry.js'; +export * from './tools/tools.js'; -// Export subagents (Phase 1) -export * from './subagents/index.js'; - -// Export skills -export * from './skills/index.js'; - -// Export extension -export * from './extension/index.js'; - -// Export prompt logic -export * from './prompts/mcp-prompts.js'; - -// Export specific tool logic -export * from './tools/read-file.js'; -export * from './tools/ls.js'; -export * from './tools/grep.js'; -export * from './tools/ripGrep.js'; -export * from './tools/glob.js'; +// Individual tools export * from './tools/edit.js'; export * from './tools/exitPlanMode.js'; export * from './tools/glob.js'; export * from './tools/grep.js'; export * from './tools/ls.js'; export * from './tools/lsp.js'; -export * from './tools/memoryTool.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; +export * from './tools/memoryTool.js'; export * from './tools/read-file.js'; export * from './tools/ripGrep.js'; export * from './tools/sdk-control-client-transport.js'; @@ -164,9 +88,6 @@ export * from './tools/shell.js'; export * from './tools/skill.js'; export * from './tools/task.js'; export * from './tools/todoWrite.js'; -export * from './tools/tool-error.js'; -export * from './tools/tool-registry.js'; -export * from './tools/tools.js'; export * from './tools/web-fetch.js'; export * from './tools/web-search/index.js'; export * from './tools/write-file.js'; @@ -182,11 +103,21 @@ export * from './services/gitService.js'; export * from './services/sessionService.js'; export * from './services/shellExecutionService.js'; +// ============================================================================ +// IDE Support +// ============================================================================ + +export * from './ide/ide-client.js'; +export * from './ide/ideContext.js'; +export * from './ide/ide-installer.js'; +export { IDE_DEFINITIONS, type IdeInfo } from './ide/detect-ide.js'; +export * from './ide/constants.js'; +export * from './ide/types.js'; + // ============================================================================ // LSP Support // ============================================================================ -// LSP support export * from './lsp/constants.js'; export * from './lsp/LspConfigLoader.js'; export * from './lsp/LspConnectionFactory.js'; @@ -202,7 +133,11 @@ export * from './lsp/types.js'; // ============================================================================ export { MCPOAuthProvider } from './mcp/oauth-provider.js'; -export type { MCPOAuthConfig } from './mcp/oauth-provider.js'; +export type { + MCPOAuthConfig, + OAuthDisplayMessage, + OAuthDisplayPayload, +} from './mcp/oauth-provider.js'; export { MCPOAuthTokenStorage } from './mcp/oauth-token-storage.js'; export { KeychainTokenStorage } from './mcp/token-storage/keychain-token-storage.js'; export type { @@ -240,7 +175,7 @@ export { } from './telemetry/types.js'; // ============================================================================ -// Extensions & Subagents +// Extensions, Skills & Subagents // ============================================================================ export * from './extension/index.js'; @@ -253,6 +188,8 @@ export * from './subagents/index.js'; // ============================================================================ export * from './utils/browser.js'; +export * from './utils/configResolver.js'; +export * from './utils/debugLogger.js'; export * from './utils/editor.js'; export * from './utils/errorParsing.js'; export * from './utils/errors.js'; @@ -264,13 +201,14 @@ export * from './utils/getFolderStructure.js'; export * from './utils/gitIgnoreParser.js'; export * from './utils/gitUtils.js'; export * from './utils/ignorePatterns.js'; +export * from './utils/jsonl-utils.js'; export * from './utils/memoryDiscovery.js'; export { OpenAILogger, openaiLogger } from './utils/openaiLogger.js'; export * from './utils/partUtils.js'; export * from './utils/pathReader.js'; export * from './utils/paths.js'; -export * from './utils/promptIdContext.js'; export * from './utils/projectSummary.js'; +export * from './utils/promptIdContext.js'; export * from './utils/quotaErrorDetection.js'; export * from './utils/readManyFiles.js'; export * from './utils/request-tokenizer/supportedImageFormats.js'; @@ -279,6 +217,7 @@ export * from './utils/ripgrepUtils.js'; export * from './utils/schemaValidator.js'; export * from './utils/shell-utils.js'; export * from './utils/subagentGenerator.js'; +export * from './utils/symlink.js'; export * from './utils/systemEncoding.js'; export * from './utils/terminalSerializer.js'; export * from './utils/textUtils.js'; @@ -287,8 +226,6 @@ export * from './utils/toml-to-markdown-converter.js'; 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 @@ -303,7 +240,10 @@ export * from './qwen/qwenOAuth2.js'; export { makeFakeConfig } from './test-utils/config.js'; export * from './test-utils/index.js'; -// Export hook types and components +// ============================================================================ +// Hooks +// ============================================================================ + 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/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 1d1157c27..a2fca6eec 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -22,8 +22,28 @@ import { export const OAUTH_DISPLAY_MESSAGE_EVENT = 'oauth-display-message' as const; +/** + * Structured display message for i18n support. + * The `key` is the i18n translation key (English text as key). + * The `params` are optional interpolation parameters. + */ +export interface OAuthDisplayMessage { + key: string; + params?: Record; +} + +/** Payload type for OAuth display message events: structured i18n message or plain string. */ +export type OAuthDisplayPayload = string | OAuthDisplayMessage; + const debugLogger = createDebugLogger('MCP_OAUTH'); +// Module-level reference to the active OAuth callback server. +// This ensures that if a new authentication is started before the previous one +// finishes (e.g. user navigated back and re-entered), the old server is closed +// first to avoid EADDRINUSE errors. +let activeCallbackServer: http.Server | null = null; +let activeCallbackTimeout: ReturnType | null = null; + /** * OAuth configuration for an MCP server. */ @@ -195,6 +215,20 @@ export class MCPOAuthProvider { private async startCallbackServer( expectedState: string, ): Promise { + // Close any previously active callback server to avoid EADDRINUSE + if (activeCallbackServer) { + try { + activeCallbackServer.close(); + } catch { + // Ignore errors when closing stale server + } + activeCallbackServer = null; + } + if (activeCallbackTimeout) { + clearTimeout(activeCallbackTimeout); + activeCallbackTimeout = null; + } + return new Promise((resolve, reject) => { const server = http.createServer( async (req: http.IncomingMessage, res: http.ServerResponse) => { @@ -226,6 +260,7 @@ export class MCPOAuthProvider { `); + activeCallbackServer = null; server.close(); reject(new Error(`OAuth error: ${error}`)); return; @@ -240,6 +275,7 @@ export class MCPOAuthProvider { if (state !== expectedState) { res.writeHead(400); res.end('Invalid state parameter'); + activeCallbackServer = null; server.close(); reject(new Error('State mismatch - possible CSRF attack')); return; @@ -257,9 +293,11 @@ export class MCPOAuthProvider { `); + activeCallbackServer = null; server.close(); resolve({ code, state }); } catch (error) { + activeCallbackServer = null; server.close(); reject(error); } @@ -273,9 +311,14 @@ export class MCPOAuthProvider { ); }); + // Track the active server so it can be cleaned up if a new auth starts + activeCallbackServer = server; + // Timeout after 5 minutes - setTimeout( + activeCallbackTimeout = setTimeout( () => { + activeCallbackServer = null; + activeCallbackTimeout = null; server.close(); reject(new Error('OAuth callback timeout')); }, @@ -603,11 +646,17 @@ export class MCPOAuthProvider { events?: EventEmitter, ): Promise { // Helper function to display messages through handler or fallback to debugLogger - const displayMessage = (message: string) => { + const displayMessage = (message: OAuthDisplayPayload) => { if (events) { events.emit(OAUTH_DISPLAY_MESSAGE_EVENT, message); } else { - debugLogger.info(message); + if (typeof message === 'string') { + debugLogger.info(message); + } else { + debugLogger.info( + `[${message.key}]${message.params ? ` ${JSON.stringify(message.params)}` : ''}`, + ); + } } }; @@ -746,13 +795,13 @@ export class MCPOAuthProvider { mcpServerUrl, ); - displayMessage(`→ Opening your browser for OAuth sign-in... - -If the browser does not open, copy and paste this URL into your browser: -${authUrl} - -💡 TIP: Triple-click to select the entire URL, then copy and paste it into your browser. -⚠️ Make sure to copy the COMPLETE URL - it may wrap across multiple lines.`); + displayMessage({ + key: 'If the browser does not open, copy and paste this URL into your browser:', + }); + displayMessage(`\n${authUrl.toString()}\n`); + displayMessage({ + key: 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.', + }); // Start callback server const callbackPromise = this.startCallbackServer(pkceParams.state); diff --git a/packages/core/src/mcp/oauth-utils.test.ts b/packages/core/src/mcp/oauth-utils.test.ts index 358213510..2e1bd8984 100644 --- a/packages/core/src/mcp/oauth-utils.test.ts +++ b/packages/core/src/mcp/oauth-utils.test.ts @@ -342,4 +342,85 @@ describe('OAuthUtils', () => { expect(result).toBe('https://mcp.alibaba-inc.com/yuque/mcp'); }); }); + + describe('discoverOAuthConfig', () => { + it('should use scopes from protected resource metadata when available', async () => { + // This test verifies the fix for the issue where scopes from + // protected resource metadata were not being used + const mockResourceMetadata: OAuthProtectedResourceMetadata = { + resource: 'https://www.modelscope.cn/mcp-server', + authorization_servers: ['https://www.modelscope.cn'], + scopes_supported: [ + 'openid', + 'profile', + 'list-operational-mcp', + 'manage-mcp-deployment', + ], + }; + + const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = { + issuer: 'https://www.modelscope.cn', + authorization_endpoint: 'https://www.modelscope.cn/oauth/authorize', + token_endpoint: 'https://www.modelscope.cn/oauth/token', + // Note: scopes_supported is NOT present in auth server metadata + }; + + mockFetch + // First call: fetch protected resource metadata + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResourceMetadata), + }) + // Second call: fetch authorization server metadata + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthServerMetadata), + }); + + const result = await OAuthUtils.discoverOAuthConfig( + 'https://www.modelscope.cn/mcp-server', + ); + + expect(result).not.toBeNull(); + expect(result!.scopes).toEqual([ + 'openid', + 'profile', + 'list-operational-mcp', + 'manage-mcp-deployment', + ]); + }); + + it('should prefer protected resource scopes over auth server scopes', async () => { + const mockResourceMetadata: OAuthProtectedResourceMetadata = { + resource: 'https://example.com/mcp', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['mcp-read', 'mcp-write'], + }; + + const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + scopes_supported: ['read', 'write', 'admin'], // Different scopes + }; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResourceMetadata), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthServerMetadata), + }); + + const result = await OAuthUtils.discoverOAuthConfig( + 'https://example.com/mcp', + ); + + expect(result).not.toBeNull(); + // Should use protected resource scopes, not auth server scopes + expect(result!.scopes).toEqual(['mcp-read', 'mcp-write']); + }); + }); }); diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index e5d5f3b74..8f60d4f82 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -38,6 +38,7 @@ export interface OAuthProtectedResourceMetadata { resource_signing_alg_values_supported?: string[]; resource_encryption_alg_values_supported?: string[]; resource_encryption_enc_values_supported?: string[]; + scopes_supported?: string[]; } /** @@ -251,6 +252,11 @@ export class OAuthUtils { if (authServerMetadata) { const config = this.metadataToOAuthConfig(authServerMetadata); + // Merge scopes from protected resource metadata (RFC 9728) + // Protected resource scopes take precedence as they define the specific access requirements + if (resourceMetadata.scopes_supported?.length) { + config.scopes = resourceMetadata.scopes_supported; + } if (authServerMetadata.registration_endpoint) { debugLogger.debug( `Dynamic client registration is supported at: ${authServerMetadata.registration_endpoint}`, @@ -325,7 +331,13 @@ export class OAuthUtils { await this.discoverAuthorizationServerMetadata(authServerUrl); if (authServerMetadata) { - return this.metadataToOAuthConfig(authServerMetadata); + const config = this.metadataToOAuthConfig(authServerMetadata); + // Merge scopes from protected resource metadata (RFC 9728) + // Protected resource scopes take precedence as they define the specific access requirements + if (resourceMetadata.scopes_supported?.length) { + config.scopes = resourceMetadata.scopes_supported; + } + return config; } return null; diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 25268aebe..87c8aaf34 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -1506,4 +1506,130 @@ describe('ModelsConfig', () => { expect(allModels.some((m) => m.id === 'gemini-ultra')).toBe(true); }); }); + + describe('max_tokens in modelsConfig', () => { + it('should not auto-fill max_tokens when samplingParams is undefined', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4', + name: 'GPT-4', + baseUrl: 'https://api.openai.example.com/v1', + // No generationConfig.samplingParams defined + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'gpt-4'); + + const gc = currentGenerationConfig(modelsConfig); + expect(gc.samplingParams).toBeUndefined(); + }); + + it('should not auto-fill max_tokens when samplingParams exists but max_tokens is missing', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4', + name: 'GPT-4', + baseUrl: 'https://api.openai.example.com/v1', + generationConfig: { + samplingParams: { temperature: 0.7 }, // max_tokens not defined + }, + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'gpt-4'); + + const gc = currentGenerationConfig(modelsConfig); + // Should preserve existing sampling params but not inject max_tokens + expect(gc.samplingParams?.temperature).toBe(0.7); + expect(gc.samplingParams?.max_tokens).toBeUndefined(); + + const sources = modelsConfig.getGenerationConfigSources(); + expect(sources['samplingParams']?.kind).toBe('modelProviders'); + }); + + it('should not override existing max_tokens from modelProviders', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4', + name: 'GPT-4', + baseUrl: 'https://api.openai.example.com/v1', + generationConfig: { + samplingParams: { temperature: 0.7, max_tokens: 4096 }, + }, + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'gpt-4'); + + const gc = currentGenerationConfig(modelsConfig); + // Should preserve both values from provider + expect(gc.samplingParams?.temperature).toBe(0.7); + expect(gc.samplingParams?.max_tokens).toBe(4096); + + const sources = modelsConfig.getGenerationConfigSources(); + expect(sources['samplingParams']?.kind).toBe('modelProviders'); + }); + + it('should not auto-fill max_tokens for different model families', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + anthropic: [ + { + id: 'claude-3-opus', + name: 'Claude 3 Opus', + baseUrl: 'https://api.anthropic.example.com/v1', + }, + ], + gemini: [ + { + id: 'gemini-pro', + name: 'Gemini Pro', + baseUrl: 'https://api.gemini.example.com/v1', + }, + ], + }; + + // Test Claude model without provider max_tokens + const claudeConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_ANTHROPIC, + modelProvidersConfig, + }); + + await claudeConfig.switchModel(AuthType.USE_ANTHROPIC, 'claude-3-opus'); + + let gc = currentGenerationConfig(claudeConfig); + expect(gc.samplingParams).toBeUndefined(); + + // Test Gemini model without provider max_tokens + const geminiConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_GEMINI, + modelProvidersConfig, + }); + + await geminiConfig.switchModel(AuthType.USE_GEMINI, 'gemini-pro'); + + gc = currentGenerationConfig(geminiConfig); + expect(gc.samplingParams).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 7ff3207d8..41d06afbe 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -91,13 +91,6 @@ vi.mock('./sharedTokenManager.js', () => ({ }, })); -// Mock qrcode-terminal -vi.mock('qrcode-terminal', () => ({ - default: { - generate: vi.fn(), - }, -})); - // Mock open vi.mock('open', () => ({ default: vi.fn(), diff --git a/packages/core/src/services/fileSystemService.test.ts b/packages/core/src/services/fileSystemService.test.ts index fe72829e2..7811a96ed 100644 --- a/packages/core/src/services/fileSystemService.test.ts +++ b/packages/core/src/services/fileSystemService.test.ts @@ -6,29 +6,46 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'node:fs/promises'; -import { StandardFileSystemService } from './fileSystemService.js'; +import { + StandardFileSystemService, + needsUtf8Bom, + resetUtf8BomCache, +} from './fileSystemService.js'; + +const mockPlatform = vi.hoisted(() => vi.fn().mockReturnValue('linux')); +const mockGetSystemEncoding = vi.hoisted(() => + vi.fn().mockReturnValue('utf-8'), +); vi.mock('fs/promises'); +vi.mock('os', () => ({ + default: { + platform: mockPlatform, + }, + platform: mockPlatform, +})); +vi.mock('../utils/systemEncoding.js', () => ({ + getSystemEncoding: mockGetSystemEncoding, +})); vi.mock('../utils/fileUtils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - readFileWithEncoding: vi.fn(), - readFileWithEncodingInfo: vi.fn(), + readFileWithLineAndLimit: vi.fn(), }; }); -import { - readFileWithEncoding, - readFileWithEncodingInfo, -} from '../utils/fileUtils.js'; +import { readFileWithLineAndLimit } from '../utils/fileUtils.js'; describe('StandardFileSystemService', () => { let fileSystem: StandardFileSystemService; beforeEach(() => { vi.resetAllMocks(); + resetUtf8BomCache(); + mockPlatform.mockReturnValue('linux'); + mockGetSystemEncoding.mockReturnValue('utf-8'); fileSystem = new StandardFileSystemService(); }); @@ -37,58 +54,69 @@ describe('StandardFileSystemService', () => { }); describe('readTextFile', () => { - it('should read file content using readFileWithEncoding', async () => { - const testContent = 'Hello, World!'; - vi.mocked(readFileWithEncoding).mockResolvedValue(testContent); - - const result = await fileSystem.readTextFile('/test/file.txt'); - - expect(readFileWithEncoding).toHaveBeenCalledWith('/test/file.txt'); - expect(result).toBe(testContent); - }); - - it('should propagate readFileWithEncoding errors', async () => { - const error = new Error('ENOENT: File not found'); - vi.mocked(readFileWithEncoding).mockRejectedValue(error); - - await expect(fileSystem.readTextFile('/test/file.txt')).rejects.toThrow( - 'ENOENT: File not found', - ); - }); - }); - - describe('readTextFileWithInfo', () => { - it('should return content, encoding, and bom via readFileWithEncodingInfo', async () => { - const mockResult = { content: 'Hello', encoding: 'utf-8', bom: false }; - vi.mocked(readFileWithEncodingInfo).mockResolvedValue(mockResult); - - const result = await fileSystem.readTextFileWithInfo('/test/file.txt'); - - expect(readFileWithEncodingInfo).toHaveBeenCalledWith('/test/file.txt'); - expect(result).toEqual(mockResult); - }); - - it('should return non-UTF-8 encoding info for GBK file', async () => { - const mockResult = { - content: '你好世界', - encoding: 'gb18030', + it('should read file content and return ReadTextFileResponse', async () => { + vi.mocked(readFileWithLineAndLimit).mockResolvedValue({ + content: 'Hello, World!', bom: false, - }; - vi.mocked(readFileWithEncodingInfo).mockResolvedValue(mockResult); + encoding: 'utf-8', + originalLineCount: 1, + }); - const result = await fileSystem.readTextFileWithInfo('/test/gbk.txt'); + const result = await fileSystem.readTextFile({ path: '/test/file.txt' }); - expect(result.encoding).toBe('gb18030'); - expect(result.bom).toBe(false); - expect(result.content).toBe('你好世界'); + expect(readFileWithLineAndLimit).toHaveBeenCalledWith({ + path: '/test/file.txt', + limit: Infinity, + line: 0, + }); + expect(result.content).toBe('Hello, World!'); + expect(result._meta?.bom).toBe(false); + expect(result._meta?.encoding).toBe('utf-8'); }); - it('should propagate readFileWithEncodingInfo errors', async () => { + it('should pass limit and line params to readFileWithLineAndLimit', async () => { + vi.mocked(readFileWithLineAndLimit).mockResolvedValue({ + content: 'line 5', + bom: false, + encoding: 'utf-8', + originalLineCount: 100, + }); + + const result = await fileSystem.readTextFile({ + path: '/test/file.txt', + limit: 10, + line: 5, + }); + + expect(readFileWithLineAndLimit).toHaveBeenCalledWith({ + path: '/test/file.txt', + limit: 10, + line: 5, + }); + expect(result._meta?.originalLineCount).toBe(100); + }); + + it('should return encoding info for GBK file', async () => { + vi.mocked(readFileWithLineAndLimit).mockResolvedValue({ + content: '你好世界', + bom: false, + encoding: 'gb18030', + originalLineCount: 1, + }); + + const result = await fileSystem.readTextFile({ path: '/test/gbk.txt' }); + + expect(result.content).toBe('你好世界'); + expect(result._meta?.encoding).toBe('gb18030'); + expect(result._meta?.bom).toBe(false); + }); + + it('should propagate readFileWithLineAndLimit errors', async () => { const error = new Error('ENOENT: File not found'); - vi.mocked(readFileWithEncodingInfo).mockRejectedValue(error); + vi.mocked(readFileWithLineAndLimit).mockRejectedValue(error); await expect( - fileSystem.readTextFileWithInfo('/test/file.txt'), + fileSystem.readTextFile({ path: '/test/file.txt' }), ).rejects.toThrow('ENOENT: File not found'); }); }); @@ -97,7 +125,10 @@ describe('StandardFileSystemService', () => { it('should write file content using fs', async () => { vi.mocked(fs.writeFile).mockResolvedValue(); - await fileSystem.writeTextFile('/test/file.txt', 'Hello, World!'); + await fileSystem.writeTextFile({ + path: '/test/file.txt', + content: 'Hello, World!', + }); expect(fs.writeFile).toHaveBeenCalledWith( '/test/file.txt', @@ -109,8 +140,10 @@ describe('StandardFileSystemService', () => { it('should write file with BOM when bom option is true', async () => { vi.mocked(fs.writeFile).mockResolvedValue(); - await fileSystem.writeTextFile('/test/file.txt', 'Hello, World!', { - bom: true, + await fileSystem.writeTextFile({ + path: '/test/file.txt', + content: 'Hello, World!', + _meta: { bom: true }, }); // Verify that fs.writeFile was called with a Buffer that starts with BOM @@ -126,8 +159,10 @@ describe('StandardFileSystemService', () => { it('should write file without BOM when bom option is false', async () => { vi.mocked(fs.writeFile).mockResolvedValue(); - await fileSystem.writeTextFile('/test/file.txt', 'Hello, World!', { - bom: false, + await fileSystem.writeTextFile({ + path: '/test/file.txt', + content: 'Hello, World!', + _meta: { bom: false }, }); expect(fs.writeFile).toHaveBeenCalledWith( @@ -142,8 +177,10 @@ describe('StandardFileSystemService', () => { // Content that includes the BOM character (as readTextFile would return) const contentWithBOM = '\uFEFF' + 'Hello'; - await fileSystem.writeTextFile('/test/file.txt', contentWithBOM, { - bom: true, + await fileSystem.writeTextFile({ + path: '/test/file.txt', + content: contentWithBOM, + _meta: { bom: true }, }); // Verify that fs.writeFile was called with a Buffer that has only one BOM @@ -170,11 +207,14 @@ describe('StandardFileSystemService', () => { } expect(bomCount).toBe(1); }); + it('should write file with non-UTF-8 encoding using iconv-lite', async () => { vi.mocked(fs.writeFile).mockResolvedValue(); - await fileSystem.writeTextFile('/test/file.txt', '你好世界', { - encoding: 'gbk', + await fileSystem.writeTextFile({ + path: '/test/file.txt', + content: '你好世界', + _meta: { encoding: 'gbk' }, }); // Verify that fs.writeFile was called with a Buffer (iconv-encoded) @@ -186,8 +226,10 @@ describe('StandardFileSystemService', () => { it('should write file as UTF-8 when encoding is utf-8', async () => { vi.mocked(fs.writeFile).mockResolvedValue(); - await fileSystem.writeTextFile('/test/file.txt', 'Hello', { - encoding: 'utf-8', + await fileSystem.writeTextFile({ + path: '/test/file.txt', + content: 'Hello', + _meta: { encoding: 'utf-8' }, }); expect(fs.writeFile).toHaveBeenCalledWith( @@ -200,9 +242,10 @@ describe('StandardFileSystemService', () => { it('should preserve UTF-16LE BOM when writing back a UTF-16LE file', async () => { vi.mocked(fs.writeFile).mockResolvedValue(); - await fileSystem.writeTextFile('/test/file.txt', 'Hello', { - encoding: 'utf-16le', - bom: true, + await fileSystem.writeTextFile({ + path: '/test/file.txt', + content: 'Hello', + _meta: { encoding: 'utf-16le', bom: true }, }); // iconv-lite encodes as UTF-16LE; with bom:true the FF FE BOM is prepended @@ -218,9 +261,10 @@ describe('StandardFileSystemService', () => { it('should not add BOM when writing UTF-16LE file without bom flag', async () => { vi.mocked(fs.writeFile).mockResolvedValue(); - await fileSystem.writeTextFile('/test/file.txt', 'Hello', { - encoding: 'utf-16le', - bom: false, + await fileSystem.writeTextFile({ + path: '/test/file.txt', + content: 'Hello', + _meta: { encoding: 'utf-16le', bom: false }, }); // No BOM prepended — raw iconv-encoded buffer written directly @@ -231,67 +275,177 @@ describe('StandardFileSystemService', () => { // First two bytes should NOT be FF FE (the UTF-16LE BOM) expect(!(buf[0] === 0xff && buf[1] === 0xfe)).toBe(true); }); + + it('should convert LF to CRLF when writing .bat files on Windows', async () => { + mockPlatform.mockReturnValue('win32'); + vi.mocked(fs.writeFile).mockResolvedValue(); + + await fileSystem.writeTextFile({ + path: '/test/script.bat', + content: '@echo off\necho hello\nexit /b 0\n', + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/script.bat', + '@echo off\r\necho hello\r\nexit /b 0\r\n', + 'utf-8', + ); + }); + + it('should convert LF to CRLF when writing .cmd files on Windows', async () => { + mockPlatform.mockReturnValue('win32'); + vi.mocked(fs.writeFile).mockResolvedValue(); + + await fileSystem.writeTextFile({ + path: '/test/script.cmd', + content: '@echo off\necho hello\n', + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/script.cmd', + '@echo off\r\necho hello\r\n', + 'utf-8', + ); + }); + + it('should not double-convert existing CRLF in .bat files on Windows', async () => { + mockPlatform.mockReturnValue('win32'); + vi.mocked(fs.writeFile).mockResolvedValue(); + + await fileSystem.writeTextFile({ + path: '/test/script.bat', + content: '@echo off\r\necho hello\r\n', + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/script.bat', + '@echo off\r\necho hello\r\n', + 'utf-8', + ); + }); + + it('should handle mixed line endings in .bat files on Windows', async () => { + mockPlatform.mockReturnValue('win32'); + vi.mocked(fs.writeFile).mockResolvedValue(); + + await fileSystem.writeTextFile({ + path: '/test/script.bat', + content: 'line1\r\nline2\nline3\r\n', + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/script.bat', + 'line1\r\nline2\r\nline3\r\n', + 'utf-8', + ); + }); + + it('should be case-insensitive for .BAT extension on Windows', async () => { + mockPlatform.mockReturnValue('win32'); + vi.mocked(fs.writeFile).mockResolvedValue(); + + await fileSystem.writeTextFile({ + path: '/test/SCRIPT.BAT', + content: 'echo hello\n', + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/SCRIPT.BAT', + 'echo hello\r\n', + 'utf-8', + ); + }); + + it('should not convert line endings for non-.bat/.cmd files on Windows', async () => { + mockPlatform.mockReturnValue('win32'); + vi.mocked(fs.writeFile).mockResolvedValue(); + + await fileSystem.writeTextFile({ + path: '/test/script.sh', + content: '#!/bin/bash\necho hello\n', + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/script.sh', + '#!/bin/bash\necho hello\n', + 'utf-8', + ); + }); + + it('should not convert line endings for .bat files on non-Windows', async () => { + mockPlatform.mockReturnValue('darwin'); + vi.mocked(fs.writeFile).mockResolvedValue(); + + await fileSystem.writeTextFile({ + path: '/test/script.bat', + content: '@echo off\necho hello\n', + }); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/script.bat', + '@echo off\necho hello\n', + 'utf-8', + ); + }); }); - describe('detectFileBOM', () => { - it('should return true for file with UTF-8 BOM', async () => { - // Create a buffer with BOM - const bomBuffer = Buffer.from([0xef, 0xbb, 0xbf]); - - // Mock fs.open to return a file descriptor that fills buffer with BOM - vi.mocked(fs.open).mockImplementation( - async () => - ({ - read: async (buffer: Buffer, offset: number) => { - // Copy BOM bytes to the buffer - bomBuffer.copy(buffer, offset); - return { bytesRead: 3 }; - }, - close: async () => {}, - }) as unknown as fs.FileHandle, - ); - - const result = await fileSystem.detectFileBOM('/test/file.txt'); - expect(result).toBe(true); + describe('needsUtf8Bom', () => { + beforeEach(() => { + resetUtf8BomCache(); }); - it('should return false for file without BOM', async () => { - // Mock file without BOM (starts with plain text) - vi.mocked(fs.open).mockImplementation( - async () => - ({ - read: async (buffer: Buffer, offset: number) => { - // Copy plain text bytes ("// ") - const plainText = Buffer.from([0x2f, 0x2f, 0x20]); - plainText.copy(buffer, offset); - return { bytesRead: 3 }; - }, - close: async () => {}, - }) as unknown as fs.FileHandle, - ); + it('should return true for .ps1 files on Windows with non-UTF-8 code page', () => { + mockPlatform.mockReturnValue('win32'); + mockGetSystemEncoding.mockReturnValue('gbk'); - const result = await fileSystem.detectFileBOM('/test/file.txt'); - expect(result).toBe(false); + expect(needsUtf8Bom('/test/script.ps1')).toBe(true); }); - it('should return false for non-existent file', async () => { - vi.mocked(fs.open).mockRejectedValue(new Error('ENOENT')); + it('should return true for .PS1 files (case-insensitive)', () => { + mockPlatform.mockReturnValue('win32'); + mockGetSystemEncoding.mockReturnValue('gbk'); - const result = await fileSystem.detectFileBOM('/test/nonexistent.txt'); - expect(result).toBe(false); + expect(needsUtf8Bom('/test/SCRIPT.PS1')).toBe(true); }); - it('should return false for empty file', async () => { - vi.mocked(fs.open).mockImplementation( - async () => - ({ - read: async () => ({ bytesRead: 0 }), - close: async () => {}, - }) as unknown as fs.FileHandle, - ); + it('should return false for .ps1 files on Windows with UTF-8 code page', () => { + mockPlatform.mockReturnValue('win32'); + mockGetSystemEncoding.mockReturnValue('utf-8'); - const result = await fileSystem.detectFileBOM('/test/empty.txt'); - expect(result).toBe(false); + expect(needsUtf8Bom('/test/script.ps1')).toBe(false); + }); + + it('should return false for .ps1 files on non-Windows', () => { + mockPlatform.mockReturnValue('darwin'); + + expect(needsUtf8Bom('/test/script.ps1')).toBe(false); + }); + + it('should return false for non-.ps1 files on Windows with non-UTF-8 code page', () => { + mockPlatform.mockReturnValue('win32'); + mockGetSystemEncoding.mockReturnValue('gbk'); + + expect(needsUtf8Bom('/test/script.sh')).toBe(false); + expect(needsUtf8Bom('/test/file.txt')).toBe(false); + expect(needsUtf8Bom('/test/script.bat')).toBe(false); + }); + + it('should cache the platform/encoding check across calls', () => { + mockPlatform.mockReturnValue('win32'); + mockGetSystemEncoding.mockReturnValue('gbk'); + + needsUtf8Bom('/test/script.ps1'); + needsUtf8Bom('/test/other.ps1'); + + // getSystemEncoding should only be called once due to caching + expect(mockGetSystemEncoding).toHaveBeenCalledTimes(1); + }); + + it('should treat null system encoding as non-UTF-8', () => { + mockPlatform.mockReturnValue('win32'); + mockGetSystemEncoding.mockReturnValue(null); + + expect(needsUtf8Bom('/test/script.ps1')).toBe(true); }); }); }); diff --git a/packages/core/src/services/fileSystemService.ts b/packages/core/src/services/fileSystemService.ts index 787d68929..6d2022c75 100644 --- a/packages/core/src/services/fileSystemService.ts +++ b/packages/core/src/services/fileSystemService.ts @@ -5,18 +5,30 @@ */ import fs from 'node:fs/promises'; +import os from 'node:os'; import * as path from 'node:path'; import { globSync } from 'glob'; -import { - readFileWithEncoding, - readFileWithEncodingInfo, -} from '../utils/fileUtils.js'; -import type { FileReadResult } from '../utils/fileUtils.js'; +import { readFileWithLineAndLimit } from '../utils/fileUtils.js'; import { iconvEncode, iconvEncodingExists, isUtf8CompatibleEncoding, } from '../utils/iconvHelper.js'; +import { getSystemEncoding } from '../utils/systemEncoding.js'; +import type { + ReadTextFileRequest, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; + +export type ReadTextFileResponse = { + content: string; + _meta?: { + bom?: boolean; + encoding?: string; + originalLineCount?: number; + }; +}; /** * Supported file encodings for new files. @@ -35,43 +47,13 @@ export type FileEncodingType = (typeof FileEncoding)[keyof typeof FileEncoding]; * Interface for file system operations that may be delegated to different implementations */ export interface FileSystemService { - /** - * Read text content from a file - * - * @param filePath - The path to the file to read - * @returns The file content as a string - */ - readTextFile(filePath: string): Promise; + readTextFile( + params: Omit, + ): Promise; - /** - * Read text content from a file, returning both the content and encoding metadata. - * Combines readTextFile + detectFileBOM + detectFileEncoding into a single I/O pass. - * - * @param filePath - The path to the file to read - * @returns The file content, encoding name, and whether a UTF-8 BOM was present - */ - readTextFileWithInfo(filePath: string): Promise; - - /** - * Write text content to a file - * - * @param filePath - The path to the file to write - * @param content - The content to write - * @param options - Optional write options including whether to add BOM - */ writeTextFile( - filePath: string, - content: string, - options?: WriteTextFileOptions, - ): Promise; - - /** - * Detects if a file has UTF-8 BOM (Byte Order Mark). - * - * @param filePath - The path to the file to check - * @returns True if the file has BOM, false otherwise - */ - detectFileBOM(filePath: string): Promise; + params: Omit, + ): Promise; /** * Finds files with a given name within specified search paths. @@ -104,19 +86,72 @@ export interface WriteTextFileOptions { } /** - * Detects if a buffer has UTF-8 BOM (Byte Order Mark). - * UTF-8 BOM is the byte sequence EF BB BF. - * - * @param buffer - The buffer to check - * @returns True if the buffer starts with UTF-8 BOM + * File extensions that require CRLF (\r\n) line endings to function correctly. + * cmd.exe parses .bat/.cmd files using CRLF delimiters; LF-only endings can + * break multi-line constructs, labels, and goto statements. */ -function hasUTF8BOM(buffer: Buffer): boolean { - return ( - buffer.length >= 3 && - buffer[0] === 0xef && - buffer[1] === 0xbb && - buffer[2] === 0xbf - ); +const CRLF_EXTENSIONS = new Set(['.bat', '.cmd']); + +/** + * File extensions that need UTF-8 BOM on Windows with a non-UTF-8 code page. + * PowerShell 5.1 (the version that ships with Windows) reads BOM-less files + * using the system's ANSI code page. Without a BOM, any non-ASCII characters + * in the script will be misinterpreted (e.g. on a GBK system). PowerShell 7+ + * defaults to UTF-8 and handles BOM fine, so adding BOM is always safe. + */ +const UTF8_BOM_EXTENSIONS = new Set(['.ps1']); + +// Cache so we only call getSystemEncoding() once per process +let cachedIsNonUtf8Windows: boolean | undefined; + +/** + * Returns true if a newly created file at the given path should be written + * with a UTF-8 BOM. Conditions (all must be true): + * 1. Running on Windows + * 2. System code page is not UTF-8 + * 3. File extension is in UTF8_BOM_EXTENSIONS (e.g. .ps1) + */ +export function needsUtf8Bom(filePath: string): boolean { + const ext = path.extname(filePath).toLowerCase(); + if (!UTF8_BOM_EXTENSIONS.has(ext)) { + return false; + } + if (cachedIsNonUtf8Windows === undefined) { + if (os.platform() !== 'win32') { + cachedIsNonUtf8Windows = false; + } else { + const sysEnc = getSystemEncoding(); + cachedIsNonUtf8Windows = sysEnc !== 'utf-8'; + } + } + return cachedIsNonUtf8Windows; +} + +/** + * Reset the UTF-8 BOM cache — useful for testing. + */ +export function resetUtf8BomCache(): void { + cachedIsNonUtf8Windows = undefined; +} + +/** + * Returns true if the file at the given path requires CRLF line endings. + * Only applies on Windows where cmd.exe actually parses these files. + */ +function needsCrlfLineEndings(filePath: string): boolean { + if (os.platform() !== 'win32') { + return false; + } + const ext = path.extname(filePath).toLowerCase(); + return CRLF_EXTENSIONS.has(ext); +} + +/** + * Ensures content uses CRLF line endings. First normalizes any existing + * \r\n to \n to avoid double-conversion, then converts all \n to \r\n. + */ +function ensureCrlfLineEndings(content: string): string { + return content.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); } /** @@ -148,24 +183,30 @@ function getBOMBytesForEncoding(encoding: string): Buffer | null { * Standard file system implementation */ export class StandardFileSystemService implements FileSystemService { - async readTextFile(filePath: string): Promise { + async readTextFile( + params: Omit, + ): Promise { + const { path, limit, line } = params; // Use encoding-aware reader that handles BOM and non-UTF-8 encodings (e.g. GBK) - return readFileWithEncoding(filePath); - } - - async readTextFileWithInfo(filePath: string): Promise { - // Single I/O pass: returns content, encoding, and BOM flag together, - // eliminating the need for separate detectFileEncoding / detectFileBOM calls. - return readFileWithEncodingInfo(filePath); + const { content, bom, encoding, originalLineCount } = + await readFileWithLineAndLimit({ + path, + limit: limit ?? Number.POSITIVE_INFINITY, + line: line || 0, + }); + return { content, _meta: { bom, encoding, originalLineCount } }; } async writeTextFile( - filePath: string, - content: string, - options?: WriteTextFileOptions, - ): Promise { - const bom = options?.bom ?? false; - const encoding = options?.encoding; + params: Omit, + ): Promise { + const { path: filePath, _meta } = params; + // Convert LF to CRLF for file types that require it (e.g. .bat, .cmd) + const content = needsCrlfLineEndings(filePath) + ? ensureCrlfLineEndings(params.content) + : params.content; + const bom = _meta?.['bom'] ?? (false as boolean); + const encoding = _meta?.['encoding'] as string | undefined; // Check if a non-UTF-8 encoding is specified and supported by iconv-lite const isNonUtf8Encoding = @@ -199,27 +240,7 @@ export class StandardFileSystemService implements FileSystemService { } else { await fs.writeFile(filePath, content, 'utf-8'); } - } - - async detectFileBOM(filePath: string): Promise { - let fd: fs.FileHandle | undefined; - try { - // Read only the first 3 bytes to check for BOM - fd = await fs.open(filePath, 'r'); - const buffer = Buffer.alloc(3); - const { bytesRead } = await fd.read(buffer, 0, 3, 0); - - if (bytesRead < 3) { - return false; - } - - return hasUTF8BOM(buffer); - } catch { - // File doesn't exist or can't be read - treat as no BOM - return false; - } finally { - await fd?.close(); - } + return { _meta }; } findFiles(fileName: string, searchPaths: readonly string[]): string[] { diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 1e93076fd..5dae23a2a 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -4,15 +4,29 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import EventEmitter from 'node:events'; import type { Readable } from 'node:stream'; import { type ChildProcess } from 'node:child_process'; +import pkg from '@xterm/headless'; import type { ShellOutputEvent } from './shellExecutionService.js'; import { ShellExecutionService } from './shellExecutionService.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; +const { Terminal } = pkg; + // Hoisted Mocks +const mockGetSystemEncoding = vi.hoisted(() => + vi.fn().mockReturnValue('utf-8'), +); const mockPtySpawn = vi.hoisted(() => vi.fn()); const mockCpSpawn = vi.hoisted(() => vi.fn()); const mockIsBinary = vi.hoisted(() => vi.fn()); @@ -64,6 +78,10 @@ vi.mock('../utils/terminalSerializer.js', () => ({ vi.mock('../utils/shell-utils.js', () => ({ getShellConfiguration: mockGetShellConfiguration, })); +vi.mock('../utils/systemEncoding.js', () => ({ + getCachedEncodingForBuffer: vi.fn().mockReturnValue('utf-8'), + getSystemEncoding: mockGetSystemEncoding, +})); const mockProcessKill = vi .spyOn(process, 'kill') @@ -77,6 +95,13 @@ const shellExecutionConfig = { disableDynamicLineTrimming: true, }; +const WINDOWS_SYSTEM_PATH = 'C:\\Windows\\System32;C:\\Shared\\Tools'; +const WINDOWS_USER_PATH = 'C:\\Users\\tester\\bin;C:\\Shared\\Tools'; +const EXPECTED_MERGED_WINDOWS_PATH = + 'C:\\Windows\\System32;C:\\Shared\\Tools;C:\\Users\\tester\\bin'; + +let originalProcessEnv: NodeJS.ProcessEnv; + const createExpectedAnsiOutput = (text: string | string[]): AnsiOutput => { const lines = Array.isArray(text) ? text : text.split('\n'); const expected: AnsiOutput = Array.from( @@ -97,6 +122,19 @@ const createExpectedAnsiOutput = (text: string | string[]): AnsiOutput => { return expected; }; +const setupConflictingPathEnv = () => { + process.env = { + ...originalProcessEnv, + PATH: WINDOWS_SYSTEM_PATH, + Path: WINDOWS_USER_PATH, + }; +}; + +const expectNormalizedWindowsPathEnv = (env: NodeJS.ProcessEnv) => { + expect(env['PATH']).toBe(EXPECTED_MERGED_WINDOWS_PATH); + expect(env['Path']).toBeUndefined(); +}; + describe('ShellExecutionService', () => { let mockPtyProcess: EventEmitter & { pid: number; @@ -119,6 +157,7 @@ describe('ShellExecutionService', () => { beforeEach(() => { vi.clearAllMocks(); + originalProcessEnv = process.env; mockIsBinary.mockReturnValue(false); mockPlatform.mockReturnValue('linux'); @@ -157,6 +196,11 @@ describe('ShellExecutionService', () => { mockPtySpawn.mockReturnValue(mockPtyProcess); }); + afterEach(() => { + process.env = originalProcessEnv; + vi.unstubAllEnvs(); + }); + // Helper function to run a standard execution simulation const simulateExecution = async ( command: string, @@ -258,6 +302,68 @@ describe('ShellExecutionService', () => { await handle.result; expect(handle.pid).toBe(12345); }); + + it('should preserve full raw output when terminal writes are backlogged', async () => { + vi.useFakeTimers(); + const originalWrite = Terminal.prototype.write; + const delayedWrite = vi + .spyOn(Terminal.prototype, 'write') + .mockImplementation(function ( + this: pkg.Terminal, + data: string | Uint8Array, + callback?: () => void, + ) { + setTimeout(() => { + originalWrite.call(this, data, callback); + }, 10); + }); + + try { + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + 'fast-output', + '/test/dir', + onOutputEventMock, + abortController.signal, + true, + shellExecutionConfig, + ); + + const onData = mockPtyProcess.onData.mock.calls[0][0] as ( + data: string, + ) => void; + for (let i = 1; i <= 500; i++) { + onData(`Line ${String(i).padStart(4, '0')}\n`); + } + + const resultPromise = handle.result; + mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + + await vi.advanceTimersByTimeAsync(250); + const result = await resultPromise; + + const lines = result.output.split('\n'); + expect(lines).toHaveLength(500); + expect(lines[0]).toBe('Line 0001'); + expect(lines[499]).toBe('Line 0500'); + } finally { + delayedWrite.mockRestore(); + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it('should collapse carriage-return progress updates in final output', async () => { + const { result } = await simulateExecution('progress-output', (pty) => { + pty.onData.mock.calls[0][0]('Compressing objects: 14% (1/7)\r'); + pty.onData.mock.calls[0][0]('Compressing objects: 28% (2/7)\r'); + pty.onData.mock.calls[0][0]('Compressing objects: 42% (3/7)\r'); + pty.onData.mock.calls[0][0]('Compressing objects: 100% (7/7), done.\n'); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + expect(result.output).toBe('Compressing objects: 100% (7/7), done.'); + }); }); describe('pty interaction', () => { @@ -272,17 +378,28 @@ describe('ShellExecutionService', () => { it('should write to the pty and trigger a render', async () => { vi.useFakeTimers(); - await simulateExecution('interactive-app', (pty) => { - ShellExecutionService.writeToPty(pty.pid!, 'input'); - pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); - }); + try { + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + 'interactive-app', + '/test/dir', + onOutputEventMock, + abortController.signal, + true, + shellExecutionConfig, + ); - expect(mockPtyProcess.write).toHaveBeenCalledWith('input'); - // Use fake timers to check for the delayed render - await vi.advanceTimersByTimeAsync(17); - // The render will cause an output event - expect(onOutputEventMock).toHaveBeenCalled(); - vi.useRealTimers(); + ShellExecutionService.writeToPty(handle.pid!, 'input'); + mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + + await vi.runAllTimersAsync(); + await handle.result; + + expect(mockPtyProcess.write).toHaveBeenCalledWith('input'); + expect(onOutputEventMock).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); it('should resize the pty and the headless terminal', async () => { @@ -431,7 +548,7 @@ describe('ShellExecutionService', () => { expect(mockPtySpawn).toHaveBeenCalledWith( 'cmd.exe', - ['/d', '/s', '/c', 'dir "foo bar"'], + '/d /s /c dir "foo bar"', expect.any(Object), ); mockGetShellConfiguration.mockReturnValue({ @@ -441,6 +558,46 @@ describe('ShellExecutionService', () => { }); }); + it('should use PowerShell on Windows with array args and UTF-8 prefix', async () => { + mockPlatform.mockReturnValue('win32'); + mockGetShellConfiguration.mockReturnValue({ + executable: 'powershell.exe', + argsPrefix: ['-NoProfile', '-Command'], + shell: 'powershell', + }); + await simulateExecution('Test-Path "C:\\Temp\\"', (pty) => + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), + ); + + // PowerShell commands on Windows are prefixed with UTF-8 output encoding + expect(mockPtySpawn).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;Test-Path "C:\\Temp\\"', + ], + expect.any(Object), + ); + mockGetShellConfiguration.mockReturnValue({ + executable: 'bash', + argsPrefix: ['-c'], + shell: 'bash', + }); + }); + + it('should normalize PATH-like env keys on Windows for pty execution', async () => { + mockPlatform.mockReturnValue('win32'); + setupConflictingPathEnv(); + + await simulateExecution('dir', (pty) => + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), + ); + + const spawnOptions = mockPtySpawn.mock.calls[0][2]; + expectNormalizedWindowsPathEnv(spawnOptions.env); + }); + it('should use bash on Linux', async () => { mockPlatform.mockReturnValue('linux'); await simulateExecution('ls "foo bar"', (pty) => @@ -548,6 +705,7 @@ describe('ShellExecutionService child_process fallback', () => { beforeEach(() => { vi.clearAllMocks(); + originalProcessEnv = process.env; mockIsBinary.mockReturnValue(false); mockPlatform.mockReturnValue('linux'); @@ -569,6 +727,11 @@ describe('ShellExecutionService child_process fallback', () => { mockCpSpawn.mockReturnValue(mockChildProcess); }); + afterEach(() => { + process.env = originalProcessEnv; + vi.unstubAllEnvs(); + }); + // Helper function to run a standard execution simulation const simulateExecution = async ( command: string, @@ -840,7 +1003,7 @@ describe('ShellExecutionService child_process fallback', () => { }); describe('Platform-Specific Behavior', () => { - it('should use cmd.exe and hide window on Windows', async () => { + it('should use cmd.exe with windowsVerbatimArguments on Windows', async () => { mockPlatform.mockReturnValue('win32'); mockGetShellConfiguration.mockReturnValue({ executable: 'cmd.exe', @@ -857,6 +1020,7 @@ describe('ShellExecutionService child_process fallback', () => { expect.objectContaining({ detached: false, windowsHide: true, + windowsVerbatimArguments: true, }), ); mockGetShellConfiguration.mockReturnValue({ @@ -866,6 +1030,48 @@ describe('ShellExecutionService child_process fallback', () => { }); }); + it('should use PowerShell with UTF-8 prefix without windowsVerbatimArguments on Windows', async () => { + mockPlatform.mockReturnValue('win32'); + mockGetShellConfiguration.mockReturnValue({ + executable: 'powershell.exe', + argsPrefix: ['-NoProfile', '-Command'], + shell: 'powershell', + }); + await simulateExecution('Test-Path "C:\\Temp\\"', (cp) => + cp.emit('exit', 0, null), + ); + + // PowerShell commands on Windows are prefixed with UTF-8 output encoding + expect(mockCpSpawn).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;Test-Path "C:\\Temp\\"', + ], + expect.objectContaining({ + detached: false, + windowsHide: true, + windowsVerbatimArguments: false, + }), + ); + mockGetShellConfiguration.mockReturnValue({ + executable: 'bash', + argsPrefix: ['-c'], + shell: 'bash', + }); + }); + + it('should normalize PATH-like env keys on Windows for child_process fallback', async () => { + mockPlatform.mockReturnValue('win32'); + setupConflictingPathEnv(); + + await simulateExecution('dir', (cp) => cp.emit('exit', 0, null)); + + const spawnOptions = mockCpSpawn.mock.calls[0][2]; + expectNormalizedWindowsPathEnv(spawnOptions.env); + }); + it('should use bash and detached process group on Linux', async () => { mockPlatform.mockReturnValue('linux'); await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null)); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 50cdc3a09..e943275bd 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -22,6 +22,103 @@ import { const { Terminal } = pkg; const SIGKILL_TIMEOUT_MS = 200; +const WINDOWS_PATH_DELIMITER = ';'; +let cachedWindowsPathFingerprint: string | undefined; +let cachedMergedWindowsPath: string | undefined; + +function mergeWindowsPathValues( + env: NodeJS.ProcessEnv, + pathKeys: string[], +): string | undefined { + const mergedEntries: string[] = []; + const seenEntries = new Set(); + + for (const key of pathKeys) { + const value = env[key]; + if (value === undefined) { + continue; + } + + for (const entry of value.split(WINDOWS_PATH_DELIMITER)) { + if (seenEntries.has(entry)) { + continue; + } + seenEntries.add(entry); + mergedEntries.push(entry); + } + } + + return mergedEntries.length > 0 + ? mergedEntries.join(WINDOWS_PATH_DELIMITER) + : undefined; +} + +function getWindowsPathFingerprint( + env: NodeJS.ProcessEnv, + pathKeys: string[], +): string { + return pathKeys.map((key) => `${key}=${env[key] ?? ''}`).join('\0'); +} + +function normalizePathEnvForWindows(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (os.platform() !== 'win32') { + return env; + } + + const normalized: NodeJS.ProcessEnv = { ...env }; + const pathKeys = Object.keys(normalized).filter( + (key) => key.toLowerCase() === 'path', + ); + + if (pathKeys.length === 0) { + return normalized; + } + + const orderedPathKeys = [...pathKeys].sort((left, right) => { + if (left === 'PATH') { + return -1; + } + if (right === 'PATH') { + return 1; + } + return left.localeCompare(right); + }); + + const fingerprint = getWindowsPathFingerprint(normalized, orderedPathKeys); + const canonicalValue = + fingerprint === cachedWindowsPathFingerprint + ? cachedMergedWindowsPath + : mergeWindowsPathValues(normalized, orderedPathKeys); + + if (fingerprint !== cachedWindowsPathFingerprint) { + cachedWindowsPathFingerprint = fingerprint; + cachedMergedWindowsPath = canonicalValue; + } + + for (const key of pathKeys) { + if (key !== 'PATH') { + delete normalized[key]; + } + } + + if (canonicalValue !== undefined) { + normalized['PATH'] = canonicalValue; + } + + return normalized; +} + +/** + * On Windows with PowerShell, prefix the command with a statement that forces + * UTF-8 output encoding so that CJK and other non-ASCII characters are emitted + * as UTF-8 regardless of the system codepage. + */ +function applyPowerShellUtf8Prefix(command: string, shell: string): string { + if (os.platform() === 'win32' && shell === 'powershell') { + return '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;' + command; + } + return command; +} /** A structured result from a shell command execution. */ export interface ShellExecutionResult { @@ -93,12 +190,32 @@ const getFullBufferText = (terminal: pkg.Terminal): string => { const lines: string[] = []; for (let i = 0; i < buffer.length; i++) { const line = buffer.getLine(i); - const lineContent = line ? line.translateToString() : ''; + const lineContent = line ? line.translateToString(true) : ''; lines.push(lineContent); } return lines.join('\n').trimEnd(); }; +const replayTerminalOutput = async ( + output: string, + cols: number, + rows: number, +): Promise => { + const replayTerminal = new Terminal({ + allowProposedApi: true, + cols, + rows, + scrollback: 10000, + convertEol: true, + }); + + await new Promise((resolve) => { + replayTerminal.write(output, () => resolve()); + }); + + return getFullBufferText(replayTerminal); +}; + interface ProcessCleanupStrategy { killPty(pid: number, pty: ActivePty): void; killChildProcesses(pids: Set): void; @@ -224,19 +341,25 @@ export class ShellExecutionService { ): ShellExecutionHandle { try { const isWindows = os.platform() === 'win32'; - const { executable, argsPrefix } = getShellConfiguration(); + const { executable, argsPrefix, shell } = getShellConfiguration(); + commandToExecute = applyPowerShellUtf8Prefix(commandToExecute, shell); 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. + // + // windowsVerbatimArguments must only be true for cmd.exe: it skips + // Node's MSVC CRT escaping, which cmd.exe doesn't understand. For + // PowerShell (.NET), we need the default escaping so that args + // round-trip correctly through CommandLineToArgvW. const child = cpSpawn(executable, shellArgs, { cwd, stdio: ['ignore', 'pipe', 'pipe'], - windowsVerbatimArguments: isWindows, + windowsVerbatimArguments: isWindows && shell === 'cmd', detached: !isWindows, windowsHide: isWindows, env: { - ...process.env, + ...normalizePathEnvForWindows(process.env), QWEN_CODE: '1', TERM: 'xterm-256color', PAGER: 'cat', @@ -418,8 +541,23 @@ export class ShellExecutionService { try { const cols = shellExecutionConfig.terminalWidth ?? 80; const rows = shellExecutionConfig.terminalHeight ?? 30; - const { executable, argsPrefix } = getShellConfiguration(); - const args = [...argsPrefix, commandToExecute]; + const { executable, argsPrefix, shell } = getShellConfiguration(); + commandToExecute = applyPowerShellUtf8Prefix(commandToExecute, shell); + + // On Windows with cmd.exe, pass args as a single string instead of + // an array. node-pty's argsToCommandLine re-quotes array elements + // that contain spaces, which mangles user-provided quoted arguments + // for cmd.exe (e.g., `type "hello world"` becomes + // `"type \"hello world\""`). + // + // For PowerShell, keep the array form: argsToCommandLine escapes for + // CommandLineToArgvW round-tripping, which .NET correctly parses. + // The string form breaks quoted paths ending in \ (e.g., "C:\Temp\") + // because CommandLineToArgvW treats \" as an escaped quote. + const args: string[] | string = + os.platform() === 'win32' && shell === 'cmd' + ? [...argsPrefix, commandToExecute].join(' ') + : [...argsPrefix, commandToExecute]; const ptyProcess = ptyInfo.module.spawn(executable, args, { cwd, @@ -427,7 +565,7 @@ export class ShellExecutionService { cols, rows, env: { - ...process.env, + ...normalizePathEnvForWindows(process.env), QWEN_CODE: '1', TERM: 'xterm-256color', PAGER: shellExecutionConfig.pager ?? 'cat', @@ -456,6 +594,7 @@ export class ShellExecutionService { let isStreamingRawContent = true; const MAX_SNIFF_SIZE = 4096; let sniffedBytes = 0; + let totalBytesReceived = 0; let isWriting = false; let hasStartedOutput = false; let renderTimeout: NodeJS.Timeout | null = null; @@ -570,21 +709,31 @@ export class ShellExecutionService { } }); + const ensureDecoder = (data: Buffer) => { + if (decoder) { + return; + } + + const encoding = getCachedEncodingForBuffer(data); + try { + decoder = new TextDecoder(encoding); + } catch { + decoder = new TextDecoder('utf-8'); + } + }; + const handleOutput = (data: Buffer) => { + // Capture raw output immediately. Rendering the headless terminal is + // slower than appending a Buffer, and rapid PTY output can otherwise + // overrun the render queue before finalize() races on exit. + ensureDecoder(data); + outputChunks.push(data); + totalBytesReceived += data.length; + const bytesReceived = totalBytesReceived; + processingChain = processingChain.then( () => new Promise((resolve) => { - if (!decoder) { - const encoding = getCachedEncodingForBuffer(data); - try { - decoder = new TextDecoder(encoding); - } catch { - decoder = new TextDecoder('utf-8'); - } - } - - outputChunks.push(data); - if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20)); sniffedBytes = sniffBuffer.length; @@ -596,7 +745,7 @@ export class ShellExecutionService { } if (isStreamingRawContent) { - const decodedChunk = decoder.decode(data, { stream: true }); + const decodedChunk = decoder!.decode(data, { stream: true }); isWriting = true; headlessTerminal.write(decodedChunk, () => { render(); @@ -604,13 +753,9 @@ export class ShellExecutionService { resolve(); }); } else { - const totalBytes = outputChunks.reduce( - (sum, chunk) => sum + chunk.length, - 0, - ); onOutputEvent({ type: 'binary_progress', - bytesReceived: totalBytes, + bytesReceived, }); resolve(); } @@ -629,13 +774,40 @@ export class ShellExecutionService { abortSignal.removeEventListener('abort', abortHandler); this.activePtys.delete(ptyProcess.pid); - const finalize = () => { + const finalize = async () => { render(true); const finalBuffer = Buffer.concat(outputChunks); + let fullOutput = ''; + + try { + if (isStreamingRawContent) { + // Re-decode the full buffer with proper encoding detection. + // The streaming decoder used the first-chunk heuristic which + // can misdetect when early output is ASCII-only but later + // output is in a different encoding (e.g. GBK). + const finalEncoding = getCachedEncodingForBuffer(finalBuffer); + const decodedOutput = new TextDecoder(finalEncoding).decode( + finalBuffer, + ); + fullOutput = await replayTerminalOutput( + decodedOutput, + cols, + rows, + ); + } else { + fullOutput = getFullBufferText(headlessTerminal); + } + } catch { + try { + fullOutput = getFullBufferText(headlessTerminal); + } catch { + // Ignore fallback rendering errors and resolve with empty text. + } + } resolve({ rawOutput: finalBuffer, - output: getFullBufferText(headlessTerminal), + output: fullOutput, exitCode, signal: signal ?? null, error, @@ -647,16 +819,20 @@ export class ShellExecutionService { }); }; - // 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), + // Give any last onData callbacks a chance to run before finalizing. + // onExit can arrive slightly before late PTY data is processed. + const flushChain = () => processingChain.then(() => {}); + const deadline = new Promise((res) => + setTimeout(res, SIGKILL_TIMEOUT_MS), ); + const drain = () => + new Promise((res) => setImmediate(res)).then(flushChain); - void Promise.race([processingComplete, deadline]).then(() => { - finalize(); + void Promise.race([ + flushChain().then(drain).then(drain), + deadline, + ]).then(() => { + void finalize(); }); }, ); diff --git a/packages/core/src/skills/bundled/review/SKILL.md b/packages/core/src/skills/bundled/review/SKILL.md new file mode 100644 index 000000000..14e5f27e6 --- /dev/null +++ b/packages/core/src/skills/bundled/review/SKILL.md @@ -0,0 +1,118 @@ +--- +name: review +description: Review changed code for correctness, security, code quality, and performance. Use when the user asks to review code changes, a PR, or specific files. Invoke with `/review`, `/review `, or `/review `. +allowedTools: + - task + - run_shell_command + - grep_search + - read_file + - glob +--- + +# Code Review + +You are an expert code reviewer. Your job is to review code changes and provide actionable feedback. + +## Step 1: Determine what to review + +Based on the arguments provided: + +- **No arguments**: Review local uncommitted changes + - Run `git diff` and `git diff --staged` to get all changes + - If both diffs are empty, inform the user there are no changes to review and stop here — do not proceed to the review agents + +- **PR number or URL** (e.g., `123` or `https://github.com/.../pull/123`): + - Run `gh pr view ` to get PR details + - Run `gh pr diff ` to get the diff + +- **File path** (e.g., `src/foo.ts`): + - Run `git diff HEAD -- ` to get recent changes + - If no diff, read the file and review its current state + +## Step 2: Parallel multi-dimensional review + +Launch **four parallel review agents** to analyze the changes from different angles. Each agent should focus exclusively on its dimension. + +### Agent 1: Correctness & Security + +Focus areas: + +- Logic errors and edge cases +- Null/undefined handling +- Race conditions and concurrency issues +- Security vulnerabilities (injection, XSS, SSRF, path traversal, etc.) +- Type safety issues +- Error handling gaps + +### Agent 2: Code Quality + +Focus areas: + +- Code style consistency with the surrounding codebase +- Naming conventions (variables, functions, classes) +- Code duplication and opportunities for reuse +- Over-engineering or unnecessary abstraction +- Missing or misleading comments +- Dead code + +### Agent 3: Performance & Efficiency + +Focus areas: + +- Performance bottlenecks (N+1 queries, unnecessary loops, etc.) +- Memory leaks or excessive memory usage +- Unnecessary re-renders (for UI code) +- Inefficient algorithms or data structures +- Missing caching opportunities +- Bundle size impact + +### Agent 4: Undirected Audit + +No preset dimension. Review the code with a completely fresh perspective to catch issues the other three agents may miss. +Focus areas: + +- Business logic soundness and correctness of assumptions +- Boundary interactions between modules or services +- Implicit assumptions that may break under different conditions +- Unexpected side effects or hidden coupling +- Anything else that looks off — trust your instincts + +## Step 3: Aggregate and present findings + +Combine results from all four agents into a single, well-organized review. Use this format: + +### Summary + +A 1-2 sentence overview of the changes and overall assessment. + +### Findings + +Use severity levels: + +- **Critical** — Must fix before merging. Bugs, security issues, data loss risks. +- **Suggestion** — Recommended improvement. Better patterns, clearer code, potential issues. +- **Nice to have** — Optional optimization. Minor style tweaks, small performance gains. + +For each finding, include: + +1. **File and line reference** (e.g., `src/foo.ts:42`) +2. **What's wrong** — Clear description of the issue +3. **Why it matters** — Impact if not addressed +4. **Suggested fix** — Concrete code suggestion when possible + +### Verdict + +One of: + +- **Approve** — No critical issues, good to merge +- **Request changes** — Has critical issues that need fixing +- **Comment** — Has suggestions but no blockers + +## Guidelines + +- Be specific and actionable. Avoid vague feedback like "could be improved." +- Reference the existing codebase conventions — don't impose external style preferences. +- Focus on the diff, not pre-existing issues in unchanged code. +- Keep the review concise. Don't repeat the same point for every occurrence. +- When suggesting a fix, show the actual code change. +- Flag any exposed secrets, credentials, API keys, or tokens in the diff as **Critical**. diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index 94d5869f9..6cb697e52 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -11,9 +11,13 @@ * users to define reusable skill configurations that can be loaded by the * model via a dedicated Skills tool. * - * Skills are stored as directories in `.qwen/skills/` (project-level) or - * `~/.qwen/skills/` (user-level), with each directory containing a SKILL.md - * file with YAML frontmatter for metadata. + * Skills are stored as directories containing a SKILL.md file with YAML + * frontmatter for metadata. They can be loaded from four levels + * (precedence: project > user > extension > bundled): + * - Project-level: `.qwen/skills/` + * - User-level: `~/.qwen/skills/` + * - Extension-level: provided by installed extensions + * - Bundled: built-in skills shipped with qwen-code */ // Core types and interfaces diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index bd047e431..78c8f36d4 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -621,6 +621,118 @@ Skill 3 content`); expect(baseDirs).toContain(path.join('/home/user', '.codex', 'skills')); expect(baseDirs).toContain(path.join('/home/user', '.claude', 'skills')); }); + + it('should return bundled-level base dir', () => { + const baseDir = manager.getSkillsBaseDir('bundled'); + + expect(baseDir).toMatch(/skills[/\\]bundled$/); + }); + + it('should throw for extension level', () => { + expect(() => manager.getSkillsBaseDir('extension')).toThrow( + 'Extension skills do not have a base directory', + ); + }); + }); + + describe('bundled skills', () => { + const bundledDirSegment = path.join('skills', 'bundled'); + const projectDirSegment = path.join('.qwen', 'skills'); + const userDirSegment = path.join('.qwen', 'skills'); + const projectPrefix = path.join('/test/project'); + const userPrefix = path.join('/home/user'); + + const reviewDirEntry = { + name: 'review', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }; + + const emptyDir = [] as unknown as Awaited>; + + function mockReaddirForLevels(levels: Set) { + vi.mocked(fs.readdir).mockImplementation((dirPath) => { + const pathStr = String(dirPath); + const isBundled = + pathStr.endsWith(bundledDirSegment) && !pathStr.includes('.qwen'); + const isProject = + pathStr.includes(projectDirSegment) && + pathStr.startsWith(projectPrefix); + const isUser = + pathStr.includes(userDirSegment) && pathStr.startsWith(userPrefix); + + if ( + (levels.has('bundled') && isBundled) || + (levels.has('project') && isProject) || + (levels.has('user') && isUser) + ) { + return Promise.resolve([reviewDirEntry] as unknown as Awaited< + ReturnType + >); + } + return Promise.resolve(emptyDir); + }); + } + + function setupReviewSkillMocks() { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(`--- +name: review +description: Review code changes +--- +Review content`); + + mockParseYaml.mockReturnValue({ + name: 'review', + description: 'Review code changes', + }); + } + + it('should load bundled skills in listSkills', async () => { + mockReaddirForLevels(new Set(['bundled'])); + setupReviewSkillMocks(); + + const skills = await manager.listSkills({ force: true }); + + expect(skills.some((s) => s.name === 'review')).toBe(true); + const reviewSkill = skills.find((s) => s.name === 'review'); + expect(reviewSkill!.level).toBe('bundled'); + }); + + it('should prioritize project-level over bundled skills with same name', async () => { + mockReaddirForLevels(new Set(['project', 'bundled'])); + setupReviewSkillMocks(); + + const skills = await manager.listSkills({ force: true }); + + const reviewSkills = skills.filter((s) => s.name === 'review'); + expect(reviewSkills).toHaveLength(1); + expect(reviewSkills[0].level).toBe('project'); + }); + + it('should prioritize user-level over bundled skills with same name', async () => { + mockReaddirForLevels(new Set(['user', 'bundled'])); + setupReviewSkillMocks(); + + const skills = await manager.listSkills({ force: true }); + + const reviewSkills = skills.filter((s) => s.name === 'review'); + expect(reviewSkills).toHaveLength(1); + expect(reviewSkills[0].level).toBe('user'); + }); + + it('should fall back to bundled level in loadSkill', async () => { + // Project, user, extension all empty; bundled has the skill + mockReaddirForLevels(new Set(['bundled'])); + setupReviewSkillMocks(); + + const skill = await manager.loadSkill('review'); + + expect(skill).toBeDefined(); + expect(skill!.name).toBe('review'); + expect(skill!.level).toBe('bundled'); + }); }); describe('change listeners', () => { diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 6df002f23..fbeb18b8d 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -8,6 +8,7 @@ import * as fs from 'fs/promises'; import * as fsSync from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { fileURLToPath } from 'url'; import { watch as watchFs, type FSWatcher } from 'chokidar'; import { parse as parseYaml } from '../utils/yaml-parser.js'; import type { @@ -40,8 +41,14 @@ export class SkillManager { private readonly watchers: Map = new Map(); private watchStarted = false; private refreshTimer: NodeJS.Timeout | null = null; + private readonly bundledSkillsDir: string; - constructor(private readonly config: Config) {} + constructor(private readonly config: Config) { + this.bundledSkillsDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'bundled', + ); + } /** * Adds a listener that will be called when skills change. @@ -90,7 +97,7 @@ export class SkillManager { const levelsToCheck: SkillLevel[] = options.level ? [options.level] - : ['project', 'user', 'extension']; + : ['project', 'user', 'extension', 'bundled']; // Check if we should use cache or force refresh const shouldUseCache = !options.force && this.skillsCache !== null; @@ -103,7 +110,7 @@ export class SkillManager { debugLogger.debug('Using cached skills'); } - // Collect skills from each level (project takes precedence over user over extension) + // Collect skills from each level (precedence: project > user > extension > bundled) for (const level of levelsToCheck) { const levelSkills = this.skillsCache?.get(level) || []; debugLogger.debug( @@ -111,7 +118,7 @@ export class SkillManager { ); for (const skill of levelSkills) { - // Skip if we've already seen this name (precedence: project > user > extension) + // Skip if we've already seen this name (precedence: project > user > extension > bundled) if (seenNames.has(skill.name)) { debugLogger.debug( `Skipping duplicate skill: ${skill.name} (${level})`, @@ -134,7 +141,7 @@ export class SkillManager { /** * Loads a skill configuration by name. * If level is specified, only searches that level. - * If level is omitted, searches project-level first, then user-level. + * If level is omitted, searches in precedence order: project > user > extension > bundled. * * @param name - Name of the skill to load * @param level - Optional level to limit search to @@ -165,7 +172,7 @@ export class SkillManager { return projectSkill; } - // Try user level first + // Try user level const userSkill = await this.findSkillByNameAtLevel(name, 'user'); if (userSkill) { debugLogger.debug(`Found skill ${name} at user level`); @@ -176,10 +183,19 @@ export class SkillManager { const extensionSkill = await this.findSkillByNameAtLevel(name, 'extension'); if (extensionSkill) { debugLogger.debug(`Found skill ${name} at extension level`); - } else { - debugLogger.debug(`Skill ${name} not found at any level`); + return extensionSkill; } - return extensionSkill; + + // Try bundled level (lowest precedence) + const bundledSkill = await this.findSkillByNameAtLevel(name, 'bundled'); + if (bundledSkill) { + debugLogger.debug(`Found skill ${name} at bundled level`); + } else { + debugLogger.debug( + `Skill ${name} not found at any level (checked: project, user, extension, bundled)`, + ); + } + return bundledSkill; } /** @@ -227,7 +243,7 @@ export class SkillManager { const skillsCache = new Map(); this.parseErrors.clear(); - const levels: SkillLevel[] = ['project', 'user', 'extension']; + const levels: SkillLevel[] = ['project', 'user', 'extension', 'bundled']; let totalSkills = 0; for (const level of levels) { @@ -416,15 +432,24 @@ export class SkillManager { * @returns Absolute directory paths */ getSkillsBaseDirs(level: SkillLevel): string[] { - const baseDirs = - level === 'project' - ? SKILL_PROVIDER_CONFIG_DIRS.map((v) => - path.join(this.config.getProjectRoot(), v, SKILLS_CONFIG_DIR), - ) - : SKILL_PROVIDER_CONFIG_DIRS.map((v) => - path.join(os.homedir(), v, SKILLS_CONFIG_DIR), - ); - return baseDirs; + switch (level) { + case 'project': + return SKILL_PROVIDER_CONFIG_DIRS.map((v) => + path.join(this.config.getProjectRoot(), v, SKILLS_CONFIG_DIR), + ); + case 'user': + return SKILL_PROVIDER_CONFIG_DIRS.map((v) => + path.join(os.homedir(), v, SKILLS_CONFIG_DIR), + ); + case 'bundled': + return [this.bundledSkillsDir]; + case 'extension': + throw new Error( + 'Extension skills do not have a base directory; they are loaded from active extensions.', + ); + default: + throw new Error(`Unknown skill level: ${level as string}`); + } } /** @@ -461,6 +486,20 @@ export class SkillManager { return skills; } + if (level === 'bundled') { + const bundledDir = this.bundledSkillsDir; + if (!fsSync.existsSync(bundledDir)) { + debugLogger.warn( + `Bundled skills directory not found: ${bundledDir}. This may indicate an incomplete installation.`, + ); + return []; + } + debugLogger.debug(`Loading bundled skills from: ${bundledDir}`); + const skills = await this.loadSkillsFromDir(bundledDir, 'bundled'); + debugLogger.debug(`Loaded ${skills.length} bundled skills`); + return skills; + } + // Iterate provider directories in PROVIDER_CONFIG_DIRS order. // The first directory that contains a skill with a given name wins, // so the order defines implicit precedence (.qwen > .agent > .cursor > ...). @@ -597,6 +636,9 @@ export class SkillManager { } } + // Only watch project and user skill directories for changes. + // Bundled skills are immutable (shipped with the package) and extension + // skills are managed by the extension system, so neither needs watching. private updateWatchersFromCache(): void { const watchTargets = new Set( (['project', 'user'] as const) diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts index 8227e9ea8..cf58ec7c2 100644 --- a/packages/core/src/skills/types.ts +++ b/packages/core/src/skills/types.ts @@ -9,8 +9,9 @@ * - 'project': Stored in `.qwen/skills/` within the project directory * - 'user': Stored in `~/.qwen/skills/` in the user's home directory * - 'extension': Provided by an installed extension + * - 'bundled': Built-in skills shipped with qwen-code */ -export type SkillLevel = 'project' | 'user' | 'extension'; +export type SkillLevel = 'project' | 'user' | 'extension' | 'bundled'; /** * Core configuration for a skill as stored in SKILL.md files. diff --git a/packages/core/src/subagents/validation.test.ts b/packages/core/src/subagents/validation.test.ts index 26819845d..1d705cc0d 100644 --- a/packages/core/src/subagents/validation.test.ts +++ b/packages/core/src/subagents/validation.test.ts @@ -164,21 +164,12 @@ describe('SubagentValidator', () => { ); }); - it('should reject prompts that are too long', () => { - const longPrompt = 'a'.repeat(10001); - const result = validator.validateSystemPrompt(longPrompt); - expect(result.isValid).toBe(false); - expect(result.errors).toContain( - 'System prompt is too long (>10,000 characters)', - ); - }); - it('should warn about long prompts', () => { - const longPrompt = 'a'.repeat(5001); + const longPrompt = 'a'.repeat(10001); const result = validator.validateSystemPrompt(longPrompt); expect(result.isValid).toBe(true); expect(result.warnings).toContain( - 'System prompt is quite long (>5,000 characters), consider shortening', + 'System prompt is quite long (>10,000 characters), consider shortening', ); }); }); @@ -372,7 +363,7 @@ describe('SubagentValidator', () => { const configWithWarnings: SubagentConfig = { ...validConfig, name: 'TestAgent', // Will generate warning about case - description: 'A'.repeat(501), // Will generate warning about long description + description: 'A'.repeat(1001), // Will generate warning about long description }; const result = validator.validateConfig(configWithWarnings); diff --git a/packages/core/src/subagents/validation.ts b/packages/core/src/subagents/validation.ts index 5df8cc315..ac45a3796 100644 --- a/packages/core/src/subagents/validation.ts +++ b/packages/core/src/subagents/validation.ts @@ -36,9 +36,9 @@ export class SubagentValidator { // Validate description if (!config.description || config.description.trim().length === 0) { errors.push('Description is required and cannot be empty'); - } else if (config.description.length > 500) { + } else if (config.description.length > 1000) { warnings.push( - 'Description is quite long (>500 chars), consider shortening for better readability', + 'Description is quite long (>1,000 chars), consider shortening for better readability', ); } @@ -181,12 +181,10 @@ export class SubagentValidator { errors.push('System prompt must be at least 10 characters long'); } - // Check maximum length to prevent token issues + // Warn for very long prompts if (trimmedPrompt.length > 10000) { - errors.push('System prompt is too long (>10,000 characters)'); - } else if (trimmedPrompt.length > 5000) { warnings.push( - 'System prompt is quite long (>5,000 characters), consider shortening', + 'System prompt is quite long (>10,000 characters), consider shortening', ); } diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index cea2188eb..8149dfc47 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -7,6 +7,7 @@ export const SERVICE_NAME = 'qwen-code'; export const EVENT_USER_PROMPT = 'qwen-code.user_prompt'; +export const EVENT_USER_RETRY = 'qwen-code.user_retry'; export const EVENT_TOOL_CALL = 'qwen-code.tool_call'; export const EVENT_API_REQUEST = 'qwen-code.api_request'; export const EVENT_API_ERROR = 'qwen-code.api_error'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 0f5981ed4..cc21d7716 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -27,6 +27,7 @@ export { export { logStartSession, logUserPrompt, + logUserRetry, logToolCall, logApiRequest, logApiError, @@ -54,6 +55,7 @@ export { SlashCommandStatus, EndSessionEvent, UserPromptEvent, + UserRetryEvent, ApiRequestEvent, ApiErrorEvent, ApiResponseEvent, diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index ab026304a..34d142c4f 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -148,15 +148,11 @@ describe('loggers', () => { const mockConfig = { getSessionId: () => 'test-session-id', getModel: () => 'test-model', - getEmbeddingModel: () => 'test-embedding-model', getSandbox: () => true, getCoreTools: () => ['ls', 'read-file'], getApprovalMode: () => 'default', - getContentGeneratorConfig: () => ({ - model: 'test-model', - apiKey: 'test-api-key', - authType: AuthType.USE_VERTEX_AI, - }), + getTruncateToolOutputThreshold: () => 25000, + getTruncateToolOutputLines: () => 1000, getTelemetryEnabled: () => true, getUsageStatisticsEnabled: () => true, getTelemetryLogPromptsEnabled: () => true, @@ -174,6 +170,9 @@ describe('loggers', () => { getOutputFormat: () => OutputFormat.JSON, getToolRegistry: () => undefined, getChatRecordingService: () => undefined, + getHookSystem: () => undefined, + getIdeMode: () => false, + getShouldUseNodePtyShell: () => true, } as unknown as Config; const startSessionEvent = new StartSessionEvent(mockConfig); @@ -186,19 +185,20 @@ describe('loggers', () => { 'event.name': EVENT_CLI_CONFIG, 'event.timestamp': '2025-01-01T00:00:00.000Z', model: 'test-model', - embedding_model: 'test-embedding-model', sandbox_enabled: true, core_tools_enabled: 'ls,read-file', approval_mode: 'default', - api_key_enabled: true, - vertex_ai_enabled: true, - log_user_prompts_enabled: true, + truncate_tool_output_threshold: 25000, + truncate_tool_output_lines: 1000, file_filtering_respect_git_ignore: true, debug_mode: true, mcp_servers: 'test-server', mcp_servers_count: 1, mcp_tools: undefined, mcp_tools_count: undefined, + hooks: undefined, + ide_enabled: false, + interactive_shell_enabled: true, output_format: 'json', skills: undefined, subagents: undefined, diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index d15d1bcb7..30334751a 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -20,6 +20,7 @@ import { EVENT_IDE_CONNECTION, EVENT_TOOL_CALL, EVENT_USER_PROMPT, + EVENT_USER_RETRY, EVENT_FLASH_FALLBACK, EVENT_NEXT_SPEAKER_CHECK, SERVICE_NAME, @@ -66,6 +67,7 @@ import type { StartSessionEvent, ToolCallEvent, UserPromptEvent, + UserRetryEvent, FlashFallbackEvent, NextSpeakerCheckEvent, LoopDetectedEvent, @@ -115,19 +117,20 @@ export function logStartSession( 'event.name': EVENT_CLI_CONFIG, 'event.timestamp': new Date().toISOString(), model: event.model, - embedding_model: event.embedding_model, sandbox_enabled: event.sandbox_enabled, core_tools_enabled: event.core_tools_enabled, approval_mode: event.approval_mode, - api_key_enabled: event.api_key_enabled, - vertex_ai_enabled: event.vertex_ai_enabled, - log_user_prompts_enabled: event.telemetry_log_user_prompts_enabled, file_filtering_respect_git_ignore: event.file_filtering_respect_git_ignore, debug_mode: event.debug_enabled, + truncate_tool_output_threshold: event.truncate_tool_output_threshold, + truncate_tool_output_lines: event.truncate_tool_output_lines, mcp_servers: event.mcp_servers, mcp_servers_count: event.mcp_servers_count, mcp_tools: event.mcp_tools, mcp_tools_count: event.mcp_tools_count, + hooks: event.hooks, + ide_enabled: event.ide_enabled, + interactive_shell_enabled: event.interactive_shell_enabled, output_format: event.output_format, skills: event.skills, subagents: event.subagents, @@ -169,6 +172,25 @@ export function logUserPrompt(config: Config, event: UserPromptEvent): void { logger.emit(logRecord); } +export function logUserRetry(config: Config, event: UserRetryEvent): void { + QwenLogger.getInstance(config)?.logRetryEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + 'event.name': EVENT_USER_RETRY, + 'event.timestamp': new Date().toISOString(), + prompt_id: event.prompt_id, + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `User retry.`, + attributes, + }; + logger.emit(logRecord); +} + export function logToolCall(config: Config, event: ToolCallEvent): void { const uiEvent = { ...event, diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts index 6cc0f230a..352d90e12 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts @@ -81,6 +81,11 @@ const makeFakeConfig = (overrides: Partial = {}): Config => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getToolRegistry: () => undefined, + getTruncateToolOutputThreshold: () => 25000, + getTruncateToolOutputLines: () => 0, + getIdeMode: () => false, + getShouldUseNodePtyShell: () => false, + getHookSystem: () => undefined, ...overrides, }; return defaults as Config; diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index 6d30e13e1..0d89d6b69 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -42,6 +42,7 @@ import type { AuthEvent, SkillLaunchEvent, UserFeedbackEvent, + UserRetryEvent, RipgrepFallbackEvent, EndSessionEvent, ExtensionUpdateEvent, @@ -415,20 +416,20 @@ export class QwenLogger { const applicationEvent = this.createViewEvent('session', 'session_start', { properties: { - model: event.model, approval_mode: event.approval_mode, - embedding_model: event.embedding_model, - sandbox_enabled: event.sandbox_enabled, core_tools_enabled: event.core_tools_enabled, - api_key_enabled: event.api_key_enabled, - vertex_ai_enabled: event.vertex_ai_enabled, debug_enabled: event.debug_enabled, + hooks: event.hooks, + ide_enabled: event.ide_enabled, + interactive_shell_enabled: event.interactive_shell_enabled, mcp_servers: event.mcp_servers, - telemetry_enabled: event.telemetry_enabled, - telemetry_log_user_prompts_enabled: - event.telemetry_log_user_prompts_enabled, + model: event.model, + sandbox_enabled: event.sandbox_enabled, skills: event.skills, subagents: event.subagents, + telemetry_enabled: event.telemetry_enabled, + truncate_tool_output_lines: event.truncate_tool_output_lines, + truncate_tool_output_threshold: event.truncate_tool_output_threshold, }, }); @@ -465,7 +466,6 @@ export class QwenLogger { logNewPromptEvent(event: UserPromptEvent): void { const rumEvent = this.createActionEvent('user', 'new_prompt', { properties: { - auth_type: event.auth_type, prompt_id: event.prompt_id, prompt_length: event.prompt_length, }, @@ -475,6 +475,17 @@ export class QwenLogger { this.flushIfNeeded(); } + logRetryEvent(event: UserRetryEvent): void { + const rumEvent = this.createActionEvent('user', 'retry', { + properties: { + prompt_id: event.prompt_id, + }, + }); + + this.enqueueLogEvent(rumEvent); + this.flushIfNeeded(); + } + logSlashCommandEvent(event: SlashCommandEvent): void { const rumEvent = this.createActionEvent('user', 'slash_command', { properties: { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index d9c6b535d..c9e6c2d53 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -10,7 +10,7 @@ import type { ApprovalMode } from '../config/config.js'; import type { CompletedToolCall } from '../core/coreToolScheduler.js'; import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import type { FileDiff } from '../tools/tools.js'; -import { AuthType } from '../core/contentGenerator.js'; +import type { AuthType } from '../core/contentGenerator.js'; import { getDecisionFromOutcome, ToolCallDecision, @@ -35,55 +35,60 @@ export class StartSessionEvent implements BaseTelemetryEvent { 'event.timestamp': string; session_id: string; model: string; - embedding_model: string; sandbox_enabled: boolean; - core_tools_enabled: string; + core_tools_enabled?: string; approval_mode: string; - api_key_enabled: boolean; - vertex_ai_enabled: boolean; debug_enabled: boolean; + truncate_tool_output_threshold: number; + truncate_tool_output_lines: number; mcp_servers: string; telemetry_enabled: boolean; - telemetry_log_user_prompts_enabled: boolean; file_filtering_respect_git_ignore: boolean; mcp_servers_count: number; mcp_tools_count?: number; mcp_tools?: string; output_format: OutputFormat; + hooks?: string; + ide_enabled: boolean; + interactive_shell_enabled: boolean; skills?: string; subagents?: string; constructor(config: Config) { - const generatorConfig = config.getContentGeneratorConfig(); const mcpServers = config.getMcpServers(); const toolRegistry = config.getToolRegistry(); - let useGemini = false; - let useVertex = false; - if (generatorConfig && generatorConfig.authType) { - useGemini = generatorConfig.authType === AuthType.USE_GEMINI; - useVertex = generatorConfig.authType === AuthType.USE_VERTEX_AI; - } - this['event.name'] = 'cli_config'; this.session_id = config.getSessionId(); this.model = config.getModel(); - this.embedding_model = config.getEmbeddingModel(); this.sandbox_enabled = typeof config.getSandbox() === 'string' || !!config.getSandbox(); - this.core_tools_enabled = (config.getCoreTools() ?? []).join(','); + const coreTools = (config.getCoreTools() ?? []).join(','); + if (coreTools) { + this.core_tools_enabled = coreTools; + } this.approval_mode = config.getApprovalMode(); - this.api_key_enabled = useGemini || useVertex; - this.vertex_ai_enabled = useVertex; this.debug_enabled = config.getDebugMode(); + this.truncate_tool_output_threshold = + config.getTruncateToolOutputThreshold(); + this.truncate_tool_output_lines = config.getTruncateToolOutputLines(); this.mcp_servers = mcpServers ? Object.keys(mcpServers).join(',') : ''; this.telemetry_enabled = config.getTelemetryEnabled(); - this.telemetry_log_user_prompts_enabled = - config.getTelemetryLogPromptsEnabled(); this.file_filtering_respect_git_ignore = config.getFileFilteringRespectGitIgnore(); this.mcp_servers_count = mcpServers ? Object.keys(mcpServers).length : 0; this.output_format = config.getOutputFormat(); + this.ide_enabled = config.getIdeMode(); + this.interactive_shell_enabled = config.getShouldUseNodePtyShell(); + + const hookSystem = config.getHookSystem(); + if (hookSystem) { + const allHooks = hookSystem.getAllHooks(); + const uniqueEventNames = [...new Set(allHooks.map((h) => h.eventName))]; + if (uniqueEventNames.length > 0) { + this.hooks = uniqueEventNames.join(','); + } + } if (toolRegistry) { const mcpTools = toolRegistry @@ -148,6 +153,18 @@ export class UserPromptEvent implements BaseTelemetryEvent { } } +export class UserRetryEvent implements BaseTelemetryEvent { + 'event.name': 'user_retry'; + 'event.timestamp': string; + prompt_id: string; + + constructor(prompt_id: string) { + this['event.name'] = 'user_retry'; + this['event.timestamp'] = new Date().toISOString(); + this.prompt_id = prompt_id; + } +} + export class ToolCallEvent implements BaseTelemetryEvent { 'event.name': 'tool_call'; 'event.timestamp': string; diff --git a/packages/core/src/tools/askUserQuestion.test.ts b/packages/core/src/tools/askUserQuestion.test.ts new file mode 100644 index 000000000..f9aabc2d9 --- /dev/null +++ b/packages/core/src/tools/askUserQuestion.test.ts @@ -0,0 +1,260 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { AskUserQuestionTool } from './askUserQuestion.js'; +import type { Config } from '../config/config.js'; +import { ApprovalMode } from '../config/config.js'; +import { ToolConfirmationOutcome } from './tools.js'; + +describe('AskUserQuestionTool', () => { + let mockConfig: Config; + let tool: AskUserQuestionTool; + + beforeEach(() => { + mockConfig = { + isInteractive: vi.fn().mockReturnValue(true), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getTargetDir: vi.fn().mockReturnValue('/mock/dir'), + getChatRecordingService: vi.fn(), + getExperimentalZedIntegration: vi.fn().mockReturnValue(false), + getInputFormat: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + tool = new AskUserQuestionTool(mockConfig); + }); + + describe('validateToolParams', () => { + it('should accept valid params with single question', () => { + const params = { + questions: [ + { + question: 'What is your favorite color?', + header: 'Color', + options: [ + { label: 'Red', description: 'The color red' }, + { label: 'Blue', description: 'The color blue' }, + ], + multiSelect: false, + }, + ], + }; + + const result = tool.validateToolParams(params); + expect(result).toBeNull(); + }); + + it('should reject params with too many questions', () => { + const params = { + questions: Array(5).fill({ + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }), + }; + + const result = tool.validateToolParams(params); + expect(result).toContain('between 1 and 4 questions'); + }); + + it('should reject question with header too long', () => { + const params = { + questions: [ + { + question: 'Test question?', + header: 'ThisHeaderIsTooLong', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const result = tool.validateToolParams(params); + expect(result).toContain('12 characters or less'); + }); + + it('should reject question with too few options', () => { + const params = { + questions: [ + { + question: 'Test question?', + header: 'Test', + options: [{ label: 'A', description: 'Only one option' }], + multiSelect: false, + }, + ], + }; + + const result = tool.validateToolParams(params); + expect(result).toContain('between 2 and 4 options'); + }); + }); + + describe('shouldConfirmExecute', () => { + it('should return confirmation details in interactive mode', async () => { + const params = { + questions: [ + { + question: 'Pick a framework?', + header: 'Framework', + options: [ + { label: 'React', description: 'A JavaScript library' }, + { label: 'Vue', description: 'Progressive framework' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(confirmation).not.toBe(false); + if (confirmation && confirmation.type === 'ask_user_question') { + expect(confirmation.type).toBe('ask_user_question'); + expect(confirmation.questions).toEqual(params.questions); + expect(confirmation.onConfirm).toBeDefined(); + } + }); + + it('should return false in non-interactive mode', async () => { + (mockConfig.isInteractive as Mock).mockReturnValue(false); + + const params = { + questions: [ + { + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(confirmation).toBe(false); + }); + }); + + describe('execute', () => { + it('should return error in non-interactive mode', async () => { + (mockConfig.isInteractive as Mock).mockReturnValue(false); + + const params = { + questions: [ + { + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('non-interactive mode'); + expect(result.returnDisplay).toContain('non-interactive mode'); + }); + + it('should return cancellation message when user declines', async () => { + const params = { + questions: [ + { + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + if (confirmation !== false) { + // Simulate user cancellation + await confirmation.onConfirm(ToolConfirmationOutcome.Cancel); + } + + const result = await invocation.execute(new AbortController().signal); + expect(result.llmContent).toContain('declined to answer'); + }); + + it('should return formatted answers when user provides them', async () => { + const params = { + questions: [ + { + question: 'Pick a framework?', + header: 'Framework', + options: [ + { label: 'React', description: 'A JavaScript library' }, + { label: 'Vue', description: 'Progressive framework' }, + ], + multiSelect: false, + }, + { + question: 'Pick a language?', + header: 'Language', + options: [ + { label: 'TypeScript', description: 'Typed JavaScript' }, + { label: 'JavaScript', description: 'Plain JS' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + if (confirmation !== false) { + // Simulate user providing answers + await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { + '0': 'React', + '1': 'TypeScript', + }, + }); + } + + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Framework**: React'); + expect(result.llmContent).toContain('Language**: TypeScript'); + expect(result.returnDisplay).toContain( + 'has provided the following answers:', + ); + }); + }); +}); diff --git a/packages/core/src/tools/askUserQuestion.ts b/packages/core/src/tools/askUserQuestion.ts new file mode 100644 index 000000000..e1c6af26e --- /dev/null +++ b/packages/core/src/tools/askUserQuestion.ts @@ -0,0 +1,352 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ToolAskUserQuestionConfirmationDetails, + ToolConfirmationPayload, + ToolResult, +} from './tools.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + ToolConfirmationOutcome, +} from './tools.js'; +import type { FunctionDeclaration } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { InputFormat } from '../output/types.js'; + +const debugLogger = createDebugLogger('ASK_USER_QUESTION'); + +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +export interface AskUserQuestionParams { + questions: Question[]; + metadata?: { + source?: string; + }; +} + +const askUserQuestionToolDescription = `Use this tool when you need to ask the user questions during execution. This allows you to: +1. Gather user preferences or requirements +2. Clarify ambiguous instructions +3. Get decisions on implementation choices as you work +4. Offer choices to the user about what direction to take. + +Usage notes: +- Users will always be able to select "Other" to provide custom text input +- Use multiSelect: true to allow multiple answers to be selected for a question +- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label + +Plan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is this plan ready?" or "Should I proceed?" - use ExitPlanMode for plan approval. +`; + +const askUserQuestionToolSchemaData: FunctionDeclaration = { + name: 'ask_user_question', + description: askUserQuestionToolDescription, + parametersJsonSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + questions: { + description: 'Questions to ask the user (1-4 questions)', + minItems: 1, + maxItems: 4, + type: 'array', + items: { + type: 'object', + properties: { + question: { + description: + 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"', + type: 'string', + }, + header: { + description: + 'Very short label displayed as a chip/tag (max 12 chars). Examples: "Auth method", "Library", "Approach".', + type: 'string', + }, + options: { + description: + "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.", + minItems: 2, + maxItems: 4, + type: 'array', + items: { + type: 'object', + properties: { + label: { + description: + 'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.', + type: 'string', + }, + description: { + description: + 'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.', + type: 'string', + }, + }, + required: ['label', 'description'], + additionalProperties: false, + }, + }, + multiSelect: { + description: + 'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.', + default: false, + type: 'boolean', + }, + }, + required: ['question', 'header', 'options', 'multiSelect'], + additionalProperties: false, + }, + }, + metadata: { + description: + 'Optional metadata for tracking and analytics purposes. Not displayed to user.', + type: 'object', + properties: { + source: { + description: + 'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.', + type: 'string', + }, + }, + additionalProperties: false, + }, + }, + required: ['questions'], + additionalProperties: false, + }, +}; + +class AskUserQuestionToolInvocation extends BaseToolInvocation< + AskUserQuestionParams, + ToolResult +> { + private userAnswers: Record = {}; + private wasAnswered = false; + + constructor( + private readonly _config: Config, + params: AskUserQuestionParams, + ) { + super(params); + } + + getDescription(): string { + const questionCount = this.params.questions.length; + return `Ask user ${questionCount} question${questionCount > 1 ? 's' : ''}`; + } + + override async shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + // Check if we're in a mode that supports user interaction + // ACP mode (VSCode extension, etc.) uses non-interactive mode but can still collect user input + const isAcpMode = + this._config.getExperimentalZedIntegration() || + this._config.getInputFormat() === InputFormat.STREAM_JSON; + + if (!this._config.isInteractive() && !isAcpMode) { + // In non-interactive mode without ACP support, we cannot collect user input + return false; + } + + const details: ToolAskUserQuestionConfirmationDetails = { + type: 'ask_user_question', + title: 'Please answer the following question(s):', + questions: this.params.questions, + metadata: this.params.metadata, + onConfirm: async ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + switch (outcome) { + case ToolConfirmationOutcome.ProceedOnce: + case ToolConfirmationOutcome.ProceedAlways: + this.wasAnswered = true; + this.userAnswers = payload?.answers ?? {}; + break; + case ToolConfirmationOutcome.Cancel: + this.wasAnswered = false; + break; + default: + this.wasAnswered = true; + this.userAnswers = payload?.answers ?? {}; + break; + } + }, + }; + + return details; + } + + async execute(_signal: AbortSignal): Promise { + try { + // Check if we're in a mode that supports user interaction + // ACP mode (VSCode extension, etc.) uses non-interactive mode but can still collect user input + const isAcpMode = + this._config.getExperimentalZedIntegration() || + this._config.getInputFormat() === InputFormat.STREAM_JSON; + + // In non-interactive mode without ACP support, we cannot collect user input + if (!this._config.isInteractive() && !isAcpMode) { + const errorMessage = + 'Cannot ask user questions in non-interactive mode without ACP support. Please run in interactive mode or enable ACP mode to use this tool.'; + return { + llmContent: errorMessage, + returnDisplay: errorMessage, + }; + } + + if (!this.wasAnswered) { + const cancellationMessage = 'User declined to answer the questions.'; + return { + llmContent: cancellationMessage, + returnDisplay: cancellationMessage, + }; + } + + // Format the answers for LLM consumption + const answersContent = Object.entries(this.userAnswers) + .map(([key, value]) => { + const questionIndex = parseInt(key, 10); + const question = this.params.questions[questionIndex]; + return `**${question?.header || `Question ${questionIndex + 1}`}**: ${value}`; + }) + .join('\n'); + + const llmMessage = `User has provided the following answers:\n\n${answersContent}`; + const displayMessage = `User has provided the following answers:\n\n${answersContent}`; + + return { + llmContent: llmMessage, + returnDisplay: displayMessage, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + debugLogger.error( + `[AskUserQuestionTool] Error executing ask_user_question: ${errorMessage}`, + ); + + const errorLlmContent = `Failed to process user answers: ${errorMessage}`; + + return { + llmContent: errorLlmContent, + returnDisplay: `Error processing answers: ${errorMessage}`, + }; + } + } +} + +export class AskUserQuestionTool extends BaseDeclarativeTool< + AskUserQuestionParams, + ToolResult +> { + static readonly Name: string = ToolNames.ASK_USER_QUESTION; + + constructor(private readonly config: Config) { + super( + AskUserQuestionTool.Name, + ToolDisplayNames.ASK_USER_QUESTION, + askUserQuestionToolDescription, + Kind.Think, + askUserQuestionToolSchemaData.parametersJsonSchema as Record< + string, + unknown + >, + ); + } + + override validateToolParams(params: AskUserQuestionParams): string | null { + // Validate questions array + if (!Array.isArray(params.questions)) { + return 'Parameter "questions" must be an array.'; + } + + if (params.questions.length < 1 || params.questions.length > 4) { + return 'Parameter "questions" must contain between 1 and 4 questions.'; + } + + // Validate individual questions + for (let i = 0; i < params.questions.length; i++) { + const question = params.questions[i]; + + if ( + !question.question || + typeof question.question !== 'string' || + question.question.trim() === '' + ) { + return `Question ${i + 1}: "question" must be a non-empty string.`; + } + + if ( + !question.header || + typeof question.header !== 'string' || + question.header.trim() === '' + ) { + return `Question ${i + 1}: "header" must be a non-empty string.`; + } + + if (question.header.length > 12) { + return `Question ${i + 1}: "header" must be 12 characters or less.`; + } + + if (!Array.isArray(question.options)) { + return `Question ${i + 1}: "options" must be an array.`; + } + + if (question.options.length < 2 || question.options.length > 4) { + return `Question ${i + 1}: "options" must contain between 2 and 4 options.`; + } + + // Validate options + for (let j = 0; j < question.options.length; j++) { + const option = question.options[j]; + + if ( + !option.label || + typeof option.label !== 'string' || + option.label.trim() === '' + ) { + return `Question ${i + 1}, Option ${j + 1}: "label" must be a non-empty string.`; + } + + if ( + !option.description || + typeof option.description !== 'string' || + option.description.trim() === '' + ) { + return `Question ${i + 1}, Option ${j + 1}: "description" must be a non-empty string.`; + } + } + + if (typeof question.multiSelect !== 'boolean') { + return `Question ${i + 1}: "multiSelect" must be a boolean.`; + } + } + + return null; + } + + protected createInvocation(params: AskUserQuestionParams) { + return new AskUserQuestionToolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 8b55e28a9..21ee04244 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -280,6 +280,38 @@ describe('EditTool', () => { ); }); + it('should return false and skip confirmation when approval mode is AUTO_EDIT', async () => { + fs.writeFileSync(filePath, 'some old content here'); + (mockConfig.getApprovalMode as Mock).mockReturnValue( + ApprovalMode.AUTO_EDIT, + ); + const params: EditToolParams = { + file_path: filePath, + old_string: 'old', + new_string: 'new', + }; + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmation).toBe(false); + }); + + it('should return false and skip confirmation when approval mode is YOLO', async () => { + fs.writeFileSync(filePath, 'some old content here'); + (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); + const params: EditToolParams = { + file_path: filePath, + old_string: 'old', + new_string: 'new', + }; + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmation).toBe(false); + }); + it('should return false if old_string is not found', async () => { fs.writeFileSync(filePath, 'some content here'); const params: EditToolParams = { diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 61a318190..ae4c9480b 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -20,14 +20,17 @@ import { makeRelative, shortenPath } from '../utils/paths.js'; import { isNodeError } from '../utils/errors.js'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; -import { FileEncoding } from '../services/fileSystemService.js'; +import { FileEncoding, needsUtf8Bom } from '../services/fileSystemService.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; import { ReadFileTool } from './read-file.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { FileOperation } from '../telemetry/metrics.js'; -import { getSpecificMimeType } from '../utils/fileUtils.js'; +import { + getSpecificMimeType, + fileExists as isFilefileExists, +} from '../utils/fileUtils.js'; import { getLanguageFromFilePath } from '../utils/language-detection.js'; import type { ModifiableDeclarativeTool, @@ -133,33 +136,40 @@ class EditToolInvocation implements ToolInvocation { private async calculateEdit(params: EditToolParams): Promise { const replaceAll = params.replace_all ?? false; let currentContent: string | null = null; - let fileExists = false; + let fileExists = await isFilefileExists(params.file_path); let isNewFile = false; let finalNewString = params.new_string; let finalOldString = params.old_string; let occurrences = 0; - let encoding = 'utf-8'; - let bom = false; let error: | { display: string; raw: string; type: ToolErrorType } | undefined = undefined; - - try { - const fileInfo = await this.config - .getFileSystemService() - .readTextFileWithInfo(params.file_path); - // Normalize line endings to LF for consistent processing. - currentContent = fileInfo.content.replace(/\r\n/g, '\n'); - fileExists = true; - // Encoding and BOM are returned from the same I/O pass, avoiding redundant reads. - encoding = fileInfo.encoding; - bom = fileInfo.bom; - } catch (err: unknown) { - if (!isNodeError(err) || err.code !== 'ENOENT') { - // Rethrow unexpected FS errors (permissions, etc.) - throw err; + let useBOM = false; + let detectedEncoding = 'utf-8'; + if (fileExists) { + try { + const fileInfo = await this.config + .getFileSystemService() + .readTextFile({ path: params.file_path }); + if (fileInfo._meta?.bom !== undefined) { + useBOM = fileInfo._meta.bom; + } else { + useBOM = + fileInfo.content.length > 0 && + fileInfo.content.codePointAt(0) === 0xfeff; + } + detectedEncoding = fileInfo._meta?.encoding || 'utf-8'; + // Normalize line endings to LF for consistent processing. + currentContent = fileInfo.content.replace(/\r\n/g, '\n'); + fileExists = true; + // Encoding and BOM are returned from the same I/O pass, avoiding redundant reads. + } catch (err: unknown) { + if (!isNodeError(err) || err.code !== 'ENOENT') { + // Rethrow unexpected FS errors (permissions, etc.) + throw err; + } + fileExists = false; } - fileExists = false; } const normalizedStrings = normalizeEditStrings( @@ -247,8 +257,8 @@ class EditToolInvocation implements ToolInvocation { occurrences, error, isNewFile, - encoding, - bom, + bom: useBOM, + encoding: detectedEncoding, }; } @@ -259,7 +269,8 @@ class EditToolInvocation implements ToolInvocation { async shouldConfirmExecute( abortSignal: AbortSignal, ): Promise { - if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { + const mode = this.config.getApprovalMode(); + if (mode === ApprovalMode.AUTO_EDIT || mode === ApprovalMode.YOLO) { return false; } @@ -386,20 +397,30 @@ class EditToolInvocation implements ToolInvocation { // For new files, apply default file encoding setting // For existing files, preserve the original encoding (BOM and charset) if (editData.isNewFile) { - const useBOM = - this.config.getDefaultFileEncoding() === FileEncoding.UTF8_BOM; - await this.config - .getFileSystemService() - .writeTextFile(this.params.file_path, editData.newContent, { + const userEncoding = this.config.getDefaultFileEncoding(); + let useBOM = false; + if (userEncoding === FileEncoding.UTF8_BOM) { + useBOM = true; + } else if (userEncoding === undefined) { + // No explicit setting: auto-detect (e.g. .ps1 on non-UTF-8 Windows) + useBOM = needsUtf8Bom(this.params.file_path); + } + await this.config.getFileSystemService().writeTextFile({ + path: this.params.file_path, + content: editData.newContent, + _meta: { bom: useBOM, - }); + }, + }); } else { - await this.config - .getFileSystemService() - .writeTextFile(this.params.file_path, editData.newContent, { + await this.config.getFileSystemService().writeTextFile({ + path: this.params.file_path, + content: editData.newContent, + _meta: { bom: editData.bom, encoding: editData.encoding, - }); + }, + }); } const fileName = path.basename(this.params.file_path); @@ -581,28 +602,38 @@ Expectation for required parameters: return { getFilePath: (params: EditToolParams) => params.file_path, getCurrentContent: async (params: EditToolParams): Promise => { - try { - return this.config - .getFileSystemService() - .readTextFile(params.file_path); - } catch (err) { - if (!isNodeError(err) || err.code !== 'ENOENT') throw err; + const fileExists = await isFilefileExists(params.file_path); + if (fileExists) { + try { + const { content } = await this.config + .getFileSystemService() + .readTextFile({ path: params.file_path }); + return content; + } catch (err) { + if (!isNodeError(err) || err.code !== 'ENOENT') throw err; + return ''; + } + } else { return ''; } }, getProposedContent: async (params: EditToolParams): Promise => { - try { - const currentContent = await this.config - .getFileSystemService() - .readTextFile(params.file_path); - return applyReplacement( - currentContent, - params.old_string, - params.new_string, - params.old_string === '' && currentContent === '', - ); - } catch (err) { - if (!isNodeError(err) || err.code !== 'ENOENT') throw err; + if (fs.existsSync(params.file_path)) { + try { + const { content: currentContent } = await this.config + .getFileSystemService() + .readTextFile({ path: params.file_path }); + return applyReplacement( + currentContent, + params.old_string, + params.new_string, + params.old_string === '' && currentContent === '', + ); + } catch (err) { + if (!isNodeError(err) || err.code !== 'ENOENT') throw err; + return ''; + } + } else { return ''; } }, diff --git a/packages/core/src/tools/exitPlanMode.ts b/packages/core/src/tools/exitPlanMode.ts index d8b3df86f..0f06add54 100644 --- a/packages/core/src/tools/exitPlanMode.ts +++ b/packages/core/src/tools/exitPlanMode.ts @@ -24,11 +24,21 @@ export interface ExitPlanModeParams { } const exitPlanModeToolDescription = `Use this tool when you are in plan mode and have finished presenting your plan and are ready to code. This will prompt the user to exit plan mode. + +## When to Use This Tool IMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool. -Eg. +## Before Using This Tool +Ensure your plan is complete and unambiguous: +- If you have unresolved questions about requirements or approach, use AskUserQuestion first (in earlier phases) +- Once your plan is finalized, use THIS tool to request approval + +**Important:** Do NOT use AskUserQuestion to ask "Is this plan okay?" or "Should I proceed?" - that's exactly what THIS tool does. ExitPlanMode inherently requests user approval of your plan. + +## Examples 1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task. 2. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task. +3. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use AskUserQuestion first, then use exit plan mode tool after clarifying the approach. `; const exitPlanModeToolSchemaData: FunctionDeclaration = { diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index cbb12fbaa..204289e61 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -41,6 +41,7 @@ describe('LSTool', () => { respectGitIgnore: true, respectQwenIgnore: true, }), + getTruncateToolOutputLines: () => 1000, storage: { getUserSkillsDirs: () => [userSkillsBase], }, @@ -100,7 +101,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('[DIR] subdir'); expect(result.llmContent).toContain('file1.txt'); - expect(result.returnDisplay).toBe('Listed 2 item(s).'); + expect(result.returnDisplay).toBe('Listed 2 item(s)'); }); it('should list files from secondary workspace directory', async () => { @@ -115,7 +116,7 @@ describe('LSTool', () => { const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('secondary-file.txt'); - expect(result.returnDisplay).toBe('Listed 1 item(s).'); + expect(result.returnDisplay).toBe('Listed 1 item(s)'); }); it('should handle empty directories', async () => { @@ -140,7 +141,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('file2.log'); - expect(result.returnDisplay).toBe('Listed 1 item(s).'); + expect(result.returnDisplay).toBe('Listed 1 item(s)'); }); it('should respect gitignore patterns', async () => { @@ -154,7 +155,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('file2.log'); // .git is always ignored by default. - expect(result.returnDisplay).toBe('Listed 2 item(s). (2 git-ignored)'); + expect(result.returnDisplay).toBe('Listed 2 item(s) (2 git-ignored)'); }); it('should respect qwenignore patterns', async () => { @@ -166,7 +167,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('file2.log'); - expect(result.returnDisplay).toBe('Listed 2 item(s). (1 qwen-ignored)'); + expect(result.returnDisplay).toBe('Listed 2 item(s) (1 qwen-ignored)'); }); it('should handle non-directory paths', async () => { @@ -204,7 +205,7 @@ describe('LSTool', () => { typeof result.llmContent === 'string' ? result.llmContent : '' ) .split('\n') - .filter(Boolean); + .filter((l) => l.trim() && l.trim() !== '---'); const entries = lines.slice(1); // Skip header expect(entries[0]).toBe('[DIR] x-dir'); @@ -259,12 +260,70 @@ describe('LSTool', () => { // Should still list the other files expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('problematic.txt'); - expect(result.returnDisplay).toBe('Listed 1 item(s).'); + expect(result.returnDisplay).toBe('Listed 1 item(s)'); statSpy.mockRestore(); }); }); + describe('truncation', () => { + it('should truncate when entries exceed config line limit', async () => { + const lowLimitConfig = { + ...mockConfig, + getTruncateToolOutputLines: () => 5, + } as unknown as Config; + const lowLimitTool = new LSTool(lowLimitConfig); + + for (let i = 0; i < 10; i++) { + await fs.writeFile( + path.join(tempRootDir, `file${String(i).padStart(2, '0')}.txt`), + `content${i}`, + ); + } + + const invocation = lowLimitTool.build({ path: tempRootDir }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('[5 items truncated]'); + expect(result.returnDisplay).toBe('Listed 10 item(s) (truncated)'); + }); + + it('should not truncate when entries are within limit', async () => { + for (let i = 0; i < 3; i++) { + await fs.writeFile( + path.join(tempRootDir, `file${i}.txt`), + `content${i}`, + ); + } + + const invocation = lsTool.build({ path: tempRootDir }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).not.toContain('truncated'); + expect(result.returnDisplay).toBe('Listed 3 item(s)'); + }); + + it('should use singular "entry" when exactly one entry is truncated', async () => { + const lowLimitConfig = { + ...mockConfig, + getTruncateToolOutputLines: () => 2, + } as unknown as Config; + const lowLimitTool = new LSTool(lowLimitConfig); + + for (let i = 0; i < 3; i++) { + await fs.writeFile( + path.join(tempRootDir, `file${i}.txt`), + `content${i}`, + ); + } + + const invocation = lowLimitTool.build({ path: tempRootDir }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('[1 item truncated]'); + }); + }); + describe('getDescription', () => { it('should return shortened relative path', () => { const deeplyNestedDir = path.join(tempRootDir, 'deeply', 'nested'); @@ -319,7 +378,7 @@ describe('LSTool', () => { const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('secondary-file.txt'); - expect(result.returnDisplay).toBe('Listed 1 item(s).'); + expect(result.returnDisplay).toBe('Listed 1 item(s)'); }); }); }); diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index eb46da308..a4283417b 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -18,6 +18,8 @@ import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('LS'); +const MAX_ENTRY_COUNT = 100; + /** * Parameters for the LS tool */ @@ -216,12 +218,27 @@ class LSToolInvocation extends BaseToolInvocation { return a.name.localeCompare(b.name); }); - // Create formatted content for LLM - const directoryContent = entries + const totalEntryCount = entries.length; + const entryLimit = Math.min( + MAX_ENTRY_COUNT, + this.config.getTruncateToolOutputLines(), + ); + const truncated = totalEntryCount > entryLimit; + + const entriesToShow = truncated ? entries.slice(0, entryLimit) : entries; + + const directoryContent = entriesToShow .map((entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`) .join('\n'); - let resultMessage = `Directory listing for ${this.params.path}:\n${directoryContent}`; + let resultMessage = `Listed ${totalEntryCount} item(s) in ${this.params.path}:\n---\n${directoryContent}`; + + if (truncated) { + const omittedEntries = totalEntryCount - entryLimit; + const entryTerm = omittedEntries === 1 ? 'item' : 'items'; + resultMessage += `\n---\n[${omittedEntries} ${entryTerm} truncated] ...`; + } + const ignoredMessages = []; if (gitIgnoredCount > 0) { ignoredMessages.push(`${gitIgnoredCount} git-ignored`); @@ -233,10 +250,13 @@ class LSToolInvocation extends BaseToolInvocation { resultMessage += `\n\n(${ignoredMessages.join(', ')})`; } - let displayMessage = `Listed ${entries.length} item(s).`; + let displayMessage = `Listed ${totalEntryCount} item(s)`; if (ignoredMessages.length > 0) { displayMessage += ` (${ignoredMessages.join(', ')})`; } + if (truncated) { + displayMessage += ' (truncated)'; + } return { llmContent: resultMessage, diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index a36af964a..0a8db8af9 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -44,6 +44,9 @@ describe('ReadFileTool', () => { }, getTruncateToolOutputThreshold: () => 2500, getTruncateToolOutputLines: () => 500, + getContentGeneratorConfig: () => ({ + modalities: { image: true, pdf: true, audio: true, video: true }, + }), } as unknown as Config; tool = new ReadFileTool(mockConfigInstance); }); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 0720cadf7..95222af5d 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -21,7 +21,6 @@ vi.mock('../services/shellExecutionService.js', () => ({ vi.mock('fs'); vi.mock('os'); vi.mock('crypto'); -vi.mock('../utils/summarizer.js'); import { isCommandAllowed } from '../utils/shell-utils.js'; import { ShellTool } from './shell.js'; @@ -35,7 +34,6 @@ import * as os from 'node:os'; import { EOL } from 'node:os'; import * as path from 'node:path'; import * as crypto from 'node:crypto'; -import * as summarizer from '../utils/summarizer.js'; import { ToolErrorType } from './tool-error.js'; import { ToolConfirmationOutcome } from './tools.js'; import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; @@ -55,13 +53,15 @@ describe('ShellTool', () => { getExcludeTools: vi.fn().mockReturnValue([]), getDebugMode: vi.fn().mockReturnValue(false), getTargetDir: vi.fn().mockReturnValue('/test/dir'), - getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined), getWorkspaceContext: vi .fn() .mockReturnValue(createMockWorkspaceContext('/test/dir')), storage: { getUserSkillsDirs: vi.fn().mockReturnValue(['/test/dir/.qwen/skills']), + getProjectTempDir: vi.fn().mockReturnValue('/tmp/qwen-temp'), }, + getTruncateToolOutputThreshold: vi.fn().mockReturnValue(0), + getTruncateToolOutputLines: vi.fn().mockReturnValue(0), getGeminiClient: vi.fn(), getGitCoAuthor: vi.fn().mockReturnValue({ enabled: true, @@ -476,42 +476,6 @@ describe('ShellTool', () => { ).toThrow('Directory must be an absolute path.'); }); - it('should summarize output when configured', async () => { - (mockConfig.getSummarizeToolOutputConfig as Mock).mockReturnValue({ - [shellTool.name]: { tokenBudget: 1000 }, - }); - vi.mocked(summarizer.summarizeToolOutput).mockResolvedValue( - 'summarized output', - ); - - const invocation = shellTool.build({ - command: 'ls', - is_background: false, - }); - const promise = invocation.execute(mockAbortSignal); - resolveExecutionPromise({ - output: 'long output', - rawOutput: Buffer.from('long output'), - exitCode: 0, - signal: null, - error: null, - aborted: false, - pid: 12345, - executionMethod: 'child_process', - }); - - const result = await promise; - - expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith( - expect.any(String), - mockConfig.getGeminiClient(), - expect.any(AbortSignal), - 1000, - ); - expect(result.llmContent).toBe('summarized output'); - expect(result.returnDisplay).toBe('long output'); - }); - it('should clean up the temp file on synchronous execution error', async () => { const error = new Error('sync spawn error'); mockShellExecutionService.mockImplementation(() => { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 14f2a6777..ef2b5f432 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -26,7 +26,9 @@ import { Kind, } from './tools.js'; import { getErrorMessage } from '../utils/errors.js'; -import { summarizeToolOutput } from '../utils/summarizer.js'; +import { truncateAndSaveToFile } from '../utils/truncation.js'; +import { logToolOutputTruncated } from '../telemetry/loggers.js'; +import { ToolOutputTruncatedEvent } from '../telemetry/types.js'; import type { ShellExecutionConfig, ShellOutputEvent, @@ -378,7 +380,43 @@ export class ShellToolInvocation extends BaseToolInvocation< } } - const summarizeConfig = this.config.getSummarizeToolOutputConfig(); + // Truncate large output and save full content to a temp file. + const truncateThreshold = this.config.getTruncateToolOutputThreshold(); + const truncateLines = this.config.getTruncateToolOutputLines(); + if ( + typeof llmContent === 'string' && + truncateThreshold > 0 && + truncateLines > 0 + ) { + const originalContentLength = llmContent.length; + const fileName = `shell_${crypto.randomBytes(6).toString('hex')}`; + const truncatedResult = await truncateAndSaveToFile( + llmContent, + fileName, + this.config.storage.getProjectTempDir(), + truncateThreshold, + truncateLines, + ); + + if (truncatedResult.outputFile) { + llmContent = truncatedResult.content; + returnDisplayMessage += + (returnDisplayMessage ? '\n' : '') + + `Output too long and was saved to: ${truncatedResult.outputFile}`; + + logToolOutputTruncated( + this.config, + new ToolOutputTruncatedEvent('', { + toolName: ShellTool.Name, + originalContentLength, + truncatedContentLength: truncatedResult.content.length, + threshold: truncateThreshold, + lines: truncateLines, + }), + ); + } + } + const executionError = result.error ? { error: { @@ -388,20 +426,6 @@ export class ShellToolInvocation extends BaseToolInvocation< } : {}; - if (summarizeConfig && summarizeConfig[ShellTool.Name]) { - const summary = await summarizeToolOutput( - llmContent, - this.config.getGeminiClient(), - signal, - summarizeConfig[ShellTool.Name].tokenBudget, - ); - return { - llmContent: summary, - returnDisplay: returnDisplayMessage, - ...executionError, - }; - } - return { llmContent, returnDisplay: returnDisplayMessage, diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 3399f7d41..c118bffbd 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -25,6 +25,7 @@ export const ToolNames = { WEB_SEARCH: 'web_search', LS: 'list_directory', LSP: 'lsp', + ASK_USER_QUESTION: 'ask_user_question', } as const; /** @@ -48,6 +49,7 @@ export const ToolDisplayNames = { WEB_SEARCH: 'WebSearch', LS: 'ListFiles', LSP: 'Lsp', + ASK_USER_QUESTION: 'AskUserQuestion', } as const; // Migration from old tool names to new tool names diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index dc14bef86..5fccddb4b 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -229,6 +229,22 @@ export class ToolRegistry { } } + /** + * Disconnects an MCP server by removing its tools, prompts, and disconnecting the client. + * Unlike disableMcpServer, this does NOT add the server to the exclusion list. + * @param serverName The name of the server to disconnect. + */ + async disconnectServer(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); + } + /** * Disables an MCP server by removing its tools, prompts, and disconnecting the client. * Also updates the config's exclusion list. diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 96ae53402..05b488d12 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -525,6 +525,7 @@ export interface PlanResultDisplay { type: 'plan_summary'; message: string; plan: string; + rejected?: boolean; } export interface ToolEditConfirmationDetails { @@ -549,6 +550,8 @@ export interface ToolConfirmationPayload { newContent?: string; // used to provide custom cancellation message when outcome is Cancel cancelMessage?: string; + // used to pass user answers from ask_user_question tool + answers?: Record; } export interface ToolExecuteConfirmationDetails { @@ -587,7 +590,8 @@ export type ToolCallConfirmationDetails = | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails | ToolInfoConfirmationDetails - | ToolPlanConfirmationDetails; + | ToolPlanConfirmationDetails + | ToolAskUserQuestionConfirmationDetails; export interface ToolPlanConfirmationDetails { type: 'plan'; @@ -596,6 +600,27 @@ export interface ToolPlanConfirmationDetails { onConfirm: (outcome: ToolConfirmationOutcome) => Promise; } +export interface ToolAskUserQuestionConfirmationDetails { + type: 'ask_user_question'; + title: string; + questions: Array<{ + question: string; + header: string; + options: Array<{ + label: string; + description: string; + }>; + multiSelect: boolean; + }>; + metadata?: { + source?: string; + }; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + /** * TODO: * 1. support explicit denied outcome diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index e096b0a72..057eb33dd 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -14,7 +14,7 @@ import { type Mocked, } from 'vitest'; import type { WriteFileToolParams } from './write-file.js'; -import { getCorrectedFileContent, WriteFileTool } from './write-file.js'; +import { WriteFileTool } from './write-file.js'; import { ToolErrorType } from './tool-error.js'; import type { FileDiff, ToolEditConfirmationDetails } from './tools.js'; import { ToolConfirmationOutcome } from './tools.js'; @@ -193,70 +193,6 @@ describe('WriteFileTool', () => { }); }); - describe('getCorrectedFileContent', () => { - it('should return proposed content unchanged for a new file', async () => { - const filePath = path.join(rootDir, 'new_corrected_file.txt'); - const proposedContent = 'Proposed new content.'; - - const result = await getCorrectedFileContent( - mockConfig, - filePath, - proposedContent, - ); - - expect(result.correctedContent).toBe(proposedContent); - expect(result.originalContent).toBe(''); - expect(result.fileExists).toBe(false); - expect(result.error).toBeUndefined(); - }); - - it('should return proposed content unchanged for an existing file', async () => { - const filePath = path.join(rootDir, 'existing_corrected_file.txt'); - const originalContent = 'Original existing content.'; - const proposedContent = 'Proposed replacement content.'; - fs.writeFileSync(filePath, originalContent, 'utf8'); - - const result = await getCorrectedFileContent( - mockConfig, - filePath, - proposedContent, - ); - - expect(result.correctedContent).toBe(proposedContent); - expect(result.originalContent).toBe(originalContent); - expect(result.fileExists).toBe(true); - expect(result.error).toBeUndefined(); - }); - - it('should return error if reading an existing file fails (e.g. permissions)', async () => { - const filePath = path.join(rootDir, 'unreadable_file.txt'); - const proposedContent = 'some content'; - fs.writeFileSync(filePath, 'content', { mode: 0o000 }); - - const readError = new Error('Permission denied'); - vi.spyOn(fsService, 'readTextFile').mockImplementationOnce(() => - Promise.reject(readError), - ); - - const result = await getCorrectedFileContent( - mockConfig, - filePath, - proposedContent, - ); - - expect(fsService.readTextFile).toHaveBeenCalledWith(filePath); - expect(result.correctedContent).toBe(proposedContent); - expect(result.originalContent).toBe(''); - expect(result.fileExists).toBe(true); - expect(result.error).toEqual({ - message: 'Permission denied', - code: undefined, - }); - - fs.chmodSync(filePath, 0o600); - }); - }); - describe('shouldConfirmExecute', () => { const abortSignal = new AbortController().signal; @@ -277,6 +213,26 @@ describe('WriteFileTool', () => { fs.chmodSync(filePath, 0o600); }); + it('should return false and skip confirmation when approval mode is AUTO_EDIT', async () => { + mockConfigInternal.getApprovalMode.mockReturnValue( + ApprovalMode.AUTO_EDIT, + ); + const filePath = path.join(rootDir, 'auto_edit_skip_confirm.txt'); + const params = { file_path: filePath, content: 'content' }; + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute(abortSignal); + expect(confirmation).toBe(false); + }); + + it('should return false and skip confirmation when approval mode is YOLO', async () => { + mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.YOLO); + const filePath = path.join(rootDir, 'yolo_skip_confirm.txt'); + const params = { file_path: filePath, content: 'content' }; + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute(abortSignal); + expect(confirmation).toBe(false); + }); + it('should request confirmation with diff for a new file', async () => { const filePath = path.join(rootDir, 'confirm_new_file.txt'); const proposedContent = 'Proposed new content for confirmation.'; @@ -484,7 +440,9 @@ describe('WriteFileTool', () => { /Successfully created and wrote to new file/, ); expect(fs.existsSync(filePath)).toBe(true); - const writtenContent = await fsService.readTextFile(filePath); + const { content: writtenContent } = await fsService.readTextFile({ + path: filePath, + }); expect(writtenContent).toBe(proposedContent); const display = result.returnDisplay as FileDiff; expect(display.fileName).toBe('execute_new_file.txt'); @@ -516,7 +474,9 @@ describe('WriteFileTool', () => { const result = await invocation.execute(abortSignal); expect(result.llmContent).toMatch(/Successfully overwrote file/); - const writtenContent = await fsService.readTextFile(filePath); + const { content: writtenContent } = await fsService.readTextFile({ + path: filePath, + }); expect(writtenContent).toBe(proposedContent); const display = result.returnDisplay as FileDiff; expect(display.fileName).toBe('execute_existing_file.txt'); @@ -528,6 +488,36 @@ describe('WriteFileTool', () => { ); }); + it('should treat metadata ENOENT as new file when readTextFile returned empty content', async () => { + const filePath = path.join(rootDir, 'execute_acp_like_missing_file.txt'); + const proposedContent = 'content from acp-like flow'; + const writeSpy = vi.spyOn(fsService, 'writeTextFile'); + + // Simulate ENOENT: file does not exist, readTextFile throws ENOENT. + const enoentError = new Error('File not found') as NodeJS.ErrnoException; + enoentError.code = 'ENOENT'; + vi.spyOn(fsService, 'readTextFile').mockRejectedValueOnce(enoentError); + + const params = { file_path: filePath, content: proposedContent }; + const invocation = tool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.error).toBeUndefined(); + expect(result.llmContent).toMatch( + /Successfully created and wrote to new file/, + ); + expect(writeSpy).toHaveBeenCalledWith({ + path: filePath, + content: proposedContent, + _meta: { + bom: false, + encoding: undefined, + }, + }); + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.readFileSync(filePath, 'utf8')).toBe(proposedContent); + }); + it('should create directory if it does not exist', async () => { const dirPath = path.join(rootDir, 'new_dir_for_write'); const filePath = path.join(dirPath, 'file_in_new_dir.txt'); @@ -757,9 +747,10 @@ describe('WriteFileTool', () => { await invocation.execute(abortSignal); // Verify writeTextFile was called with bom: true - expect(writeSpy).toHaveBeenCalledWith(filePath, newContent, { - bom: true, - encoding: 'utf-8', + expect(writeSpy).toHaveBeenCalledWith({ + path: filePath, + content: newContent, + _meta: { bom: true, encoding: 'utf-8' }, }); // Cleanup @@ -784,9 +775,10 @@ describe('WriteFileTool', () => { await invocation.execute(abortSignal); // Verify writeTextFile was called with bom: false - expect(writeSpy).toHaveBeenCalledWith(filePath, newContent, { - bom: false, - encoding: 'utf-8', + expect(writeSpy).toHaveBeenCalledWith({ + path: filePath, + content: newContent, + _meta: { bom: false, encoding: 'utf-8' }, }); // Cleanup @@ -812,8 +804,10 @@ describe('WriteFileTool', () => { await invocation.execute(abortSignal); // Verify writeTextFile was called with bom: false (default is utf-8) - expect(writeSpy).toHaveBeenCalledWith(filePath, newContent, { - bom: false, + expect(writeSpy).toHaveBeenCalledWith({ + path: filePath, + content: newContent, + _meta: { bom: false, encoding: undefined }, }); // Cleanup @@ -844,8 +838,10 @@ describe('WriteFileTool', () => { await invocation.execute(abortSignal); // Verify writeTextFile was called with bom: true - expect(writeSpy).toHaveBeenCalledWith(filePath, newContent, { - bom: true, + expect(writeSpy).toHaveBeenCalledWith({ + path: filePath, + content: newContent, + _meta: { bom: true, encoding: undefined }, }); // Restore mock diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 4085e3b69..2fb53a73f 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -24,7 +24,7 @@ import { ToolConfirmationOutcome, } from './tools.js'; import { ToolErrorType } from './tool-error.js'; -import { FileEncoding } from '../services/fileSystemService.js'; +import { FileEncoding, needsUtf8Bom } from '../services/fileSystemService.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; @@ -37,7 +37,10 @@ import { IdeClient } from '../ide/ide-client.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { FileOperation } from '../telemetry/metrics.js'; -import { getSpecificMimeType } from '../utils/fileUtils.js'; +import { + getSpecificMimeType, + fileExists as isFilefileExists, +} from '../utils/fileUtils.js'; import { getLanguageFromFilePath } from '../utils/language-detection.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -68,47 +71,6 @@ export interface WriteFileToolParams { ai_proposed_content?: string; } -interface GetCorrectedFileContentResult { - originalContent: string; - correctedContent: string; - fileExists: boolean; - error?: { message: string; code?: string }; -} - -export async function getCorrectedFileContent( - config: Config, - filePath: string, - proposedContent: string, -): Promise { - let originalContent = ''; - let fileExists = false; - const correctedContent = proposedContent; - - try { - originalContent = await config - .getFileSystemService() - .readTextFile(filePath); - fileExists = true; // File exists and was read - } catch (err) { - if (isNodeError(err) && err.code === 'ENOENT') { - fileExists = false; - originalContent = ''; - } else { - // File exists but could not be read (permissions, etc.) - fileExists = true; // Mark as existing but problematic - originalContent = ''; // Can't use its content - const error = { - message: getErrorMessage(err), - code: isNodeError(err) ? err.code : undefined, - }; - // Return early as we can't proceed with content correction meaningfully - return { originalContent, correctedContent, fileExists, error }; - } - } - - return { originalContent, correctedContent, fileExists }; -} - class WriteFileToolInvocation extends BaseToolInvocation< WriteFileToolParams, ToolResult @@ -135,22 +97,26 @@ class WriteFileToolInvocation extends BaseToolInvocation< override async shouldConfirmExecute( _abortSignal: AbortSignal, ): Promise { - if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { + const mode = this.config.getApprovalMode(); + if (mode === ApprovalMode.AUTO_EDIT || mode === ApprovalMode.YOLO) { return false; } - const correctedContentResult = await getCorrectedFileContent( - this.config, - this.params.file_path, - this.params.content, - ); - - if (correctedContentResult.error) { - // If file exists but couldn't be read, we can't show a diff for confirmation. - return false; + let originalContent = ''; + const fileExists = await isFilefileExists(this.params.file_path); + if (fileExists) { + try { + const { content } = await this.config + .getFileSystemService() + .readTextFile({ path: this.params.file_path }); + originalContent = content; + } catch (err) { + debugLogger.error( + `Error reading existing file for confirmation: ${getErrorMessage(err)}`, + ); + return false; + } } - - const { originalContent, correctedContent } = correctedContentResult; const relativePath = makeRelative( this.params.file_path, this.config.getTargetDir(), @@ -160,7 +126,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< const fileDiff = Diff.createPatch( fileName, originalContent, // Original content (empty if new file or unreadable) - correctedContent, // Content after potential correction + this.params.content, // Content after potential correction 'Current', 'Proposed', DEFAULT_DIFF_OPTIONS, @@ -169,7 +135,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< const ideClient = await IdeClient.getInstance(); const ideConfirmation = this.config.getIdeMode() && ideClient.isDiffingEnabled() - ? ideClient.openDiff(this.params.file_path, correctedContent) + ? ideClient.openDiff(this.params.file_path, this.params.content) : undefined; const confirmationDetails: ToolEditConfirmationDetails = { @@ -179,7 +145,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< filePath: this.params.file_path, fileDiff, originalContent, - newContent: correctedContent, + newContent: this.params.content, onConfirm: async (outcome: ToolConfirmationOutcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); @@ -201,81 +167,86 @@ class WriteFileToolInvocation extends BaseToolInvocation< const { file_path, content, ai_proposed_content, modified_by_user } = this.params; - const correctedContentResult = await getCorrectedFileContent( - this.config, - file_path, - content, - ); - - if (correctedContentResult.error) { - const errDetails = correctedContentResult.error; - const errorMsg = errDetails.code - ? `Error checking existing file '${file_path}': ${errDetails.message} (${errDetails.code})` - : `Error checking existing file: ${errDetails.message}`; - return { - llmContent: errorMsg, - returnDisplay: errorMsg, - error: { - message: errorMsg, - type: ToolErrorType.FILE_WRITE_FAILURE, - }, - }; - } - - const { - originalContent, - correctedContent: fileContent, - fileExists, - } = correctedContentResult; - // fileExists is true if the file existed (and was readable or unreadable but caught by readError). - // fileExists is false if the file did not exist (ENOENT). - const isNewFile = - !fileExists || - (correctedContentResult.error !== undefined && - !correctedContentResult.fileExists); - - try { - const dirName = path.dirname(file_path); - if (!fs.existsSync(dirName)) { - fs.mkdirSync(dirName, { recursive: true }); - } - - // Check if file exists and has BOM to preserve encoding - // For new files, use the configured default encoding - let useBOM = false; - let detectedEncoding: string | undefined; - if (!isNewFile) { - // Use readTextFileWithInfo for a single I/O pass that returns encoding - // and BOM metadata together, avoiding separate detectFileBOM / detectFileEncoding calls. + let fileExists = await isFilefileExists(file_path); + let originalContent = ''; + let useBOM = false; + let detectedEncoding: string | undefined; + const dirName = path.dirname(file_path); + if (fileExists) { + try { const fileInfo = await this.config .getFileSystemService() - .readTextFileWithInfo(file_path); - useBOM = fileInfo.bom; - detectedEncoding = fileInfo.encoding; - } else { - useBOM = this.config.getDefaultFileEncoding() === FileEncoding.UTF8_BOM; + .readTextFile({ path: file_path }); + if (fileInfo._meta?.bom !== undefined) { + useBOM = fileInfo._meta.bom; + } else { + useBOM = + fileInfo.content.length > 0 && + fileInfo.content.codePointAt(0) === 0xfeff; + } + detectedEncoding = fileInfo._meta?.encoding || 'utf-8'; + originalContent = fileInfo.content; + fileExists = true; // File exists and was read + } catch (err) { + if (isNodeError(err) && err.code === 'ENOENT') { + fileExists = false; + } else { + const error = { + message: getErrorMessage(err), + code: isNodeError(err) ? err.code : undefined, + }; + const errorMsg = error.code + ? `Error checking existing file '${file_path}': ${error.message} (${error.code})` + : `Error checking existing file: ${error.message}`; + return { + llmContent: errorMsg, + returnDisplay: errorMsg, + error: { + message: errorMsg, + type: ToolErrorType.FILE_WRITE_FAILURE, + }, + }; + } } + } - await this.config - .getFileSystemService() - .writeTextFile(file_path, fileContent, { + if (!fileExists) { + fs.mkdirSync(dirName, { recursive: true }); + const userEncoding = this.config.getDefaultFileEncoding(); + if (userEncoding === FileEncoding.UTF8_BOM) { + // User explicitly configured UTF-8 BOM for all new files + useBOM = true; + } else if (userEncoding === undefined) { + // No explicit setting: auto-detect based on platform/extension. + // e.g. .ps1 on Windows with a non-UTF-8 code page needs BOM so + // PowerShell 5.1 reads the file as UTF-8 instead of the system ANSI page + useBOM = needsUtf8Bom(file_path); + } + // else: user explicitly set 'utf-8' (no BOM) — respect it + detectedEncoding = undefined; + } + + try { + await this.config.getFileSystemService().writeTextFile({ + path: file_path, + content, + _meta: { bom: useBOM, encoding: detectedEncoding, - }); + }, + }); // Generate diff for display result const fileName = path.basename(file_path); // If there was a readError, originalContent in correctedContentResult is '', // but for the diff, we want to show the original content as it was before the write if possible. // However, if it was unreadable, currentContentForDiff will be empty. - const currentContentForDiff = correctedContentResult.error - ? '' // Or some indicator of unreadable content - : originalContent; + const currentContentForDiff = originalContent; const fileDiff = Diff.createPatch( fileName, currentContentForDiff, - fileContent, + content, 'Original', 'Written', DEFAULT_DIFF_OPTIONS, @@ -290,7 +261,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< ); const llmSuccessMessageParts = [ - isNewFile + !fileExists ? `Successfully created and wrote to new file: ${file_path}.` : `Successfully overwrote file: ${file_path}.`, ]; @@ -304,9 +275,11 @@ class WriteFileToolInvocation extends BaseToolInvocation< const mimetype = getSpecificMimeType(file_path); const programmingLanguage = getLanguageFromFilePath(file_path); const extension = path.extname(file_path); - const operation = isNewFile ? FileOperation.CREATE : FileOperation.UPDATE; + const operation = !fileExists + ? FileOperation.CREATE + : FileOperation.UPDATE; - const lineCount = fileContent.split('\n').length; + const lineCount = content.split('\n').length; logFileOperation( this.config, new FileOperationEvent( @@ -322,8 +295,8 @@ class WriteFileToolInvocation extends BaseToolInvocation< const displayResult: FileDiff = { fileDiff, fileName, - originalContent: correctedContentResult.originalContent, - newContent: correctedContentResult.correctedContent, + originalContent, + newContent: content, diffStat, }; @@ -458,21 +431,22 @@ export class WriteFileTool return { getFilePath: (params: WriteFileToolParams) => params.file_path, getCurrentContent: async (params: WriteFileToolParams) => { - const correctedContentResult = await getCorrectedFileContent( - this.config, - params.file_path, - params.content, - ); - return correctedContentResult.originalContent; - }, - getProposedContent: async (params: WriteFileToolParams) => { - const correctedContentResult = await getCorrectedFileContent( - this.config, - params.file_path, - params.content, - ); - return correctedContentResult.correctedContent; + const fileExists = await isFilefileExists(params.file_path); + if (fileExists) { + try { + const { content } = await this.config + .getFileSystemService() + .readTextFile({ path: params.file_path }); + return content; + } catch (err) { + if (!isNodeError(err) || err.code !== 'ENOENT') throw err; + return ''; + } + } else { + return ''; + } }, + getProposedContent: async (params: WriteFileToolParams) => params.content, createUpdatedParams: ( _oldContent: string, modifiedProposedContent: string, diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index 6dc38e4d7..b2210c3ec 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -33,6 +33,7 @@ import { fileExists, } from './fileUtils.js'; import type { Config } from '../config/config.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; vi.mock('mime/lite', () => ({ default: { getType: vi.fn() }, @@ -52,10 +53,17 @@ describe('fileUtils', () => { let nonexistentFilePath: string; let directoryPath: string; + const fsService = new StandardFileSystemService(); + const mockConfig = { getTruncateToolOutputThreshold: () => 2500, getTruncateToolOutputLines: () => 500, getTargetDir: () => tempRootDir, + getModel: () => 'qwen3.5-plus', + getContentGeneratorConfig: () => ({ + modalities: { image: true, video: true }, + }), + getFileSystemService: () => fsService, } as unknown as Config; beforeEach(() => { @@ -838,7 +846,7 @@ describe('fileUtils', () => { it('should handle read errors for text files', async () => { actualNodeFs.writeFileSync(testTextFilePath, 'content'); // File must exist for initial statSync const readError = new Error('Simulated read error'); - vi.spyOn(fsPromises, 'readFile').mockRejectedValueOnce(readError); + vi.spyOn(fsService, 'readTextFile').mockRejectedValueOnce(readError); const result = await processSingleFileContent( testTextFilePath, @@ -887,29 +895,73 @@ describe('fileUtils', () => { expect(result.returnDisplay).toContain('Read image file: image.png'); }); - it('should process a PDF file', async () => { + it('should reject image files when model does not support image', async () => { + const fakePngData = Buffer.from('fake png data'); + actualNodeFs.writeFileSync(testImageFilePath, fakePngData); + mockMimeGetType.mockReturnValue('image/png'); + + const mockConfigNoImage = { + ...mockConfig, + getContentGeneratorConfig: () => ({ modalities: {} }), + } as unknown as Config; + + const result = await processSingleFileContent( + testImageFilePath, + mockConfigNoImage, + ); + expect(typeof result.llmContent).toBe('string'); + expect(result.llmContent).toContain('Unsupported image file'); + expect(result.llmContent).toContain('does not support image input'); + expect(result.returnDisplay).toContain('Skipped image file'); + }); + + it('should reject PDF files when model does not support PDF', async () => { const fakePdfData = Buffer.from('fake pdf data'); actualNodeFs.writeFileSync(testPdfFilePath, fakePdfData); mockMimeGetType.mockReturnValue('application/pdf'); + + const mockConfigNoPdf = { + ...mockConfig, + getContentGeneratorConfig: () => ({ + modalities: { image: true }, + }), + } as unknown as Config; + const result = await processSingleFileContent( testPdfFilePath, - mockConfig, + mockConfigNoPdf, ); - expect( - (result.llmContent as { inlineData: unknown }).inlineData, - ).toBeDefined(); + expect(typeof result.llmContent).toBe('string'); + expect(result.llmContent).toContain('Unsupported pdf file'); + expect(result.llmContent).toContain( + 'does not support PDF input directly', + ); + expect(result.llmContent).toContain('/extensions install'); + expect(result.returnDisplay).toContain('Skipped pdf file'); + }); + + it('should accept PDF files when model supports PDF', async () => { + const fakePdfData = Buffer.from('fake pdf data'); + actualNodeFs.writeFileSync(testPdfFilePath, fakePdfData); + mockMimeGetType.mockReturnValue('application/pdf'); + + const mockConfigWithPdf = { + ...mockConfig, + getContentGeneratorConfig: () => ({ + modalities: { image: true, pdf: true }, + }), + } as unknown as Config; + + const result = await processSingleFileContent( + testPdfFilePath, + mockConfigWithPdf, + ); + expect(result.llmContent).toHaveProperty('inlineData'); expect( (result.llmContent as { inlineData: { mimeType: string } }).inlineData .mimeType, ).toBe('application/pdf'); - expect( - (result.llmContent as { inlineData: { data: string } }).inlineData.data, - ).toBe(fakePdfData.toString('base64')); - expect( - (result.llmContent as { inlineData: { displayName?: string } }) - .inlineData.displayName, - ).toBe('document.pdf'); - expect(result.returnDisplay).toContain('Read pdf file: document.pdf'); + expect(result.returnDisplay).toContain('Read pdf file'); }); it('should read an SVG file as text when under 1MB', async () => { diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 05de408ef..8eefc0880 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -18,6 +18,7 @@ import { ToolErrorType } from '../tools/tool-error.js'; import { BINARY_EXTENSIONS } from './ignorePatterns.js'; import type { Config } from '../config/config.js'; import { createDebugLogger } from './debugLogger.js'; +import type { InputModalities } from '../core/contentGenerator.js'; import { detectEncodingFromBuffer } from './systemEncoding.js'; const debugLogger = createDebugLogger('FILE_UTILS'); @@ -227,7 +228,7 @@ export async function readFileWithEncodingInfo( return { content: full.toString('utf8'), encoding: 'utf-8', bom: false }; } - // Not valid UTF-8 — try chardet-based encoding detection + // Not valid UTF-8 — try chardet statistical detection const detected = detectEncodingFromBuffer(full); if (detected && !isUtf8CompatibleEncoding(detected)) { try { @@ -260,6 +261,40 @@ export async function readFileWithEncoding(filePath: string): Promise { return result.content; } +export async function countFileLines(filePath: string): Promise { + const result = await readFileWithEncodingInfo(filePath); + return result.content.split('\n').length; +} + +export async function readFileWithLineAndLimit(params: { + path: string; + limit: number; + line?: number; +}): Promise<{ + content: string; + bom?: boolean; + encoding?: string; + originalLineCount: number; +}> { + const { path: filePath, limit, line } = params; + const { content, encoding, bom } = await readFileWithEncodingInfo(filePath); + const lines = content.split('\n'); + const originalLineCount = lines.length; + const startLine = line || 0; + // Ensure endLine does not exceed originalLineCount + const endLine = Math.min(startLine + limit, originalLineCount); + // Ensure selectedLines doesn't try to slice beyond array bounds if startLine is too high + const actualStartLine = Math.min(startLine, originalLineCount); + const selectedLines = lines.slice(actualStartLine, endLine); + + return { + content: selectedLines.join('\n'), + bom, + encoding, + originalLineCount, + }; +} + /** * Detect the encoding of a file by reading a sample from its beginning. * Returns the encoding name (e.g. 'utf-8', 'gbk', 'shift_jis'). @@ -468,11 +503,47 @@ export interface ProcessedFileReadResult { returnDisplay: string; error?: string; // Optional error message for the LLM if file processing failed errorType?: ToolErrorType; // Structured error type + originalLineCount?: number; // For text files, the total number of lines in the original file isTruncated?: boolean; // For text files, indicates if content was truncated - originalLineCount?: number; // For text files linesShown?: [number, number]; // For text files [startLine, endLine] (1-based for display) } +/** + * For media file types, returns the corresponding modality key. + * Returns undefined for non-media types (text, binary, svg) which are always supported. + */ +function mediaModalityKey( + fileType: 'image' | 'pdf' | 'audio' | 'video' | 'text' | 'binary' | 'svg', +): keyof InputModalities | undefined { + if ( + fileType === 'image' || + fileType === 'pdf' || + fileType === 'audio' || + fileType === 'video' + ) { + return fileType; + } + return undefined; +} + +/** + * Build the same unsupported-modality message used by the converter, + * so the LLM sees a consistent hint regardless of where the check fires. + */ +function unsupportedModalityMessage( + modality: string, + displayName: string, +): string { + let hint: string; + if (modality === 'pdf') { + hint = + 'This model does not support PDF input directly. The read_file tool cannot extract PDF content either. To extract text from the PDF file, try using skills if applicable, or guide user to install pdf skill by running this slash command:\n/extensions install https://github.com/anthropics/skills:document-skills'; + } else { + hint = `This model does not support ${modality} input. The read_file tool cannot process this type of file either. To handle this file, try using skills if applicable, or any tools installed at system wide, or let the user know you cannot process this type of file.`; + } + return `[Unsupported ${modality} file: "${displayName}". ${hint}]`; +} + /** * Reads and processes a single file, handling text, images, and PDFs. * @param filePath Absolute path to the file. @@ -527,6 +598,26 @@ export async function processSingleFileContent( .replace(/\\/g, '/'); const displayName = path.basename(filePath); + + // Check modality support for media files using the resolved config + // (same source of truth the converter uses at API-call time). + const modality = mediaModalityKey(fileType); + if (modality) { + const modalities: InputModalities = + config.getContentGeneratorConfig()?.modalities ?? {}; + if (!modalities[modality]) { + const message = unsupportedModalityMessage(modality, displayName); + debugLogger.warn( + `Model '${config.getModel()}' does not support ${modality} input. ` + + `Skipping file: ${relativePathForDisplay}`, + ); + return { + llmContent: message, + returnDisplay: `Skipped ${fileType} file: ${relativePathForDisplay} (model doesn't support ${modality} input)`, + }; + } + } + switch (fileType) { case 'binary': { return { @@ -550,20 +641,18 @@ export async function processSingleFileContent( } case 'text': { // Use BOM-aware reader to avoid leaving a BOM character in content and to support UTF-16/32 transparently - const content = await readFileWithEncoding(filePath); - const lines = content.split('\n').map((line) => line.trimEnd()); - const originalLineCount = lines.length; - + const { content, _meta } = await config + .getFileSystemService() + .readTextFile({ + path: filePath, + limit: limit ?? config.getTruncateToolOutputLines(), + line: offset, + }); + const originalLineCount = + _meta?.originalLineCount ?? (await countFileLines(filePath)); + const selectedLines = content.split('\n').map((line) => line.trimEnd()); const startLine = offset || 0; - const configLineLimit = config.getTruncateToolOutputLines(); const configCharLimit = config.getTruncateToolOutputThreshold(); - const effectiveLimit = limit === undefined ? configLineLimit : limit; - - // Ensure endLine does not exceed originalLineCount - const endLine = Math.min(startLine + effectiveLimit, originalLineCount); - // Ensure selectedLines doesn't try to slice beyond array bounds if startLine is too high - const actualStartLine = Math.min(startLine, originalLineCount); - const selectedLines = lines.slice(actualStartLine, endLine); // Apply character limit truncation let llmContent = ''; @@ -603,11 +692,7 @@ export async function processSingleFileContent( linesIncluded = selectedLines.length; } - // Calculate actual end line shown - const actualEndLine = contentLengthTruncated - ? actualStartLine + linesIncluded - : endLine; - + const actualEndLine = startLine + linesIncluded; const contentRangeTruncated = startLine > 0 || actualEndLine < originalLineCount; const isTruncated = contentRangeTruncated || contentLengthTruncated; @@ -616,7 +701,7 @@ export async function processSingleFileContent( let returnDisplay = ''; if (isTruncated) { returnDisplay = `Read lines ${ - actualStartLine + 1 + startLine + 1 }-${actualEndLine} of ${originalLineCount} from ${relativePathForDisplay}`; if (contentLengthTruncated) { returnDisplay += ' (truncated)'; @@ -628,7 +713,7 @@ export async function processSingleFileContent( returnDisplay, isTruncated, originalLineCount, - linesShown: [actualStartLine + 1, actualEndLine], + linesShown: [startLine + 1, actualEndLine], }; } case 'image': diff --git a/packages/core/src/utils/pathReader.test.ts b/packages/core/src/utils/pathReader.test.ts index 282a7d6d1..97717d0a3 100644 --- a/packages/core/src/utils/pathReader.test.ts +++ b/packages/core/src/utils/pathReader.test.ts @@ -31,6 +31,9 @@ const createMockConfig = ( getFileService: () => mockFileService, getTruncateToolOutputThreshold: () => 2500, getTruncateToolOutputLines: () => 500, + getContentGeneratorConfig: () => ({ + modalities: { image: true, pdf: true, audio: true, video: true }, + }), } as unknown as Config; }; diff --git a/packages/core/src/utils/readManyFiles.test.ts b/packages/core/src/utils/readManyFiles.test.ts index 859753fef..e043eed1c 100644 --- a/packages/core/src/utils/readManyFiles.test.ts +++ b/packages/core/src/utils/readManyFiles.test.ts @@ -12,6 +12,7 @@ import os from 'node:os'; import type { PartListUnion } from '@google/genai'; import { readManyFiles } from './readManyFiles.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; import type { Config } from '../config/config.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; @@ -44,6 +45,7 @@ describe('readManyFiles', () => { getWorkspaceContext: () => createMockWorkspaceContext(rootDir), getTruncateToolOutputLines: () => 1000, getTruncateToolOutputThreshold: () => 2500, + getFileSystemService: () => new StandardFileSystemService(), }) as unknown as Config; async function createTestFile( diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 1f0476866..1c839530f 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -913,3 +913,13 @@ export function isCommandNeedsPermission(command: string): { reason: 'Command requires permission to execute.', }; } + +// ConPTY on Windows builds <= 19041 has known reliability issues (missing +// output, hangs). VS Code uses the same cutoff: microsoft/vscode#123725. +const CONPTY_MIN_WINDOWS_BUILD = 19042; + +export function shouldDefaultToNodePty(): boolean { + if (os.platform() !== 'win32') return true; + const build = parseInt(os.release().split('.')[2] ?? '', 10); + return !isNaN(build) && build >= CONPTY_MIN_WINDOWS_BUILD; +} diff --git a/packages/core/src/utils/summarizer.test.ts b/packages/core/src/utils/summarizer.test.ts deleted file mode 100644 index 6098e77b7..000000000 --- a/packages/core/src/utils/summarizer.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Mock } from 'vitest'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { GeminiClient } from '../core/client.js'; -import { Config } from '../config/config.js'; -import { - summarizeToolOutput, - llmSummarizer, - defaultSummarizer, -} from './summarizer.js'; -import type { ToolResult } from '../tools/tools.js'; - -// Mock GeminiClient and Config constructor -vi.mock('../core/client.js'); -vi.mock('../config/config.js'); - -describe('summarizers', () => { - let mockGeminiClient: GeminiClient; - let MockConfig: Mock; - const abortSignal = new AbortController().signal; - - beforeEach(() => { - MockConfig = vi.mocked(Config); - const mockConfigInstance = new MockConfig( - 'test-api-key', - 'gemini-pro', - false, - '.', - false, - undefined, - false, - undefined, - undefined, - undefined, - ); - - mockGeminiClient = new GeminiClient(mockConfigInstance); - (mockGeminiClient.generateContent as Mock) = vi.fn(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('summarizeToolOutput', () => { - it('should return original text if it is shorter than maxLength', async () => { - const shortText = 'This is a short text.'; - const result = await summarizeToolOutput( - shortText, - mockGeminiClient, - abortSignal, - 2000, - ); - expect(result).toBe(shortText); - expect(mockGeminiClient.generateContent).not.toHaveBeenCalled(); - }); - - it('should return original text if it is empty', async () => { - const emptyText = ''; - const result = await summarizeToolOutput( - emptyText, - mockGeminiClient, - abortSignal, - 2000, - ); - expect(result).toBe(emptyText); - expect(mockGeminiClient.generateContent).not.toHaveBeenCalled(); - }); - - it('should call generateContent if text is longer than maxLength', async () => { - const longText = 'This is a very long text.'.repeat(200); - const summary = 'This is a summary.'; - (mockGeminiClient.generateContent as Mock).mockResolvedValue({ - candidates: [{ content: { parts: [{ text: summary }] } }], - }); - - const result = await summarizeToolOutput( - longText, - mockGeminiClient, - abortSignal, - 2000, - ); - - expect(mockGeminiClient.generateContent).toHaveBeenCalledTimes(1); - expect(result).toBe(summary); - }); - - it('should return original text if generateContent throws an error', async () => { - const longText = 'This is a very long text.'.repeat(200); - const error = new Error('API Error'); - (mockGeminiClient.generateContent as Mock).mockRejectedValue(error); - - const result = await summarizeToolOutput( - longText, - mockGeminiClient, - abortSignal, - 2000, - ); - - expect(mockGeminiClient.generateContent).toHaveBeenCalledTimes(1); - expect(result).toBe(longText); - }); - - it('should construct the correct prompt for summarization', async () => { - const longText = 'This is a very long text.'.repeat(200); - const summary = 'This is a summary.'; - (mockGeminiClient.generateContent as Mock).mockResolvedValue({ - candidates: [{ content: { parts: [{ text: summary }] } }], - }); - - await summarizeToolOutput(longText, mockGeminiClient, abortSignal, 1000); - - const expectedPrompt = `Summarize the following tool output to be a maximum of 1000 tokens. The summary should be concise and capture the main points of the tool output. - -The summarization should be done based on the content that is provided. Here are the basic rules to follow: -1. If the text is a directory listing or any output that is structural, use the history of the conversation to understand the context. Using this context try to understand what information we need from the tool output and return that as a response. -2. If the text is text content and there is nothing structural that we need, summarize the text. -3. If the text is the output of a shell command, use the history of the conversation to understand the context. Using this context try to understand what information we need from the tool output and return a summarization along with the stack trace of any error within the tags. The stack trace should be complete and not truncated. If there are warnings, you should include them in the summary within tags. - - -Text to summarize: -"${longText}" - -Return the summary string which should first contain an overall summarization of text followed by the full stack trace of errors and warnings in the tool output. -`; - const calledWith = (mockGeminiClient.generateContent as Mock).mock - .calls[0]; - const contents = calledWith[0]; - expect(contents[0].parts[0].text).toBe(expectedPrompt); - }); - }); - - describe('llmSummarizer', () => { - it('should summarize tool output using summarizeToolOutput', async () => { - const toolResult: ToolResult = { - llmContent: 'This is a very long text.'.repeat(200), - returnDisplay: '', - }; - const summary = 'This is a summary.'; - (mockGeminiClient.generateContent as Mock).mockResolvedValue({ - candidates: [{ content: { parts: [{ text: summary }] } }], - }); - - const result = await llmSummarizer( - toolResult, - mockGeminiClient, - abortSignal, - ); - - expect(mockGeminiClient.generateContent).toHaveBeenCalledTimes(1); - expect(result).toBe(summary); - }); - - it('should handle different llmContent types', async () => { - const longText = 'This is a very long text.'.repeat(200); - const toolResult: ToolResult = { - llmContent: [{ text: longText }], - returnDisplay: '', - }; - const summary = 'This is a summary.'; - (mockGeminiClient.generateContent as Mock).mockResolvedValue({ - candidates: [{ content: { parts: [{ text: summary }] } }], - }); - - const result = await llmSummarizer( - toolResult, - mockGeminiClient, - abortSignal, - ); - - expect(mockGeminiClient.generateContent).toHaveBeenCalledTimes(1); - const calledWith = (mockGeminiClient.generateContent as Mock).mock - .calls[0]; - const contents = calledWith[0]; - expect(contents[0].parts[0].text).toContain(`"${longText}"`); - expect(result).toBe(summary); - }); - }); - - describe('defaultSummarizer', () => { - it('should stringify the llmContent', async () => { - const toolResult: ToolResult = { - llmContent: { text: 'some data' }, - returnDisplay: '', - }; - - const result = await defaultSummarizer( - toolResult, - mockGeminiClient, - abortSignal, - ); - - expect(result).toBe(JSON.stringify({ text: 'some data' })); - expect(mockGeminiClient.generateContent).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/core/src/utils/summarizer.ts b/packages/core/src/utils/summarizer.ts deleted file mode 100644 index 8c2b391ea..000000000 --- a/packages/core/src/utils/summarizer.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ToolResult } from '../tools/tools.js'; -import type { - Content, - GenerateContentConfig, - GenerateContentResponse, -} from '@google/genai'; -import type { GeminiClient } from '../core/client.js'; -import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; -import { getResponseText, partToString } from './partUtils.js'; -import { createDebugLogger } from './debugLogger.js'; - -const debugLogger = createDebugLogger('SUMMARIZER'); - -/** - * A function that summarizes the result of a tool execution. - * - * @param result The result of the tool execution. - * @returns The summary of the result. - */ -export type Summarizer = ( - result: ToolResult, - geminiClient: GeminiClient, - abortSignal: AbortSignal, -) => Promise; - -/** - * The default summarizer for tool results. - * - * @param result The result of the tool execution. - * @param geminiClient The Gemini client to use for summarization. - * @param abortSignal The abort signal to use for summarization. - * @returns The summary of the result. - */ -export const defaultSummarizer: Summarizer = ( - result: ToolResult, - _geminiClient: GeminiClient, - _abortSignal: AbortSignal, -) => Promise.resolve(JSON.stringify(result.llmContent)); - -const SUMMARIZE_TOOL_OUTPUT_PROMPT = `Summarize the following tool output to be a maximum of {maxOutputTokens} tokens. The summary should be concise and capture the main points of the tool output. - -The summarization should be done based on the content that is provided. Here are the basic rules to follow: -1. If the text is a directory listing or any output that is structural, use the history of the conversation to understand the context. Using this context try to understand what information we need from the tool output and return that as a response. -2. If the text is text content and there is nothing structural that we need, summarize the text. -3. If the text is the output of a shell command, use the history of the conversation to understand the context. Using this context try to understand what information we need from the tool output and return a summarization along with the stack trace of any error within the tags. The stack trace should be complete and not truncated. If there are warnings, you should include them in the summary within tags. - - -Text to summarize: -"{textToSummarize}" - -Return the summary string which should first contain an overall summarization of text followed by the full stack trace of errors and warnings in the tool output. -`; - -export const llmSummarizer: Summarizer = (result, geminiClient, abortSignal) => - summarizeToolOutput( - partToString(result.llmContent), - geminiClient, - abortSignal, - ); - -export async function summarizeToolOutput( - textToSummarize: string, - geminiClient: GeminiClient, - abortSignal: AbortSignal, - maxOutputTokens: number = 2000, -): Promise { - // There is going to be a slight difference here since we are comparing length of string with maxOutputTokens. - // This is meant to be a ballpark estimation of if we need to summarize the tool output. - if (!textToSummarize || textToSummarize.length < maxOutputTokens) { - return textToSummarize; - } - const prompt = SUMMARIZE_TOOL_OUTPUT_PROMPT.replace( - '{maxOutputTokens}', - String(maxOutputTokens), - ).replace('{textToSummarize}', textToSummarize); - - const contents: Content[] = [{ role: 'user', parts: [{ text: prompt }] }]; - const toolOutputSummarizerConfig: GenerateContentConfig = { - maxOutputTokens, - }; - try { - const parsedResponse = (await geminiClient.generateContent( - contents, - toolOutputSummarizerConfig, - abortSignal, - DEFAULT_QWEN_FLASH_MODEL, - )) as unknown as GenerateContentResponse; - return getResponseText(parsedResponse) || textToSummarize; - } catch (error) { - debugLogger.error('Failed to summarize tool output.', error); - return textToSummarize; - } -} diff --git a/packages/core/src/utils/systemEncoding.test.ts b/packages/core/src/utils/systemEncoding.test.ts index 6b6ce693f..9a8bb8887 100644 --- a/packages/core/src/utils/systemEncoding.test.ts +++ b/packages/core/src/utils/systemEncoding.test.ts @@ -54,7 +54,7 @@ describe('Shell Command Processor - Encoding Functions', () => { expect(windowsCodePageToEncoding(65001)).toBe('utf-8'); expect(windowsCodePageToEncoding(1252)).toBe('windows-1252'); expect(windowsCodePageToEncoding(932)).toBe('shift_jis'); - expect(windowsCodePageToEncoding(936)).toBe('gb2312'); + expect(windowsCodePageToEncoding(936)).toBe('gbk'); expect(windowsCodePageToEncoding(949)).toBe('euc-kr'); expect(windowsCodePageToEncoding(950)).toBe('big5'); expect(windowsCodePageToEncoding(1200)).toBe('utf-16le'); @@ -283,6 +283,23 @@ describe('Shell Command Processor - Encoding Functions', () => { mockedOsPlatform.mockReturnValue('linux'); }); + it('should return utf-8 for valid UTF-8 buffers regardless of system encoding', () => { + // System encoding is GBK, but buffer is valid UTF-8 + mockedOsPlatform.mockReturnValue('win32'); + mockedExecSync.mockReturnValue('Active code page: 936'); + + const buffer = Buffer.from('Hello 你好', 'utf-8'); + const result = getCachedEncodingForBuffer(buffer); + expect(result).toBe('utf-8'); + }); + + it('should return utf-8 for pure ASCII buffers', () => { + // ASCII is valid UTF-8 — should return utf-8 immediately + const buffer = Buffer.from('hello world'); + const result = getCachedEncodingForBuffer(buffer); + expect(result).toBe('utf-8'); + }); + it('should use cached system encoding on subsequent calls', () => { process.env['LANG'] = 'en_US.UTF-8'; const buffer = Buffer.from('test'); @@ -305,7 +322,8 @@ describe('Shell Command Processor - Encoding Functions', () => { throw new Error('locale command failed'); }); - const buffer = Buffer.from('test'); + // Use bytes that are NOT valid UTF-8 so the UTF-8-first check fails + const buffer = Buffer.from([0x80, 0x81, 0x82]); mockedChardetDetect.mockReturnValue('ISO-8859-1'); const result = getCachedEncodingForBuffer(buffer); @@ -335,8 +353,9 @@ describe('Shell Command Processor - Encoding Functions', () => { throw new Error('locale command failed'); }); - const buffer1 = Buffer.from('test1'); - const buffer2 = Buffer.from('test2'); + // Use bytes that are NOT valid UTF-8 so the UTF-8-first check fails + const buffer1 = Buffer.from([0x80, 0x81]); + const buffer2 = Buffer.from([0x82, 0x83]); mockedChardetDetect .mockReturnValueOnce('ISO-8859-1') @@ -354,7 +373,9 @@ describe('Shell Command Processor - Encoding Functions', () => { mockedOsPlatform.mockReturnValue('win32'); mockedExecSync.mockReturnValue('Active code page: 1252'); - const buffer = Buffer.from('test'); + // Use bytes that are NOT valid UTF-8 so the UTF-8-first check fails + // and we fall through to system encoding detection + const buffer = Buffer.from([0x80, 0x81, 0x82]); const result = getCachedEncodingForBuffer(buffer); expect(result).toBe('windows-1252'); @@ -365,7 +386,6 @@ describe('Shell Command Processor - Encoding Functions', () => { mockedExecSync.mockReturnValue('Active code page: 936'); // GBK const buffer = Buffer.from('test'); - // Mock chardet to return UTF-8 mockedChardetDetect.mockReturnValue('UTF-8'); const result = getCachedEncodingForBuffer(buffer); @@ -385,8 +405,9 @@ describe('Shell Command Processor - Encoding Functions', () => { throw new Error('locale command failed'); }); - const buffer1 = Buffer.from('test1'); - const buffer2 = Buffer.from('test2'); + // Use bytes that are NOT valid UTF-8 so the UTF-8-first check fails + const buffer1 = Buffer.from([0x80, 0x81]); + const buffer2 = Buffer.from([0x82, 0x83]); mockedChardetDetect .mockReturnValueOnce('ISO-8859-1') @@ -398,18 +419,16 @@ describe('Shell Command Processor - Encoding Functions', () => { const result1 = getCachedEncodingForBuffer(buffer1); const result2 = getCachedEncodingForBuffer(buffer2); - // Should call execSync only once due to caching (null result is cached) - expect(mockedExecSync).toHaveBeenCalledTimes(1); + // System encoding is only checked as fallback after UTF-8 and chardet + // both fail. Since chardet returns results here, execSync may not be called. expect(result1).toBe('iso-8859-1'); expect(result2).toBe('utf-16'); - // Call a third time to verify cache is still used - const buffer3 = Buffer.from('test3'); + // Call a third time to verify chardet is called each time (not cached) + const buffer3 = Buffer.from([0x84, 0x85]); mockedChardetDetect.mockReturnValueOnce('UTF-32'); const result3 = getCachedEncodingForBuffer(buffer3); - // Still should be only one call to execSync - expect(mockedExecSync).toHaveBeenCalledTimes(1); expect(result3).toBe('utf-32'); }); }); diff --git a/packages/core/src/utils/systemEncoding.ts b/packages/core/src/utils/systemEncoding.ts index 4bce69f4c..1af4831f1 100644 --- a/packages/core/src/utils/systemEncoding.ts +++ b/packages/core/src/utils/systemEncoding.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { isUtf8 } from 'node:buffer'; import { execSync } from 'node:child_process'; import os from 'node:os'; import { detect as chardetDetect } from 'chardet'; @@ -23,34 +24,39 @@ export function resetEncodingCache(): void { } /** - * Returns the system encoding, caching the result to avoid repeated system calls. - * If system encoding detection fails, falls back to detecting from the provided buffer. - * Note: Only the system encoding is cached - buffer-based detection runs for each buffer - * since different buffers may have different encodings. - * @param buffer A buffer to use for detecting encoding if system detection fails. + * Detects the encoding of a buffer. + * + * Strategy: try UTF-8 first, then chardet, then system encoding. + * UTF-8 is tried first because modern developer tools, PowerShell Core, + * git, node, and most CLI tools output UTF-8. Legacy codepage bytes + * (0x80-0xFF) rarely form valid multi-byte UTF-8 sequences by accident. + * + * This function should be called on the **complete** output buffer + * (after the command finishes), not on individual streaming chunks, + * to avoid misdetection when early chunks are ASCII-only. + * + * @param buffer A buffer to analyze for encoding detection. */ export function getCachedEncodingForBuffer(buffer: Buffer): string { - // Cache system encoding detection since it's system-wide + if (isUtf8(buffer)) { + return 'utf-8'; + } + + // Buffer is not valid UTF-8 — try chardet, then system encoding + const detected = detectEncodingFromBuffer(buffer); + if (detected) { + return detected; + } + if (cachedSystemEncoding === undefined) { cachedSystemEncoding = getSystemEncoding(); } - - // If we have a cached system encoding, use it if (cachedSystemEncoding) { - // If the system encoding is not UTF-8 (e.g. Windows CP936), but the buffer - // is detected as UTF-8, prefer UTF-8. This handles tools like 'git' which - // often output UTF-8 regardless of the system code page. - if (cachedSystemEncoding !== 'utf-8') { - const detected = detectEncodingFromBuffer(buffer); - if (detected === 'utf-8') { - return 'utf-8'; - } - } return cachedSystemEncoding; } - // Otherwise, detect from this specific buffer (don't cache this result) - return detectEncodingFromBuffer(buffer) || 'utf-8'; + // Last resort + return 'utf-8'; } /** @@ -123,6 +129,7 @@ export function getSystemEncoding(): string | null { * @param cp The Windows code page number (e.g., 437, 850, etc.) * @returns The corresponding encoding name as a string, or null if no mapping exists. */ + export function windowsCodePageToEncoding(cp: number): string | null { // Most common mappings; extend as needed const map: { [key: number]: string } = { @@ -132,7 +139,7 @@ export function windowsCodePageToEncoding(cp: number): string | null { 866: 'cp866', 874: 'windows-874', 932: 'shift_jis', - 936: 'gb2312', + 936: 'gbk', 949: 'euc-kr', 950: 'big5', 1200: 'utf-16le', @@ -158,13 +165,18 @@ export function windowsCodePageToEncoding(cp: number): string | null { } /** - * Attempts to detect encoding from a buffer using chardet. - * This is useful when system encoding detection fails. - * Returns the detected encoding in lowercase, or null if detection fails. + * Attempts to detect the encoding of a non-UTF-8 buffer using chardet + * statistical analysis. Returns null when chardet cannot determine the + * encoding (e.g. the buffer is too small or ambiguous). + * + * Callers that need a guaranteed result should provide their own fallback + * (e.g. {@link getCachedEncodingForBuffer} falls back to the system codepage). + * * @param buffer The buffer to analyze for encoding. * @return The detected encoding as a lowercase string, or null if detection fails. */ export function detectEncodingFromBuffer(buffer: Buffer): string | null { + // Try chardet statistical detection first — works well for larger files try { const detected = chardetDetect(buffer); if (detected && typeof detected === 'string') { diff --git a/packages/core/src/utils/truncation.test.ts b/packages/core/src/utils/truncation.test.ts new file mode 100644 index 000000000..4fb4bb99e --- /dev/null +++ b/packages/core/src/utils/truncation.test.ts @@ -0,0 +1,310 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { truncateAndSaveToFile } from './truncation.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +vi.mock('node:fs/promises'); + +describe('truncateAndSaveToFile', () => { + const mockWriteFile = vi.mocked(fs.writeFile); + const THRESHOLD = 40_000; + const TRUNCATE_LINES = 1000; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return content unchanged if below both threshold and line limit', async () => { + const content = 'Short content'; + const fileName = 'test-file'; + const projectTempDir = '/tmp'; + + const result = await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + expect(result).toEqual({ content }); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('should truncate when line limit exceeded even if under character threshold', async () => { + // 2000 short lines, well under the 40,000 char threshold + const lines = Array(2000).fill('short'); + const content = lines.join('\n'); // ~12,000 chars, under THRESHOLD + const fileName = 'test-file'; + const projectTempDir = '/tmp'; + + expect(content.length).toBeLessThan(THRESHOLD); + + mockWriteFile.mockResolvedValue(undefined); + + const result = await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + expect(result.outputFile).toBe( + path.join(projectTempDir, `${fileName}.output`), + ); + + const head = Math.floor(TRUNCATE_LINES / 5); + const beginning = lines.slice(0, head); + const end = lines.slice(-(TRUNCATE_LINES - head)); + const expectedTruncated = + beginning.join('\n') + + '\n\n---\n... [CONTENT TRUNCATED] ...\n---\n\n' + + end.join('\n'); + + expect(result.content).toContain( + 'Tool output was too large and has been truncated', + ); + expect(result.content).toContain(expectedTruncated); + }); + + it('should reduce effective lines when line content would exceed character threshold', async () => { + // 2000 lines of 100 chars each = 200,000 chars, well over THRESHOLD (40,000) + // Even after truncating to TRUNCATE_LINES (1000), that's 100,000 chars — still over. + // The effective line count should be reduced to fit within the threshold. + const lines = Array(2000).fill('x'.repeat(100)); + const content = lines.join('\n'); + const fileName = 'test-file'; + const projectTempDir = '/tmp'; + + mockWriteFile.mockResolvedValue(undefined); + + const result = await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + expect(result.outputFile).toBeDefined(); + expect(result.content).toContain('... [CONTENT TRUNCATED] ...'); + + // Extract just the truncated part (after the instructions) + const truncatedPart = result.content.split( + 'Truncated part of the output:\n', + )[1]; + // The truncated content (excluding the instructions header) should + // be roughly within the character threshold. + expect(truncatedPart.length).toBeLessThan(THRESHOLD * 1.5); + + // With 100 chars/line and 40,000 threshold, effective lines ≈ 400. + // Verify we have fewer lines than the default TRUNCATE_LINES. + const truncatedLines = truncatedPart.split('\n'); + expect(truncatedLines.length).toBeLessThan(TRUNCATE_LINES); + }); + + it('should truncate content by lines when line limit is the binding constraint', async () => { + // 2000 lines of 5 chars each = ~12,000 chars, well under THRESHOLD (40,000) + // so the line limit (1000) is the binding constraint, not the char threshold. + const lines = Array(2000).fill('hello'); + const content = lines.join('\n'); + const fileName = 'test-file'; + const projectTempDir = '/tmp'; + + expect(content.length).toBeLessThan(THRESHOLD); + + mockWriteFile.mockResolvedValue(undefined); + + const result = await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + expect(result.outputFile).toBe( + path.join(projectTempDir, `${fileName}.output`), + ); + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(projectTempDir, `${fileName}.output`), + content, + ); + + // Effective lines = min(1000, 40000/5) = 1000 (line limit is binding) + const head = Math.floor(TRUNCATE_LINES / 5); + const beginning = lines.slice(0, head); + const end = lines.slice(-(TRUNCATE_LINES - head)); + const expectedTruncated = + beginning.join('\n') + + '\n\n---\n... [CONTENT TRUNCATED] ...\n---\n\n' + + end.join('\n'); + + expect(result.content).toContain( + 'Tool output was too large and has been truncated', + ); + expect(result.content).toContain('Truncated part of the output:'); + expect(result.content).toContain(expectedTruncated); + }); + + it('should truncate content with few but very long lines', async () => { + const content = 'a'.repeat(200_000); // A single very long line + const fileName = 'test-file'; + const projectTempDir = '/tmp'; + + mockWriteFile.mockResolvedValue(undefined); + + const result = await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + expect(result.outputFile).toBe( + path.join(projectTempDir, `${fileName}.output`), + ); + // Full original content is saved to file (no wrapping) + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(projectTempDir, `${fileName}.output`), + content, + ); + + expect(result.content).toContain( + 'Tool output was too large and has been truncated', + ); + expect(result.content).toContain('... [CONTENT TRUNCATED] ...'); + + // The truncated content should stay near the character threshold + const truncatedPart = result.content.split( + 'Truncated part of the output:\n', + )[1]; + expect(truncatedPart.length).toBeLessThan(THRESHOLD * 1.5); + }); + + it('should stay near char threshold even when line lengths vary widely', async () => { + // Mix of short and very long lines — the old average-based approach + // would undercount because long lines in the tail blow past the budget. + const lines: string[] = []; + for (let i = 0; i < 2000; i++) { + lines.push(i % 10 === 0 ? 'x'.repeat(5000) : 'short'); + } + const content = lines.join('\n'); + const fileName = 'test-file'; + const projectTempDir = '/tmp'; + + mockWriteFile.mockResolvedValue(undefined); + + const result = await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + expect(result.content).toContain('... [CONTENT TRUNCATED] ...'); + + const truncatedPart = result.content.split( + 'Truncated part of the output:\n', + )[1]; + // Should stay within ~1.5x the threshold even with variable line lengths + expect(truncatedPart.length).toBeLessThan(THRESHOLD * 1.5); + }); + + it('should handle file write errors gracefully', async () => { + const content = 'a'.repeat(2_000_000); + const fileName = 'test-file'; + const projectTempDir = '/tmp'; + + mockWriteFile.mockRejectedValue(new Error('File write failed')); + + const result = await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + expect(result.outputFile).toBeUndefined(); + expect(result.content).toContain( + '[Note: Could not save full output to file]', + ); + expect(mockWriteFile).toHaveBeenCalled(); + }); + + it('should save to correct file path with file name', async () => { + const content = 'a'.repeat(200_000); + const fileName = 'unique-file-123'; + const projectTempDir = '/custom/temp/dir'; + + mockWriteFile.mockResolvedValue(undefined); + + const result = await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + const expectedPath = path.join(projectTempDir, `${fileName}.output`); + expect(result.outputFile).toBe(expectedPath); + expect(mockWriteFile).toHaveBeenCalledWith(expectedPath, content); + }); + + it('should include helpful instructions in truncated message', async () => { + const content = 'a'.repeat(2_000_000); + const fileName = 'test-file'; + const projectTempDir = '/tmp'; + + mockWriteFile.mockResolvedValue(undefined); + + const result = await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + expect(result.content).toContain( + 'Tool output was too large and has been truncated', + ); + expect(result.content).toContain('The full output has been saved to:'); + expect(result.content).toContain( + 'To read the complete output, use the read_file tool with the absolute file path above', + ); + expect(result.content).toContain( + 'The truncated output below shows the beginning and end of the content', + ); + }); + + it('should sanitize fileName to prevent path traversal', async () => { + const content = 'a'.repeat(200_000); + const fileName = '../../../../../etc/passwd'; + const projectTempDir = '/tmp/safe_dir'; + + mockWriteFile.mockResolvedValue(undefined); + + await truncateAndSaveToFile( + content, + fileName, + projectTempDir, + THRESHOLD, + TRUNCATE_LINES, + ); + + const expectedPath = path.join(projectTempDir, 'passwd.output'); + expect(mockWriteFile).toHaveBeenCalledWith(expectedPath, content); + }); +}); diff --git a/packages/core/src/utils/truncation.ts b/packages/core/src/utils/truncation.ts new file mode 100644 index 000000000..47a21ef60 --- /dev/null +++ b/packages/core/src/utils/truncation.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { ReadFileTool } from '../tools/read-file.js'; + +/** + * Truncates large tool output and saves the full content to a temp file. + * Used by the shell tool to prevent excessively large outputs from being + * sent to the LLM context. + * + * If content length is within the threshold, returns it unchanged. + * Otherwise, saves full content to a file and returns a truncated version + * with head/tail lines and a pointer to the saved file. + */ +export async function truncateAndSaveToFile( + content: string, + fileName: string, + projectTempDir: string, + threshold: number, + truncateLines: number, +): Promise<{ content: string; outputFile?: string }> { + const lines = content.split('\n'); + + // Check both constraints: character threshold and line limit. + if (content.length <= threshold && lines.length <= truncateLines) { + return { content }; + } + + // Build head and tail within both line and character budgets. + const effectiveLines = Math.min(truncateLines, lines.length); + const headCount = Math.max(Math.floor(effectiveLines / 5), 1); + const tailCount = effectiveLines - headCount; + const separator = '\n\n---\n... [CONTENT TRUNCATED] ...\n---\n\n'; + const ellipsis = '...'; + + // Collect head lines within budget. If a single line exceeds the + // remaining budget, include a truncated slice of it. + const headBudget = Math.floor(threshold / 5); + const beginning: string[] = []; + let headChars = 0; + for (let i = 0; i < Math.min(headCount, lines.length); i++) { + const remaining = headBudget - headChars; + if (remaining <= 0) break; + if (lines[i].length + 1 > remaining) { + const sliceLen = Math.max(remaining - ellipsis.length, 0); + beginning.push(lines[i].slice(0, sliceLen) + ellipsis); + headChars = headBudget; + break; + } + beginning.push(lines[i]); + headChars += lines[i].length + 1; // +1 for newline + } + + // Collect tail lines within remaining budget. If a single line exceeds + // the remaining budget, include a truncated slice of it. + const tailBudget = Math.max(threshold - headChars - separator.length, 0); + const end: string[] = []; + let tailChars = 0; + const tailStart = Math.max(lines.length - tailCount, beginning.length); + for (let i = lines.length - 1; i >= tailStart; i--) { + const remaining = tailBudget - tailChars; + if (remaining <= 0) break; + if (lines[i].length + 1 > remaining) { + const sliceLen = Math.max(remaining - ellipsis.length, 0); + end.unshift(ellipsis + lines[i].slice(-sliceLen)); + tailChars = tailBudget; + break; + } + end.unshift(lines[i]); + tailChars += lines[i].length + 1; + } + + const truncatedContent = beginning.join('\n') + separator + end.join('\n'); + + // Sanitize fileName to prevent path traversal. + const safeFileName = `${path.basename(fileName)}.output`; + const outputFile = path.join(projectTempDir, safeFileName); + try { + await fs.writeFile(outputFile, content); + + return { + content: `Tool output was too large and has been truncated. +The full output has been saved to: ${outputFile} +To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. +The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed. + +Truncated part of the output: +${truncatedContent}`, + outputFile, + }; + } catch (_error) { + return { + content: + truncatedContent + `\n[Note: Could not save full output to file]`, + }; + } +} diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 292a7550a..96e5db072 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -60,6 +60,7 @@ Creates a new query session with the Qwen Code. | `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | | `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | | `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | | `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | | `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | | `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | @@ -247,6 +248,36 @@ const result = query({ }); ``` +### Override the System Prompt + +```typescript +import { query } from '@qwen-code/sdk'; + +const result = query({ + prompt: 'Say hello in one sentence.', + options: { + systemPrompt: 'You are a terse assistant. Answer in exactly one sentence.', + }, +}); +``` + +### Append to the Built-in System Prompt + +```typescript +import { query } from '@qwen-code/sdk'; + +const result = query({ + prompt: 'Review the current directory.', + options: { + systemPrompt: { + type: 'preset', + preset: 'qwen_code', + append: 'Be terse and focus on concrete findings.', + }, + }, +}); +``` + ### With SDK-Embedded MCP Servers The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process. diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 4ae465975..805d03cfb 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -55,6 +55,8 @@ export type { PermissionMode, CanUseTool, PermissionResult, + QuerySystemPrompt, + QuerySystemPromptPreset, CLIMcpServerConfig, McpServerConfig, McpOAuthConfig, diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 5ffcd1dda..42d332b15 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -7,7 +7,11 @@ import { serializeJsonLine } from '../utils/jsonLines.js'; import { ProcessTransport } from '../transport/ProcessTransport.js'; import { prepareSpawnInfo, type SpawnInfo } from '../utils/cliPath.js'; import { Query } from './Query.js'; -import type { QueryOptions } from '../types/types.js'; +import type { + QueryOptions, + QuerySystemPrompt, + TransportOptions, +} from '../types/types.js'; import { QueryOptionsSchema } from '../types/queryOptionsSchema.js'; import { SdkLogger } from '../utils/logger.js'; import { randomUUID } from 'node:crypto'; @@ -44,6 +48,7 @@ export function query({ // Generate or use provided session ID for SDK-CLI alignment const sessionId = options.resume ?? options.sessionId ?? randomUUID(); + const resolvedSystemPrompt = resolveSystemPromptOption(options.systemPrompt); const transport = new ProcessTransport({ pathToQwenExecutable, @@ -52,6 +57,7 @@ export function query({ model: options.model, permissionMode: options.permissionMode, env: options.env, + ...resolvedSystemPrompt, abortController, debug: options.debug, stderr: options.stderr, @@ -112,6 +118,20 @@ export function query({ return queryInstance; } +function resolveSystemPromptOption( + systemPrompt: QuerySystemPrompt | undefined, +): Pick { + if (!systemPrompt) { + return {}; + } + + if (typeof systemPrompt === 'string') { + return { systemPrompt }; + } + + return systemPrompt.append ? { appendSystemPrompt: systemPrompt.append } : {}; +} + function validateOptions(options: QueryOptions): SpawnInfo | undefined { const validationResult = QueryOptionsSchema.safeParse(options); if (!validationResult.success) { diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index a763a519c..fa55d0327 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -232,6 +232,14 @@ export class ProcessTransport implements Transport { args.push('--model', this.options.model); } + if (this.options.systemPrompt) { + args.push('--system-prompt', this.options.systemPrompt); + } + + if (this.options.appendSystemPrompt) { + args.push('--append-system-prompt', this.options.appendSystemPrompt); + } + if (this.options.permissionMode) { args.push('--approval-mode', this.options.permissionMode); } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index 6781bb6dc..823bc7085 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -123,12 +123,29 @@ export const TimeoutConfigSchema = z.object({ streamClose: z.number().positive().optional(), }); +const QuerySystemPromptPresetSchema = z + .object({ + type: z.literal('preset'), + preset: z.literal('qwen_code'), + append: z + .string() + .min(1, 'systemPrompt.append must be a non-empty string') + .optional(), + }) + .strict(); + export const QueryOptionsSchema = z .object({ cwd: z.string().optional(), model: z.string().optional(), pathToQwenExecutable: z.string().optional(), env: z.record(z.string(), z.string()).optional(), + systemPrompt: z + .union([ + z.string().min(1, 'systemPrompt must be a non-empty string'), + QuerySystemPromptPresetSchema, + ]) + .optional(), permissionMode: z.enum(['default', 'plan', 'auto-edit', 'yolo']).optional(), canUseTool: z .custom((val) => typeof val === 'function', { diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index e726f4a2c..b532adc8f 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -16,6 +16,8 @@ export type TransportOptions = { model?: string; permissionMode?: PermissionMode; env?: Record; + systemPrompt?: string; + appendSystemPrompt?: string; abortController?: AbortController; debug?: boolean; stderr?: (message: string) => void; @@ -46,6 +48,14 @@ export type TransportOptions = { sessionId?: string; }; +export interface QuerySystemPromptPreset { + type: 'preset'; + preset: 'qwen_code'; + append?: string; +} + +export type QuerySystemPrompt = string | QuerySystemPromptPreset; + type ToolInput = Record; export type CanUseTool = ( @@ -226,6 +236,16 @@ export interface QueryOptions { */ env?: Record; + /** + * System prompt configuration for the Qwen CLI session. + * + * - `string`: fully overrides the main session system prompt + * - `{ type: 'preset', preset: 'qwen_code', append?: string }`: + * uses Qwen Code's built-in prompt as the base and optionally appends extra + * instructions for the main session + */ + systemPrompt?: QuerySystemPrompt; + /** * Permission mode controlling how the SDK handles tool execution approval. * diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 327166528..b5e6c19c0 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -196,6 +196,84 @@ describe('ProcessTransport', () => { ); }); + it('should pass systemPrompt through --system-prompt', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + systemPrompt: 'You are a test system prompt.', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--system-prompt', + 'You are a test system prompt.', + ]), + expect.any(Object), + ); + }); + + it('should pass appendSystemPrompt through --append-system-prompt', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + appendSystemPrompt: 'Be extra concise.', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining(['--append-system-prompt', 'Be extra concise.']), + expect.any(Object), + ); + }); + + it('should pass both systemPrompt and appendSystemPrompt when provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + systemPrompt: 'Override prompt', + appendSystemPrompt: 'Append prompt', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--system-prompt', + 'Override prompt', + '--append-system-prompt', + 'Append prompt', + ]), + expect.any(Object), + ); + }); + it('should include --resume argument when provided', () => { mockPrepareSpawnInfo.mockReturnValue({ command: 'qwen', diff --git a/packages/sdk-typescript/test/unit/createQuery.test.ts b/packages/sdk-typescript/test/unit/createQuery.test.ts new file mode 100644 index 000000000..66b48e938 --- /dev/null +++ b/packages/sdk-typescript/test/unit/createQuery.test.ts @@ -0,0 +1,97 @@ +/** + * Unit tests for query() option mapping + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { QueryOptions } from '../../src/query/createQuery.js'; + +const mockProcessTransport = vi.fn(); +const mockQuery = vi.fn(); +const mockPrepareSpawnInfo = vi.fn(); + +vi.mock('../../src/transport/ProcessTransport.js', () => ({ + ProcessTransport: mockProcessTransport, +})); + +vi.mock('../../src/query/Query.js', () => ({ + Query: mockQuery, +})); + +vi.mock('../../src/utils/cliPath.js', () => ({ + prepareSpawnInfo: mockPrepareSpawnInfo, +})); + +describe('query()', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockPrepareSpawnInfo.mockReturnValue(undefined); + mockProcessTransport.mockImplementation(() => ({ + write: vi.fn(), + readMessages: vi.fn(), + close: vi.fn(), + waitForExit: vi.fn(), + endInput: vi.fn(), + exitError: null, + })); + mockQuery.mockImplementation(() => ({ + initialized: Promise.resolve(), + getSessionId: () => 'test-session-id', + streamInput: vi.fn(), + })); + }); + + it('maps string systemPrompt to TransportOptions.systemPrompt', async () => { + const { query } = await import('../../src/query/createQuery.js'); + + query({ + prompt: 'hello', + options: { + systemPrompt: 'You are a strict reviewer.', + } satisfies QueryOptions, + }); + + expect(mockProcessTransport).toHaveBeenCalledWith( + expect.objectContaining({ + systemPrompt: 'You are a strict reviewer.', + }), + ); + }); + + it('maps preset systemPrompt append to TransportOptions.appendSystemPrompt', async () => { + const { query } = await import('../../src/query/createQuery.js'); + + query({ + prompt: 'hello', + options: { + systemPrompt: { + type: 'preset', + preset: 'qwen_code', + append: 'Be terse.', + }, + } satisfies QueryOptions, + }); + + const transportOptions = mockProcessTransport.mock.calls[0]?.[0]; + + expect(transportOptions.appendSystemPrompt).toBe('Be terse.'); + expect(transportOptions.systemPrompt).toBeUndefined(); + }); + + it('rejects non-qwen preset names at runtime validation', async () => { + const { query } = await import('../../src/query/createQuery.js'); + + expect(() => + query({ + prompt: 'hello', + options: { + systemPrompt: { + type: 'preset', + preset: 'claude_code', + append: 'Be terse.', + } as never, + } satisfies QueryOptions, + }), + ).toThrow(/systemPrompt/); + }); +}); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index e5f087f3c..d4d5c1d85 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.12.0", + "version": "0.13.0", "private": true, "main": "src/index.ts", "license": "Apache-2.0", 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/assets/sidebar-icon.svg b/packages/vscode-ide-companion/assets/sidebar-icon.svg new file mode 100644 index 000000000..51cdab785 --- /dev/null +++ b/packages/vscode-ide-companion/assets/sidebar-icon.svg @@ -0,0 +1,6 @@ + + + diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index f83d3cd86..a7c18ab4b 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.12.0", + "version": "0.13.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { @@ -28,7 +28,13 @@ "ide companion" ], "activationEvents": [ - "onStartupFinished" + "onStartupFinished", + "onView:qwen-code.chatView.sidebar", + "onView:qwen-code.chatView.secondary", + "onCommand:qwen-code.openChat", + "onCommand:qwen-code.focusChat", + "onCommand:qwen-code.newConversation", + "onCommand:qwen-code.showLogs" ], "contributes": { "jsonValidation": [ @@ -37,6 +43,44 @@ "url": "./schemas/settings.schema.json" } ], + "viewsContainers": { + "activitybar": [ + { + "id": "qwen-code-sidebar", + "title": "Qwen Code", + "icon": "assets/sidebar-icon.svg", + "when": "qwen-code:doesNotSupportSecondarySidebar" + } + ], + "secondarySidebar": [ + { + "id": "qwen-code-secondary", + "title": "Qwen Code", + "icon": "assets/sidebar-icon.svg", + "when": "!qwen-code:doesNotSupportSecondarySidebar" + } + ] + }, + "views": { + "qwen-code-sidebar": [ + { + "type": "webview", + "id": "qwen-code.chatView.sidebar", + "name": "Qwen Code", + "icon": "assets/sidebar-icon.svg", + "when": "qwen-code:doesNotSupportSecondarySidebar" + } + ], + "qwen-code-secondary": [ + { + "type": "webview", + "id": "qwen-code.chatView.secondary", + "name": "Qwen Code", + "icon": "assets/sidebar-icon.svg", + "when": "!qwen-code:doesNotSupportSecondarySidebar" + } + ] + }, "languages": [ { "id": "qwen-diff-editable" @@ -69,6 +113,18 @@ { "command": "qwen-code.login", "title": "Qwen Code: Login" + }, + { + "command": "qwen-code.focusChat", + "title": "Qwen Code: Focus Chat View" + }, + { + "command": "qwen-code.newConversation", + "title": "Qwen Code: New Conversation" + }, + { + "command": "qwen-code.showLogs", + "title": "Qwen Code: Show Logs" } ], "menus": { @@ -113,6 +169,11 @@ "command": "qwen.diff.accept", "key": "cmd+s", "when": "qwen.diff.isVisible" + }, + { + "command": "qwen-code.focusChat", + "key": "ctrl+shift+l", + "mac": "cmd+shift+l" } ] }, @@ -158,6 +219,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 index d0eef6ae9..8f56a9c89 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -242,11 +242,6 @@ "type": "number", "default": -1 }, - "summarizeToolOutput": { - "description": "Settings for summarizing tool output.", - "type": "object", - "additionalProperties": true - }, "chatCompression": { "description": "Chat compression settings.", "type": "object", @@ -450,11 +445,6 @@ "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", @@ -600,14 +590,130 @@ "description": "Hooks that execute before agent processing. Can modify prompts or inject context.", "type": "array", "items": { - "type": "string" + "description": "A hook definition with an optional matcher and a list of hook configurations.", + "type": "object", + "properties": { + "matcher": { + "description": "An optional matcher pattern to filter when this hook definition applies.", + "type": "string" + }, + "sequential": { + "description": "Whether the hooks should be executed sequentially instead of in parallel.", + "type": "boolean" + }, + "hooks": { + "description": "The list of hook configurations to execute.", + "type": "array", + "items": { + "description": "A hook configuration entry that defines a command to execute.", + "type": "object", + "properties": { + "type": { + "description": "The type of hook.", + "type": "string", + "enum": [ + "command" + ] + }, + "command": { + "description": "The command to execute when the hook is triggered.", + "type": "string" + }, + "name": { + "description": "An optional name for the hook.", + "type": "string" + }, + "description": { + "description": "An optional description of what the hook does.", + "type": "string" + }, + "timeout": { + "description": "Timeout in milliseconds for the hook execution.", + "type": "number" + }, + "env": { + "description": "Environment variables to set when executing the hook command.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "command" + ] + } + } + }, + "required": [ + "hooks" + ] } }, "Stop": { "description": "Hooks that execute after agent processing. Can post-process responses or log interactions.", "type": "array", "items": { - "type": "string" + "description": "A hook definition with an optional matcher and a list of hook configurations.", + "type": "object", + "properties": { + "matcher": { + "description": "An optional matcher pattern to filter when this hook definition applies.", + "type": "string" + }, + "sequential": { + "description": "Whether the hooks should be executed sequentially instead of in parallel.", + "type": "boolean" + }, + "hooks": { + "description": "The list of hook configurations to execute.", + "type": "array", + "items": { + "description": "A hook configuration entry that defines a command to execute.", + "type": "object", + "properties": { + "type": { + "description": "The type of hook.", + "type": "string", + "enum": [ + "command" + ] + }, + "command": { + "description": "The command to execute when the hook is triggered.", + "type": "string" + }, + "name": { + "description": "An optional name for the hook.", + "type": "string" + }, + "description": { + "description": "An optional description of what the hook does.", + "type": "string" + }, + "timeout": { + "description": "Timeout in milliseconds for the hook execution.", + "type": "number" + }, + "env": { + "description": "Environment variables to set when executing the hook command.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "command" + ] + } + } + }, + "required": [ + "hooks" + ] } } } diff --git a/packages/vscode-ide-companion/src/commands/index.test.ts b/packages/vscode-ide-companion/src/commands/index.test.ts new file mode 100644 index 000000000..e05f14272 --- /dev/null +++ b/packages/vscode-ide-companion/src/commands/index.test.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + focusChatCommand, + openNewChatTabCommand, + registerNewCommands, +} from './index.js'; + +const { + registerCommand, + executeCommand, + showWarningMessage, + showInformationMessage, +} = vi.hoisted(() => ({ + registerCommand: vi.fn( + (_id: string, handler: (...args: unknown[]) => unknown) => ({ + dispose: vi.fn(), + handler, + }), + ), + executeCommand: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), +})); + +vi.mock('vscode', () => ({ + commands: { + registerCommand, + executeCommand, + }, + window: { + showWarningMessage, + showInformationMessage, + }, + workspace: { + workspaceFolders: [], + }, + Uri: { + joinPath: vi.fn(), + }, +})); + +function getRegisteredHandler(commandId: string) { + const call = registerCommand.mock.calls.find(([id]) => id === commandId); + if (!call) { + throw new Error(`Command ${commandId} was not registered`); + } + return call[1] as (...args: unknown[]) => Promise; +} + +describe('registerNewCommands', () => { + const context = { subscriptions: [] as Array<{ dispose: () => void }> }; + const diffManager = { showDiff: vi.fn() }; + const log = vi.fn(); + + beforeEach(() => { + context.subscriptions = []; + registerCommand.mockClear(); + executeCommand.mockClear(); + showWarningMessage.mockClear(); + showInformationMessage.mockClear(); + }); + + it('openNewChatTab opens a new provider without creating a second session explicitly', async () => { + const provider = { + show: vi.fn().mockResolvedValue(undefined), + createNewSession: vi.fn().mockResolvedValue(undefined), + }; + + registerNewCommands( + context as never, + log, + diffManager as never, + () => [], + () => provider as never, + ); + + await getRegisteredHandler(openNewChatTabCommand)(); + + expect(provider.show).toHaveBeenCalledTimes(1); + expect(provider.createNewSession).not.toHaveBeenCalled(); + }); + + it('focusChat focuses the secondary sidebar when it is supported', async () => { + registerNewCommands( + context as never, + log, + diffManager as never, + () => [], + vi.fn() as never, + undefined, + true, + ); + + await getRegisteredHandler(focusChatCommand)(); + + expect(executeCommand).toHaveBeenCalledWith( + 'qwen-code.chatView.secondary.focus', + ); + }); + + it('focusChat falls back to the primary sidebar when secondary sidebar is unavailable', async () => { + registerNewCommands( + context as never, + log, + diffManager as never, + () => [], + vi.fn() as never, + undefined, + false, + ); + + await getRegisteredHandler(focusChatCommand)(); + + expect(executeCommand).toHaveBeenCalledWith( + 'qwen-code.chatView.sidebar.focus', + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 5f487c6fb..b296c43bd 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -6,7 +6,12 @@ import * as vscode from 'vscode'; import type { DiffManager } from '../diff-manager.js'; -import type { WebViewProvider } from '../webview/WebViewProvider.js'; +import type { WebViewProvider } from '../webview/providers/WebViewProvider.js'; +import { getErrorMessage } from '../utils/errorMessage.js'; +import { + CHAT_VIEW_ID_SIDEBAR, + CHAT_VIEW_ID_SECONDARY, +} from '../constants/viewIds.js'; type Logger = (message: string) => void; @@ -15,16 +20,36 @@ export const showDiffCommand = 'qwenCode.showDiff'; export const openChatCommand = 'qwen-code.openChat'; export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; export const loginCommand = 'qwen-code.login'; +export const focusChatCommand = 'qwen-code.focusChat'; +export const newConversationCommand = 'qwen-code.newConversation'; +export const showLogsCommand = 'qwen-code.showLogs'; +/** + * Register all Qwen Code chat-related commands. + * + * `openChat` and `newConversation` always open an editor tab, while + * `focusChat` focuses the secondary sidebar (preferred) or primary sidebar. + * + * @param context - VS Code extension context for subscription management + * @param log - Logger function for debug output + * @param diffManager - Diff manager for showing file diffs + * @param getWebViewProviders - Returns all active editor-tab WebView providers + * @param createWebViewProvider - Factory to create a new editor-tab WebView provider + * @param outputChannel - Optional output channel for the showLogs command + * @param supportsSecondarySidebar - Whether the running VS Code supports secondary sidebar + */ export function registerNewCommands( context: vscode.ExtensionContext, log: Logger, diffManager: DiffManager, getWebViewProviders: () => WebViewProvider[], createWebViewProvider: () => WebViewProvider, + outputChannel?: vscode.OutputChannel, + supportsSecondarySidebar = true, ): void { const disposables: vscode.Disposable[] = []; + // Open Chat: show the most recent editor tab or create a new one disposables.push( vscode.commands.registerCommand(openChatCommand, async () => { const providers = getWebViewProviders(); @@ -55,17 +80,18 @@ export function registerNewCommands( log(`[Command] Showing diff for ${absolutePath}`); await diffManager.showDiff(absolutePath, args.oldText, args.newText); } catch (error) { - log(`[Command] Error showing diff: ${error}`); - vscode.window.showErrorMessage(`Failed to show diff: ${error}`); + const errorMsg = getErrorMessage(error); + log(`[Command] Error showing diff: ${errorMsg}`); + vscode.window.showErrorMessage(`Failed to show diff: ${errorMsg}`); } }, ), ); + // Open New Chat Tab: always create a new editor tab disposables.push( vscode.commands.registerCommand(openNewChatTabCommand, async () => { const provider = createWebViewProvider(); - // Session restoration is now disabled by default, so no need to suppress it await provider.show(); }), ); @@ -82,5 +108,39 @@ export function registerNewCommands( } }), ); + + // Focus Chat: bring the active chat view to front. + // Use secondary sidebar when supported; fall back to primary sidebar. + disposables.push( + vscode.commands.registerCommand(focusChatCommand, async () => { + if (supportsSecondarySidebar) { + await vscode.commands.executeCommand(`${CHAT_VIEW_ID_SECONDARY}.focus`); + } else { + await vscode.commands.executeCommand(`${CHAT_VIEW_ID_SIDEBAR}.focus`); + } + }), + ); + + // New Conversation: open a new editor tab for a fresh conversation + disposables.push( + vscode.commands.registerCommand(newConversationCommand, async () => { + const provider = createWebViewProvider(); + await provider.show(); + }), + ); + + // Show Logs: reveal the output channel + disposables.push( + vscode.commands.registerCommand(showLogsCommand, async () => { + if (outputChannel) { + outputChannel.show(true); + } else { + vscode.window.showWarningMessage( + 'Qwen Code Companion log channel is not available.', + ); + } + }), + ); + context.subscriptions.push(...disposables); } 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/constants/viewIds.ts b/packages/vscode-ide-companion/src/constants/viewIds.ts new file mode 100644 index 000000000..b54c6eaa1 --- /dev/null +++ b/packages/vscode-ide-companion/src/constants/viewIds.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * WebviewView IDs for the chat UI host positions. + * These IDs must match the `views` contributions declared in package.json. + * + * Only one of sidebar / secondary is visible at runtime — controlled by the + * `qwen-code:doesNotSupportSecondarySidebar` context key in package.json. + * The secondary sidebar is preferred; the primary sidebar is a fallback for + * VS Code versions that lack secondary sidebar support. + */ +export const CHAT_VIEW_ID_SIDEBAR = 'qwen-code.chatView.sidebar'; +export const CHAT_VIEW_ID_SECONDARY = 'qwen-code.chatView.secondary'; diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index ef0d5ad46..72c3d476e 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -23,6 +23,7 @@ vi.mock('@qwen-code/qwen-code-core/src/ide/detect-ide.js', async () => { }); vi.mock('vscode', () => ({ + version: '1.94.0', window: { createOutputChannel: vi.fn(() => ({ appendLine: vi.fn(), @@ -43,6 +44,9 @@ vi.mock('vscode', () => ({ registerWebviewPanelSerializer: vi.fn(() => ({ dispose: vi.fn(), })), + registerWebviewViewProvider: vi.fn(() => ({ + dispose: vi.fn(), + })), }, workspace: { workspaceFolders: [], @@ -134,6 +138,22 @@ describe('activate', () => { expect(vscode.workspace.onDidGrantWorkspaceTrust).toHaveBeenCalled(); }); + it('should register webview view providers for sidebar and secondary positions', async () => { + await activate(context); + + // Verify registerWebviewViewProvider was called 2 times (sidebar + secondary) + const registerCalls = vi.mocked(vscode.window.registerWebviewViewProvider) + .mock.calls; + expect(registerCalls).toHaveLength(2); + + // Extract view IDs from the calls + const viewIds = registerCalls.map((call) => call[0]); + + // Only sidebar and secondary are registered; panel view was removed + expect(viewIds).toContain('qwen-code.chatView.sidebar'); + expect(viewIds).toContain('qwen-code.chatView.secondary'); + }); + it('should launch the Qwen Code when the user clicks the button', async () => { const showInformationMessageMock = vi .mocked(vscode.window.showInformationMessage) diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 14ff4bcae..54b494024 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -14,7 +14,9 @@ import { IDE_DEFINITIONS, type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; -import { WebViewProvider } from './webview/WebViewProvider.js'; +import { WebViewProvider } from './webview/providers/WebViewProvider.js'; +import { ChatProviderRegistry } from './webview/providers/ChatProviderRegistry.js'; +import { registerChatViewProviders } from './webview/providers/chatViewRegistration.js'; import { registerNewCommands } from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; import { isWindows } from './utils/platform.js'; @@ -35,7 +37,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet = new Set([ let ideServer: IDEServer; let logger: vscode.OutputChannel; -let webViewProviders: WebViewProvider[] = []; // Track multiple chat tabs +let chatProviderRegistry: ChatProviderRegistry | null = null; let log: (message: string) => void = () => {}; @@ -125,17 +127,25 @@ export async function activate(context: vscode.ExtensionContext) { ); log('Readonly file system provider registered'); + chatProviderRegistry = new ChatProviderRegistry( + () => new WebViewProvider(context, context.extensionUri), + ); + const diffContentProvider = new DiffContentProvider(); const diffManager = new DiffManager( log, diffContentProvider, - // Delay when any chat tab has a pending permission drawer - () => webViewProviders.some((p) => p.hasPendingPermission()), - // Suppress diffs when active mode is auto or yolo in any chat tab + // Delay when any chat surface has a pending permission drawer + () => + chatProviderRegistry + ?.getPermissionAwareProviders() + .some((p) => p.hasPendingPermission()) ?? false, + // Suppress diffs when active mode is auto or yolo in any chat surface () => { - const providers = webViewProviders.filter( - (p) => typeof p.shouldSuppressDiff === 'function', - ); + const providers = + chatProviderRegistry + ?.getPermissionAwareProviders() + .filter((p) => typeof p.shouldSuppressDiff === 'function') ?? []; if (providers.length === 0) { return false; } @@ -144,11 +154,16 @@ export async function activate(context: vscode.ExtensionContext) { ); // Helper function to create a new WebView provider instance - const createWebViewProvider = (): WebViewProvider => { - const provider = new WebViewProvider(context, context.extensionUri); - webViewProviders.push(provider); - return provider; - }; + const createWebViewProvider = (): WebViewProvider => + chatProviderRegistry!.createEditorProvider(); + + const createViewProvider = (): WebViewProvider => + chatProviderRegistry!.createViewProvider(); + + const supportsSecondarySidebar = registerChatViewProviders({ + context, + createViewProvider, + }); // Register WebView panel serializer for persistence across reloads context.subscriptions.push( @@ -192,8 +207,10 @@ export async function activate(context: vscode.ExtensionContext) { context, log, diffManager, - () => webViewProviders, + () => chatProviderRegistry?.getEditorProviders() ?? [], createWebViewProvider, + logger, + supportsSecondarySidebar, ); context.subscriptions.push( @@ -211,9 +228,10 @@ export async function activate(context: vscode.ExtensionContext) { if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.acceptDiff(docUri); } - // If WebView is requesting permission, actively select an allow option (prefer once) + // If any chat surface is requesting permission, actively select allow (prefer once) try { - for (const provider of webViewProviders) { + for (const provider of chatProviderRegistry?.getPermissionAwareProviders() ?? + []) { if (provider?.hasPendingPermission()) { provider.respondToPendingPermission('allow'); } @@ -228,9 +246,10 @@ export async function activate(context: vscode.ExtensionContext) { if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.cancelDiff(docUri); } - // If WebView is requesting permission, actively select reject/cancel + // If any chat surface is requesting permission, actively select reject/cancel try { - for (const provider of webViewProviders) { + for (const provider of chatProviderRegistry?.getPermissionAwareProviders() ?? + []) { if (provider?.hasPendingPermission()) { provider.respondToPendingPermission('cancel'); } @@ -364,11 +383,8 @@ export async function deactivate(): Promise { if (ideServer) { await ideServer.stop(); } - // Dispose all WebView providers - webViewProviders.forEach((provider) => { - provider.dispose(); - }); - webViewProviders = []; + chatProviderRegistry?.disposeAll(); + chatProviderRegistry = null; } catch (err) { const message = err instanceof Error ? err.message : String(err); log(`Failed to stop IDE server during deactivation: ${message}`); diff --git a/packages/vscode-ide-companion/src/package.test.ts b/packages/vscode-ide-companion/src/package.test.ts new file mode 100644 index 000000000..9d7cdaef5 --- /dev/null +++ b/packages/vscode-ide-companion/src/package.test.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('package.json command metadata', () => { + it('describes focusChat as focusing the chat view', () => { + const manifest = JSON.parse( + readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8'), + ) as { + contributes: { + commands: Array<{ command: string; title: string }>; + }; + }; + + const command = manifest.contributes.commands.find( + (item) => item.command === 'qwen-code.focusChat', + ); + + expect(command?.title).toBe('Qwen Code: Focus Chat View'); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts new file mode 100644 index 000000000..32977171a --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { RequestError } from '@agentclientprotocol/sdk'; + +// AcpConnection imports AcpFileHandler which imports vscode. +// Mock vscode so it can be resolved without the actual VS Code runtime. +vi.mock('vscode', () => ({})); + +import { AcpConnection } from './acpConnection.js'; +import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; + +describe('AcpConnection readTextFile error mapping', () => { + it('maps ENOENT to RESOURCE_NOT_FOUND RequestError', () => { + const conn = new AcpConnection() as unknown as { + mapReadTextFileError: (error: unknown, filePath: string) => unknown; + }; + const enoent = Object.assign(new Error('missing file'), { code: 'ENOENT' }); + + expect(() => + conn.mapReadTextFileError(enoent, '/tmp/missing.txt'), + ).toThrowError( + expect.objectContaining({ + code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND, + }), + ); + }); + + it('keeps non-ENOENT RequestError unchanged', () => { + const conn = new AcpConnection() as unknown as { + mapReadTextFileError: (error: unknown, filePath: string) => unknown; + }; + const requestError = new RequestError( + ACP_ERROR_CODES.INTERNAL_ERROR, + 'Internal error', + ); + + expect(conn.mapReadTextFileError(requestError, '/tmp/file.txt')).toBe( + requestError, + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 0a5aec02c..5d263b618 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -4,64 +4,71 @@ * 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, + RequestError, +} from '@agentclientprotocol/sdk'; +import type { + Client, + Agent, + SessionNotification, + RequestPermissionRequest, + RequestPermissionResponse, + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, + AuthenticateResponse, + NewSessionResponse, + LoadSessionResponse, + ListSessionsResponse, + PromptResponse, + SetSessionModeResponse, + SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; import type { - AcpMessage, - AcpPermissionRequest, - AcpResponse, - AcpSessionUpdate, AuthenticateUpdateNotification, + AskUserQuestionRequest, } 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'; +import { ACP_ERROR_CODES } from '../constants/acpSchema.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 = () => {}; + onAskUserQuestion: (data: AskUserQuestionRequest) => Promise<{ + optionId: string; + answers?: Record; + }> = () => Promise.resolve({ optionId: 'cancel' }); 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 +82,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 +89,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 +115,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 +122,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 +137,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 +157,398 @@ 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), - ); - } - } - } - }); + // Check if this is an ask_user_question request by inspecting rawInput + const rawInput = permissionData.toolCall?.rawInput as + | Record + | undefined; + const isAskUserQuestion = Array.isArray(rawInput?.questions); - // Initialize protocol - const res = await this.sessionManager.initialize( - this.child, - this.pendingRequests, - this.nextRequestId, + if (isAskUserQuestion) { + // Handle ask_user_question separately via dedicated callback + const questions = (rawInput?.questions ?? + []) as AskUserQuestionRequest['questions']; + const metadata = + rawInput?.metadata as AskUserQuestionRequest['metadata']; + + const response = await self.onAskUserQuestion({ + sessionId: permissionData.sessionId, + questions, + metadata, + }); + + const optionId = response?.optionId; + const answers = response?.answers; + console.log('[ACP] AskUserQuestion response:', optionId); + + let outcome: 'selected' | 'cancelled'; + if ( + optionId && + (optionId.includes('reject') || optionId === 'cancel') + ) { + outcome = 'cancelled'; + } else { + outcome = 'selected'; + } + + if (outcome === 'cancelled') { + return { outcome: { outcome: 'cancelled' } }; + } + return { + outcome: { + outcome: 'selected', + optionId: optionId || 'proceed_once', + }, + answers, + } as RequestPermissionResponse; + } + + // Handle regular permission request + 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); + + 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 { + try { + const result = await self.fileHandler.handleReadTextFile({ + path: params.path, + sessionId: params.sessionId, + line: params.line ?? null, + limit: params.limit ?? null, + }); + return { content: result.content }; + } catch (error) { + throw self.mapReadTextFileError(error, params.path); + } + }, + + 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, - }; + private ensureConnection(): ClientSideConnection { + if (!this.sdkConnection) { + throw new Error('Not connected to ACP agent'); + } + return this.sdkConnection; + } - // 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); + private mapReadTextFileError(error: unknown, filePath: string): unknown { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as { code?: unknown }).code + : undefined; - 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, + if (errorCode === 'ENOENT') { + throw new RequestError( + ACP_ERROR_CODES.RESOURCE_NOT_FOUND, + `File not found: ${filePath}`, ); } + + return error; } - /** - * 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..e5c68da8e --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Use vi.hoisted so the mocks are accessible inside the vi.mock factory +// (vi.mock calls are hoisted to the top of the file by Vitest). +const { + mockGetText, + mockPositionAt, + mockSave, + mockApplyEdit, + mockOpenTextDocument, + mockCreateDirectory, + mockStatFile, + mockWriteFile, +} = vi.hoisted(() => { + const mockGetText = vi.fn(); + const mockPositionAt = vi.fn((offset: number) => ({ offset })); + const mockSave = vi.fn().mockResolvedValue(true); + const mockApplyEdit = vi.fn().mockResolvedValue(true); + const mockOpenTextDocument = vi.fn().mockResolvedValue({ + getText: mockGetText, + positionAt: mockPositionAt, + isDirty: false, + save: mockSave, + }); + const mockCreateDirectory = vi.fn().mockResolvedValue(undefined); + const mockStatFile = vi.fn(); + const mockWriteFile = vi.fn().mockResolvedValue(undefined); + return { + mockGetText, + mockPositionAt, + mockSave, + mockApplyEdit, + mockOpenTextDocument, + mockCreateDirectory, + mockStatFile, + mockWriteFile, + }; +}); + +vi.mock('vscode', () => ({ + Uri: { + file: (p: string) => ({ fsPath: p, toString: () => p }), + }, + workspace: { + openTextDocument: mockOpenTextDocument, + applyEdit: mockApplyEdit, + fs: { + createDirectory: mockCreateDirectory, + stat: mockStatFile, + writeFile: mockWriteFile, + }, + }, + WorkspaceEdit: class { + replace = vi.fn(); + }, + Range: class { + constructor( + public start: unknown, + public end: unknown, + ) {} + }, +})); + +import { AcpFileHandler } from './acpFileHandler.js'; + +describe('AcpFileHandler', () => { + let handler: AcpFileHandler; + + beforeEach(() => { + handler = new AcpFileHandler(); + vi.clearAllMocks(); + // Restore default implementations after clearAllMocks + mockOpenTextDocument.mockResolvedValue({ + getText: mockGetText, + positionAt: mockPositionAt, + isDirty: false, + save: mockSave, + }); + mockApplyEdit.mockResolvedValue(true); + mockCreateDirectory.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + }); + + describe('handleReadTextFile', () => { + it('returns full content when no line/limit specified', async () => { + mockGetText.mockReturnValue('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 () => { + mockGetText.mockReturnValue('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 () => { + mockGetText.mockReturnValue('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 () => { + mockGetText.mockReturnValue('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 () => { + mockGetText.mockReturnValue('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'; + mockOpenTextDocument.mockRejectedValue(err); + + await expect( + handler.handleReadTextFile({ + path: '/missing/file.txt', + sessionId: 'sid', + line: null, + limit: null, + }), + ).rejects.toThrow('ENOENT'); + }); + + it('normalises VS Code FileNotFound to ENOENT', async () => { + // vscode.FileSystemError.FileNotFound sets code = 'FileNotFound' + const err = new Error('file not found') as NodeJS.ErrnoException; + (err as unknown as Record).code = 'FileNotFound'; + mockOpenTextDocument.mockRejectedValue(err); + + const rejection = handler.handleReadTextFile({ + path: '/missing/file.txt', + sessionId: 'sid', + line: null, + limit: null, + }); + + await expect(rejection).rejects.toThrow('ENOENT'); + await expect(rejection).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); + + describe('handleWriteTextFile', () => { + it('creates directory and uses WorkspaceEdit for existing file', async () => { + // stat resolves → file exists + mockStatFile.mockResolvedValue({}); + mockGetText.mockReturnValue('old content'); + + const result = await handler.handleWriteTextFile({ + path: '/test/dir/file.txt', + content: 'hello', + sessionId: 'sid', + }); + + expect(result).toBeNull(); + expect(mockCreateDirectory).toHaveBeenCalled(); + expect(mockApplyEdit).toHaveBeenCalled(); + }); + + it('writes bytes directly for new (non-existing) file', async () => { + // stat rejects → file does not exist + mockStatFile.mockRejectedValue(new Error('FileNotFound')); + + const result = await handler.handleWriteTextFile({ + path: '/test/dir/newfile.txt', + content: 'hello', + sessionId: 'sid', + }); + + expect(result).toBeNull(); + expect(mockCreateDirectory).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith( + expect.objectContaining({ fsPath: '/test/dir/newfile.txt' }), + expect.any(Uint8Array), + ); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/acpFileHandler.ts b/packages/vscode-ide-companion/src/services/acpFileHandler.ts index 2416ceb37..3bf526823 100644 --- a/packages/vscode-ide-companion/src/services/acpFileHandler.ts +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.ts @@ -10,8 +10,9 @@ * Responsible for handling file read and write operations in the ACP protocol */ -import { promises as fs } from 'fs'; import * as path from 'path'; +import * as vscode from 'vscode'; +import { getErrorMessage } from '../utils/errorMessage.js'; /** * ACP File Operation Handler Class @@ -43,15 +44,21 @@ export class AcpFileHandler { }); try { - const content = await fs.readFile(params.path, 'utf-8'); + const uri = vscode.Uri.file(params.path); + // openTextDocument handles encoding detection (BOM, files.encoding setting, + // chardet) and returns properly decoded Unicode text regardless of the + // source encoding (UTF-8, GBK, Shift-JIS, etc.). + const document = await vscode.workspace.openTextDocument(uri); + const content = document.getText(); console.log( - `[ACP] Successfully read file: ${params.path} (${content.length} bytes)`, + `[ACP] Successfully read file: ${params.path} (${content.length} chars)`, ); - // 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') }; @@ -63,12 +70,25 @@ export class AcpFileHandler { console.log(`[ACP] Returning full file content`); return result; } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); + const errorMsg = getErrorMessage(error); console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg); - const nodeError = error as NodeJS.ErrnoException; - if (nodeError?.code === 'ENOENT') { - throw error; + // Detect "file not found" from both Node.js (code === 'ENOENT') and + // VS Code's FileSystemError.FileNotFound (code === 'FileNotFound'). + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as { code?: unknown }).code + : undefined; + + if (errorCode === 'ENOENT' || errorCode === 'FileNotFound') { + // Normalise to a Node-style ENOENT so downstream ACP layers + // (mapReadTextFileError → AcpFileSystemService) can recognise it. + const enoent = new Error( + `ENOENT: no such file or directory, open '${params.path}'`, + ) as NodeJS.ErrnoException; + enoent.code = 'ENOENT'; + enoent.path = params.path; + throw enoent; } throw new Error(`Failed to read file '${params.path}': ${errorMsg}`); @@ -96,18 +116,56 @@ export class AcpFileHandler { console.log(`[ACP] Content size: ${params.content.length} bytes`); try { - // Ensure directory exists - const dirName = path.dirname(params.path); - console.log(`[ACP] Ensuring directory exists: ${dirName}`); - await fs.mkdir(dirName, { recursive: true }); + const uri = vscode.Uri.file(params.path); - // Write file - await fs.writeFile(params.path, params.content, 'utf-8'); + // Ensure the parent directory exists. + const dirUri = vscode.Uri.file(path.dirname(params.path)); + console.log(`[ACP] Ensuring directory exists: ${dirUri.fsPath}`); + await vscode.workspace.fs.createDirectory(dirUri); + + // Determine whether the file already exists so we can choose the right + // write strategy. + let fileExists = false; + try { + await vscode.workspace.fs.stat(uri); + fileExists = true; + } catch { + fileExists = false; + } + + if (fileExists) { + // Open the document so VS Code tracks its original encoding, replace + // all content via WorkspaceEdit, then save. VS Code writes back using + // the same encoding it detected on open (e.g. GBK), preserving the + // original encoding without any manual codec work. + const document = await vscode.workspace.openTextDocument(uri); + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length), + ); + edit.replace(uri, fullRange, params.content); + const applied = await vscode.workspace.applyEdit(edit); + if (!applied) { + throw new Error('WorkspaceEdit was not applied'); + } + const updatedDoc = await vscode.workspace.openTextDocument(uri); + if (updatedDoc.isDirty) { + const saved = await updatedDoc.save(); + if (!saved) { + throw new Error(`File could not be saved: ${params.path}`); + } + } + } else { + // New file – write UTF-8 bytes directly. + const bytes = Buffer.from(params.content, 'utf-8'); + await vscode.workspace.fs.writeFile(uri, bytes); + } console.log(`[ACP] Successfully wrote file: ${params.path}`); return null; } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); + const errorMsg = getErrorMessage(error); console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg); throw new Error(`Failed to write file '${params.path}': ${errorMsg}`); diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts 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.test.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts new file mode 100644 index 000000000..440dc2b18 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { extractSessionListItems } from './qwenAgentManager.js'; + +vi.mock('vscode', () => ({ + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, +})); + +describe('extractSessionListItems', () => { + it('returns sessions array from the "sessions" field', () => { + const items = extractSessionListItems({ + sessions: [{ sessionId: 'session-1' }], + }); + expect(items).toEqual([{ sessionId: 'session-1' }]); + }); + + it('returns items array from the legacy "items" field', () => { + const items = extractSessionListItems({ + items: [{ sessionId: 'session-2' }], + }); + expect(items).toEqual([{ sessionId: 'session-2' }]); + }); + + it('prefers "sessions" over "items" when both are present', () => { + const items = extractSessionListItems({ + sessions: [{ sessionId: 'from-sessions' }], + items: [{ sessionId: 'from-items' }], + }); + expect(items).toEqual([{ sessionId: 'from-sessions' }]); + }); + + it('returns empty array for null/undefined input', () => { + expect(extractSessionListItems(null)).toEqual([]); + expect(extractSessionListItems(undefined)).toEqual([]); + }); + + it('returns empty array for non-object input', () => { + expect(extractSessionListItems('string')).toEqual([]); + expect(extractSessionListItems(42)).toEqual([]); + }); + + it('returns empty array when neither field is an array', () => { + expect(extractSessionListItems({ sessions: 'not-array' })).toEqual([]); + expect(extractSessionListItems({ items: 123 })).toEqual([]); + expect(extractSessionListItems({})).toEqual([]); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 0944ee5b7..c5a0920d7 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -5,11 +5,14 @@ */ import { AcpConnection } from './acpConnection.js'; import type { - AcpSessionUpdate, - AcpPermissionRequest, - AuthenticateUpdateNotification, ModelInfo, AvailableCommand, + RequestPermissionRequest, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import type { + AuthenticateUpdateNotification, + AskUserQuestionRequest, } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; @@ -29,13 +32,45 @@ 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'; +import { getErrorMessage } from '../utils/errorMessage.js'; import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; +/** + * Extract session list items from ACP response. + * Handles both 'sessions' (new) and 'items' (legacy) response shapes. + * @param response - The ACP session/list response + * @returns Array of session items, or empty array if invalid + */ +export function extractSessionListItems( + response: unknown, +): Array> { + if (!response || typeof response !== 'object') { + return []; + } + + const payload = response as { + sessions?: unknown; + items?: unknown; + }; + + // Prefer 'sessions' field, fall back to 'items' for backwards compatibility + if (Array.isArray(payload.sessions)) { + return payload.sessions as Array>; + } + + if (Array.isArray(payload.items)) { + return payload.items as Array>; + } + + return []; +} + /** * Qwen Agent Manager * @@ -65,6 +100,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 +121,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 +142,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 +161,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 +170,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 +218,28 @@ 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.onAskUserQuestion = async ( + data: AskUserQuestionRequest, + ) => { + if (this.callbacks.onAskUserQuestion) { + const result = await this.callbacks.onAskUserQuestion(data); + return result; + } + return { optionId: 'cancel' }; }; this.connection.onEndTurn = (reason?: string) => { @@ -217,10 +314,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 +328,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 +363,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 +379,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,21 +443,8 @@ 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>) - : []; - } + const items = extractSessionListItems(res); console.log( '[QwenAgentManager] Sessions retrieved via ACP:', @@ -366,7 +458,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,18 +537,8 @@ 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 : []; - } + const items = extractSessionListItems(res); const mapped = items.map((item) => ({ id: item.sessionId || item.id, @@ -464,25 +546,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 +979,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,10 +1009,12 @@ 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 = - error instanceof Error ? error.message : String(error); + const errorMessage = getErrorMessage(error); console.error( '[QwenAgentManager] Session load via ACP failed for session:', sessionId, @@ -1190,35 +1221,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,12 +1310,26 @@ 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); } + /** + * Register ask user question callback + * + * @param callback - Ask user question callback function + */ + onAskUserQuestion( + callback: ( + request: AskUserQuestionRequest, + ) => Promise<{ optionId: string; answers?: Record }>, + ): void { + this.callbacks.onAskUserQuestion = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + /** * Register end-of-turn callback * @@ -1367,7 +1384,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 +1427,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..60c0b3ac5 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -15,15 +15,24 @@ 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 { getErrorMessage } from '../utils/errorMessage.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 +62,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 +114,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 +144,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, + }; } /** @@ -141,6 +168,8 @@ export class QwenConnectionHandler { authMethod: string, autoAuthenticate: boolean, ): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log( @@ -150,8 +179,8 @@ export class QwenConnectionHandler { console.log('[QwenAgentManager] Session created successfully'); return res; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + lastError = error; + const errorMessage = getErrorMessage(error); console.error( `[QwenAgentManager] Session creation attempt ${attempt} failed:`, errorMessage, @@ -195,9 +224,7 @@ export class QwenConnectionHandler { } if (attempt === maxRetries) { - throw new Error( - `Session creation failed after ${maxRetries} attempts: ${errorMessage}`, - ); + throw error; } const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); @@ -206,6 +233,10 @@ export class QwenConnectionHandler { } } + if (lastError !== undefined) { + throw lastError; + } + throw new Error('Session creation failed unexpectedly'); } } 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..9a6495237 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,32 @@ 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; +// Ask User Question types +export interface QuestionOption { + label: 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[]; - }; +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; } -// 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 { +export interface AskUserQuestionRequest { 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; + questions: Question[]; + metadata?: { + source?: 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..3f73b2e2d 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -4,14 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ import type { - AcpPermissionRequest, ModelInfo, AvailableCommand, -} from './acpTypes.js'; + RequestPermissionRequest, +} from '@agentclientprotocol/sdk'; +import type { AskUserQuestionRequest } from './acpTypes.js'; import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export interface ChatMessage { - role: 'user' | 'assistant'; + role: 'user' | 'assistant' | 'thinking'; content: string; timestamp: number; } @@ -35,10 +36,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 +59,10 @@ export interface QwenAgentCallbacks { onThoughtChunk?: (chunk: string) => void; onToolCall?: (update: ToolCallUpdateData) => void; onPlan?: (entries: PlanEntry[]) => void; - onPermissionRequest?: (request: AcpPermissionRequest) => Promise; + onPermissionRequest?: (request: RequestPermissionRequest) => Promise; + onAskUserQuestion?: ( + request: AskUserQuestionRequest, + ) => Promise<{ optionId: string; answers?: Record }>; 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..a20f31406 100644 --- a/packages/vscode-ide-companion/src/types/connectionTypes.ts +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -6,9 +6,12 @@ import type { ChildProcess } from 'child_process'; import type { - AcpSessionUpdate, - AcpPermissionRequest, + RequestPermissionRequest, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import type { AuthenticateUpdateNotification, + AskUserQuestionRequest, } from './acpTypes.js'; export interface PendingRequest { @@ -19,12 +22,16 @@ 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; onEndTurn: (reason?: string) => void; + onAskUserQuestion: (data: AskUserQuestionRequest) => Promise<{ + optionId: string; + answers?: Record; + }>; } export interface AcpConnectionState { diff --git a/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts b/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts index f17f68170..76025b6b1 100644 --- a/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts +++ b/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts @@ -12,3 +12,14 @@ export interface PermissionResponseMessage { type: string; data: PermissionResponsePayload; } + +export interface AskUserQuestionResponsePayload { + optionId?: string; + answers: Record; + cancelled?: boolean; +} + +export interface AskUserQuestionResponseMessage { + type: string; + data: AskUserQuestionResponsePayload; +} 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/utils/authErrors.ts b/packages/vscode-ide-companion/src/utils/authErrors.ts index 85d652045..2c4258689 100644 --- a/packages/vscode-ide-companion/src/utils/authErrors.ts +++ b/packages/vscode-ide-companion/src/utils/authErrors.ts @@ -6,13 +6,56 @@ import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; -const AUTH_ERROR_PATTERNS = [ - 'Authentication required', // Standard authentication request message - `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`, // RPC error code indicates auth failure - 'Unauthorized', // HTTP unauthorized error - 'Invalid token', // Invalid token - 'Session expired', // Session expired -]; +const CODE_PATTERN = /\(\s*code:\s*(-?\d+)\s*\)/i; + +const toNumericCode = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (/^-?\d+$/.test(trimmed)) { + return Number.parseInt(trimmed, 10); + } + } + return null; +}; + +const extractCodeFromUnknown = (value: unknown): number | null => { + if (!value) { + return null; + } + + const directCode = toNumericCode(value); + if (directCode !== null) { + return directCode; + } + + if (typeof value === 'string') { + const match = value.match(CODE_PATTERN); + return match?.[1] ? Number.parseInt(match[1], 10) : null; + } + + if (typeof value === 'object') { + const record = value as Record; + const topLevelCode = toNumericCode(record['code']); + if (topLevelCode !== null) { + return topLevelCode; + } + + const nestedCode = extractCodeFromUnknown(record['error']); + if (nestedCode !== null) { + return nestedCode; + } + + const messageCode = extractCodeFromUnknown(record['message']); + if (messageCode !== null) { + return messageCode; + } + } + + return null; +}; /** * Determines if the given error is authentication-related @@ -23,14 +66,6 @@ export const isAuthenticationRequiredError = (error: unknown): boolean => { return false; } - // Extract error message text - const message = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : String(error); - - // Match authentication-related errors using predefined patterns - return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); + const code = extractCodeFromUnknown(error); + return code === ACP_ERROR_CODES.AUTH_REQUIRED; }; diff --git a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts index e3b837778..e855b2dec 100644 --- a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts +++ b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts @@ -10,6 +10,7 @@ import { openChatCommand } from '../commands/index.js'; /** * Find the editor group immediately to the left of the Qwen chat webview. * - If the chat webview group is the leftmost group, returns undefined. + * - If no chat webview is found in any editor group, returns undefined. * - Uses the webview tab viewType 'mainThreadWebview-qwenCode.chat'. */ export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined { diff --git a/packages/vscode-ide-companion/src/utils/errorMessage.test.ts b/packages/vscode-ide-companion/src/utils/errorMessage.test.ts new file mode 100644 index 000000000..55de1cd0b --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/errorMessage.test.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { getErrorMessage } from './errorMessage.js'; + +describe('getErrorMessage', () => { + it('extracts detailed message from top-level data.details string', () => { + expect( + getErrorMessage({ + data: { + details: 'Detailed error from backend', + }, + }), + ).toBe('Detailed error from backend'); + }); + + it('extracts detailed message from nested error.data.details.message', () => { + expect( + getErrorMessage({ + error: { + data: { + details: { + message: 'Nested detailed error message', + }, + }, + }, + }), + ).toBe('Nested detailed error message'); + }); +}); diff --git a/packages/vscode-ide-companion/src/utils/errorMessage.ts b/packages/vscode-ide-companion/src/utils/errorMessage.ts new file mode 100644 index 000000000..8cd7301b0 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/errorMessage.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export function getErrorMessage( + error: unknown, + fallback = 'Unknown error', +): string { + const combineMessageAndDetails = ( + message: string | null, + detailsMessage: string | null, + ): string | null => { + if (message && detailsMessage) { + return message === detailsMessage + ? message + : `${message}: ${detailsMessage}`; + } + return message ?? detailsMessage; + }; + + const extractDetailsMessage = (value: unknown): string | null => { + if (typeof value === 'string' && value) { + return value; + } + + if (typeof value !== 'object' || value === null) { + return null; + } + + const record = value as Record; + const details = record['details']; + if (typeof details === 'string' && details) { + return details; + } + if (typeof details === 'object' && details !== null) { + const detailsRecord = details as Record; + if ( + typeof detailsRecord['message'] === 'string' && + detailsRecord['message'] + ) { + return detailsRecord['message']; + } + try { + return JSON.stringify(details); + } catch { + return null; + } + } + return null; + }; + + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === 'string' && error) { + return error; + } + if (typeof error === 'object' && error !== null) { + const record = error as Record; + const topLevelMessage = + typeof record['message'] === 'string' && record['message'] + ? record['message'] + : null; + const topLevelDetailsMessage = extractDetailsMessage(record['data']); + const combinedTopLevelMessage = combineMessageAndDetails( + topLevelMessage, + topLevelDetailsMessage, + ); + if (combinedTopLevelMessage) { + return combinedTopLevelMessage; + } + const nested = record['error']; + if (typeof nested === 'object' && nested !== null) { + const nestedRecord = nested as Record; + const nestedMessage = + typeof nestedRecord['message'] === 'string' && nestedRecord['message'] + ? nestedRecord['message'] + : null; + const nestedDetailsMessage = extractDetailsMessage(nestedRecord['data']); + const combinedNestedMessage = combineMessageAndDetails( + nestedMessage, + nestedDetailsMessage, + ); + if (combinedNestedMessage) { + return combinedNestedMessage; + } + } + try { + return JSON.stringify(error); + } catch { + return fallback; + } + } + return fallback; +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 8d2c0bfed..bb503f307 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -44,11 +44,13 @@ 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 type { Question } from '../types/acpTypes.js'; import { DEFAULT_TOKEN_LIMIT, tokenLimit, } from '@qwen-code/qwen-code-core/src/core/tokenLimits.js'; +import { AskUserQuestionDialog } from '@qwen-code/webui'; export const App: React.FC = () => { const vscode = useVSCode(); @@ -70,6 +72,13 @@ export const App: React.FC = () => { options: PermissionOption[]; toolCall: PermissionToolCall; } | null>(null); + const [askUserQuestionRequest, setAskUserQuestionRequest] = useState<{ + questions: Question[]; + sessionId: string; + metadata?: { + source?: string; + }; + } | null>(null); const [planEntries, setPlanEntries] = useState([]); const [isAuthenticated, setIsAuthenticated] = useState(null); const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading @@ -298,22 +307,24 @@ export const App: React.FC = () => { // Emit a cancel to the extension and immediately reflect interruption locally. const handleCancel = useCallback(() => { if (messageHandling.isStreaming || messageHandling.isWaitingForResponse) { - // Proactively end local states and add an 'Interrupted' line - try { - messageHandling.endStreaming?.(); - } catch { - /* no-op */ + // End streaming state and add an 'Interrupted' line. + // IMPORTANT: Do NOT clear isWaitingForResponse here — let the + // extension's streamEnd message clear it after the cancel is + // properly processed on the backend. This keeps the submit + // guard active and prevents any cached input from being + // auto-submitted during the cancel → confirmed window. + if (messageHandling.isStreaming) { + try { + messageHandling.endStreaming?.(); + } catch { + /* no-op */ + } + messageHandling.addMessage({ + role: 'assistant', + content: 'Interrupted', + timestamp: Date.now(), + }); } - try { - messageHandling.clearWaitingForResponse?.(); - } catch { - /* no-op */ - } - messageHandling.addMessage({ - role: 'assistant', - content: 'Interrupted', - timestamp: Date.now(), - }); } // Notify extension/agent to cancel server-side work vscode.postMessage({ @@ -331,6 +342,7 @@ export const App: React.FC = () => { clearToolCalls, setPlanEntries, handlePermissionRequest: setPermissionRequest, + handleAskUserQuestion: setAskUserQuestionRequest, inputFieldRef, setInputText, setEditMode, @@ -481,6 +493,31 @@ export const App: React.FC = () => { [vscode], ); + // Handle ask user question response + const handleAskUserQuestionResponse = useCallback( + (answers: Record) => { + // Forward answers to extension as ACP permission response + vscode.postMessage({ + type: 'askUserQuestionResponse', + data: { answers }, + }); + + setAskUserQuestionRequest(null); + }, + [vscode], + ); + + // Handle ask user question cancel + const handleAskUserQuestionCancel = useCallback(() => { + // Forward cancel to extension as ACP permission response with cancel option + vscode.postMessage({ + type: 'askUserQuestionResponse', + data: { answers: {}, cancelled: true }, + }); + + setAskUserQuestionRequest(null); + }, [vscode]); + // Handle completion selection const handleCompletionSelect = useCallback( (item: CompletionItem) => { @@ -1012,6 +1049,14 @@ export const App: React.FC = () => { onClose={() => setPermissionRequest(null)} /> )} + + {isAuthenticated && askUserQuestionRequest && ( + + )}
); }; 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/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index ab4b70b2e..0b703da46 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { BaseMessageHandler } from './BaseMessageHandler.js'; +import { getErrorMessage } from '../../utils/errorMessage.js'; /** * Auth message handler @@ -67,6 +68,7 @@ export class AuthMessageHandler extends BaseMessageHandler { await vscode.commands.executeCommand('qwen-code.login'); } } catch (error) { + const errorMsg = getErrorMessage(error); console.error('[AuthMessageHandler] Login failed:', error); console.error( '[AuthMessageHandler] Error stack:', @@ -75,7 +77,7 @@ export class AuthMessageHandler extends BaseMessageHandler { this.sendToWebView({ type: 'loginError', data: { - message: `Login failed: ${error instanceof Error ? error.message : String(error)}`, + message: `Login failed: ${errorMsg}`, }, }); } diff --git a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts index 7d82315dc..bb49cc540 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { BaseMessageHandler } from './BaseMessageHandler.js'; import { getFileName } from '../utils/webviewUtils.js'; +import { getErrorMessage } from '../../utils/errorMessage.js'; /** * Editor message handler @@ -105,7 +106,9 @@ export class EditorMessageHandler extends BaseMessageHandler { '[EditorMessageHandler] Failed to focus active editor:', error, ); - vscode.window.showErrorMessage(`Failed to focus editor: ${error}`); + vscode.window.showErrorMessage( + `Failed to focus editor: ${getErrorMessage(error)}`, + ); } } } diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts index 908de9ca4..4e6e43575 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -14,6 +14,7 @@ import { } from '../../utils/editorGroupUtils.js'; import { ReadonlyFileSystemProvider } from '../../services/readonlyFileSystemProvider.js'; import { FileDiscoveryService } from '@qwen-code/qwen-code-core/src/services/fileDiscoveryService.js'; +import { getErrorMessage } from '../../utils/errorMessage.js'; /** * File message handler @@ -118,9 +119,10 @@ export class FileMessageHandler extends BaseMessageHandler { } } catch (error) { console.error('[FileMessageHandler] Failed to attach file:', error); + const errorMsg = getErrorMessage(error); this.sendToWebView({ type: 'error', - data: { message: `Failed to attach file: ${error}` }, + data: { message: `Failed to attach file: ${errorMsg}` }, }); } } @@ -203,9 +205,10 @@ export class FileMessageHandler extends BaseMessageHandler { '[FileMessageHandler] Failed to show context picker:', error, ); + const errorMsg = getErrorMessage(error); this.sendToWebView({ type: 'error', - data: { message: `Failed to show context picker: ${error}` }, + data: { message: `Failed to show context picker: ${errorMsg}` }, }); } } @@ -360,9 +363,10 @@ export class FileMessageHandler extends BaseMessageHandler { '[FileMessageHandler] Failed to get workspace files:', error, ); + const errorMsg = getErrorMessage(error); this.sendToWebView({ type: 'error', - data: { message: `Failed to get workspace files: ${error}` }, + data: { message: `Failed to get workspace files: ${errorMsg}` }, }); } } @@ -422,7 +426,9 @@ export class FileMessageHandler extends BaseMessageHandler { console.log('[FileOperations] File opened successfully:', absolutePath); } catch (error) { console.error('[FileMessageHandler] Failed to open file:', error); - vscode.window.showErrorMessage(`Failed to open file: ${error}`); + vscode.window.showErrorMessage( + `Failed to open file: ${getErrorMessage(error)}`, + ); } } @@ -445,7 +451,9 @@ export class FileMessageHandler extends BaseMessageHandler { }); } catch (error) { console.error('[FileMessageHandler] Failed to open diff:', error); - vscode.window.showErrorMessage(`Failed to open diff: ${error}`); + vscode.window.showErrorMessage( + `Failed to open diff: ${getErrorMessage(error)}`, + ); } } @@ -544,7 +552,7 @@ export class FileMessageHandler extends BaseMessageHandler { error, ); vscode.window.showErrorMessage( - `Failed to create and open temporary file: ${error}`, + `Failed to create and open temporary file: ${getErrorMessage(error)}`, ); } } diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index de23fb1e5..9cb401b43 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -7,7 +7,10 @@ import type { IMessageHandler } from './BaseMessageHandler.js'; import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; import type { ConversationStore } from '../../services/conversationStore.js'; -import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../../types/webviewMessageTypes.js'; import { SessionMessageHandler } from './SessionMessageHandler.js'; import { FileMessageHandler } from './FileMessageHandler.js'; import { EditorMessageHandler } from './EditorMessageHandler.js'; @@ -25,6 +28,9 @@ export class MessageRouter { private permissionHandler: | ((message: PermissionResponseMessage) => void) | null = null; + private askUserQuestionHandler: + | ((message: AskUserQuestionResponseMessage) => void) + | null = null; constructor( agentManager: QwenAgentManager, @@ -86,6 +92,14 @@ export class MessageRouter { return; } + // Handle ask user question response specially + if (message.type === 'askUserQuestionResponse') { + if (this.askUserQuestionHandler) { + this.askUserQuestionHandler(message as AskUserQuestionResponseMessage); + } + return; + } + // Find appropriate handler const handler = this.handlers.find((h) => h.canHandle(message.type)); @@ -135,6 +149,15 @@ export class MessageRouter { this.permissionHandler = handler; } + /** + * Set ask user question handler + */ + setAskUserQuestionHandler( + handler: (message: AskUserQuestionResponseMessage) => void, + ): void { + this.askUserQuestionHandler = handler; + } + /** * Set login handler */ diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 72278d62e..e03a0e28d 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -8,9 +8,8 @@ import * as vscode from 'vscode'; import { BaseMessageHandler } from './BaseMessageHandler.js'; import type { ChatMessage } from '../../services/qwenAgentManager.js'; import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; -import { ACP_ERROR_CODES } from '../../constants/acpSchema.js'; - -const AUTH_REQUIRED_CODE_PATTERN = `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`; +import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; +import { getErrorMessage } from '../../utils/errorMessage.js'; /** * Session message handler @@ -27,7 +26,6 @@ export class SessionMessageHandler extends BaseMessageHandler { 'newQwenSession', 'switchQwenSession', 'getQwenSessions', - 'saveSession', 'resumeSession', 'cancelStreaming', // UI action: open a new chat tab (new WebviewPanel) @@ -87,10 +85,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; @@ -106,9 +100,10 @@ export class SessionMessageHandler extends BaseMessageHandler { '[SessionMessageHandler] Failed to open new chat tab:', error, ); + const errorMsg = this.getErrorMessage(error); this.sendToWebView({ type: 'error', - data: { message: `Failed to open new chat tab: ${error}` }, + data: { message: `Failed to open new chat tab: ${errorMsg}` }, }); } break; @@ -165,16 +160,49 @@ export class SessionMessageHandler extends BaseMessageHandler { } /** - * Notify the webview that streaming has finished. + * Monotonically increasing request counter used to tag streamStart/streamEnd + * so the WebView can detect and discard stale events from previous requests. */ - private sendStreamEnd(reason?: string): void { - const data: { timestamp: number; reason?: string } = { + private requestCounter = 0; + private currentRequestId: string | null = null; + private streamEndSent = false; + + /** + * Notify the webview that streaming has finished. + * Includes the `requestId` so the webview can ignore stale events. + * Guarded by `streamEndSent` to prevent duplicate streamEnd for the + * same request (e.g. cancel handler + error handler both sending one). + * + * @param reason Optional reason string (e.g. 'user_cancelled'). + * @param forRequestId When provided, the call is scoped to a specific + * request invocation. If a newer request has since overwritten + * `this.currentRequestId`, the call is silently dropped — this + * prevents a stale `handleSendMessage` invocation (resumed after + * cancellation) from emitting a streamEnd tagged as the newer request. + */ + private sendStreamEnd(reason?: string, forRequestId?: string): void { + if (this.streamEndSent) { + return; + } + // If the caller captured a request ID, only proceed when it still + // matches the active request. A mismatch means a newer request has + // taken over the shared state; emitting now would incorrectly tag + // the event with the newer request's ID. + if (forRequestId && this.currentRequestId !== forRequestId) { + return; + } + this.streamEndSent = true; + + const data: { timestamp: number; reason?: string; requestId?: string } = { timestamp: Date.now(), }; if (reason) { data.reason = reason; } + if (this.currentRequestId) { + data.requestId = this.currentRequestId; + } this.sendToWebView({ type: 'streamEnd', @@ -226,6 +254,14 @@ export class SessionMessageHandler extends BaseMessageHandler { return 'dismiss'; } + private getErrorMessage(error: unknown): string { + return getErrorMessage(error); + } + + private shouldPromptLogin(error: unknown): boolean { + return isAuthenticationRequiredError(error); + } + /** * Handle send message request */ @@ -284,7 +320,7 @@ export class SessionMessageHandler extends BaseMessageHandler { data: newConv, }); } catch (error) { - const errorMsg = `Failed to create conversation: ${error}`; + const errorMsg = `Failed to create conversation: ${this.getErrorMessage(error)}`; console.error('[SessionMessageHandler]', errorMsg); vscode.window.showErrorMessage(errorMsg); this.sendToWebView({ @@ -372,12 +408,8 @@ export class SessionMessageHandler extends BaseMessageHandler { '[SessionMessageHandler] Failed to create session before sending message:', createErr, ); - const errorMsg = - createErr instanceof Error ? createErr.message : String(createErr); - if ( - errorMsg.includes('Authentication required') || - errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) - ) { + const errorMsg = this.getErrorMessage(createErr); + if (this.shouldPromptLogin(createErr)) { await this.promptLogin( 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', ); @@ -389,12 +421,28 @@ export class SessionMessageHandler extends BaseMessageHandler { } // Send to agent + // + // Generate a unique requestId so the webview can correlate + // streamStart/streamEnd and discard stale events. + this.requestCounter += 1; + this.currentRequestId = `req-${this.requestCounter}-${Date.now()}`; + this.streamEndSent = false; + + // Capture locally so that if a newer handleSendMessage() overwrites + // the shared fields while we are awaiting, our sendStreamEnd calls + // will detect the mismatch and silently no-op instead of emitting + // a streamEnd tagged with the newer request's ID. + const myRequestId = this.currentRequestId; + try { this.resetStreamContent(); this.sendToWebView({ type: 'streamStart', - data: { timestamp: Date.now() }, + data: { + timestamp: Date.now(), + requestId: myRequestId, + }, }); await this.agentManager.sendMessage(formattedText); @@ -412,13 +460,13 @@ export class SessionMessageHandler extends BaseMessageHandler { ); } - this.sendStreamEnd(); + this.sendStreamEnd(undefined, myRequestId); } catch (error) { console.error('[SessionMessageHandler] Error sending message:', error); const err = error as unknown as Error; // Safely convert error to string - const errorMsg = error ? String(error) : 'Unknown error'; + const errorMsg = this.getErrorMessage(error); const lower = errorMsg.toLowerCase(); // Suppress user-cancelled/aborted errors (ESC/Stop button) @@ -434,17 +482,13 @@ export class SessionMessageHandler extends BaseMessageHandler { if (isAbortLike) { // Do not show VS Code error popup for intentional cancellations. // Ensure the webview knows the stream ended due to user action. - this.sendStreamEnd('user_cancelled'); + this.sendStreamEnd('user_cancelled', myRequestId); return; } // Check for session not found error and handle it appropriately if ( errorMsg.includes('Session not found') || - errorMsg.includes('No active ACP session') || - errorMsg.includes('Authentication required') || - errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) || - errorMsg.includes('Unauthorized') || - errorMsg.includes('Invalid token') + this.shouldPromptLogin(error) ) { // Show a more user-friendly error message for expired sessions await this.promptLogin( @@ -456,7 +500,7 @@ export class SessionMessageHandler extends BaseMessageHandler { type: 'sessionExpired', data: { message: 'Session expired. Please login again.' }, }); - this.sendStreamEnd('session_expired'); + this.sendStreamEnd('session_expired', myRequestId); } else { const isTimeoutError = lower.includes('timeout') || lower.includes('timed out'); @@ -479,15 +523,15 @@ export class SessionMessageHandler extends BaseMessageHandler { type: 'message', data: timeoutMessage, }); - this.sendStreamEnd('timeout'); + this.sendStreamEnd('timeout', myRequestId); } else { // Handling of Non-Timeout Errors - vscode.window.showErrorMessage(`Error sending message: ${error}`); + vscode.window.showErrorMessage(`Error sending message: ${errorMsg}`); this.sendToWebView({ type: 'error', data: { message: errorMsg }, }); - this.sendStreamEnd('error'); + this.sendStreamEnd('error', myRequestId); } } } @@ -529,15 +573,9 @@ export class SessionMessageHandler extends BaseMessageHandler { ); // Safely convert error to string - const errorMsg = error ? String(error) : 'Unknown error'; + const errorMsg = this.getErrorMessage(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') - ) { + if (this.shouldPromptLogin(error)) { // Show a more user-friendly error message for expired sessions await this.promptLogin( 'Your login session has expired or is invalid. Please login again to create a new session.', @@ -551,7 +589,7 @@ export class SessionMessageHandler extends BaseMessageHandler { } else { this.sendToWebView({ type: 'error', - data: { message: `Failed to create new session: ${error}` }, + data: { message: `Failed to create new session: ${errorMsg}` }, }); } } @@ -637,17 +675,8 @@ export class SessionMessageHandler extends BaseMessageHandler { loadError, ); - // Safely convert error to string - const errorMsg = loadError ? String(loadError) : 'Unknown error'; - // Check for authentication/session expiration errors - if ( - errorMsg.includes('Authentication required') || - errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) || - errorMsg.includes('Unauthorized') || - errorMsg.includes('Invalid token') || - errorMsg.includes('No active ACP session') - ) { + if (this.shouldPromptLogin(loadError)) { // Show a more user-friendly error message for expired sessions await this.promptLogin( 'Your login session has expired or is invalid. Please login again to switch sessions.', @@ -696,18 +725,8 @@ export class SessionMessageHandler extends BaseMessageHandler { createError, ); - // Safely convert error to string - const createErrorMsg = createError - ? String(createError) - : 'Unknown error'; // Check for authentication/session expiration errors in session creation - if ( - createErrorMsg.includes('Authentication required') || - createErrorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) || - createErrorMsg.includes('Unauthorized') || - createErrorMsg.includes('Invalid token') || - createErrorMsg.includes('No active ACP session') - ) { + if (this.shouldPromptLogin(createError)) { // Show a more user-friendly error message for expired sessions await this.promptLogin( 'Your login session has expired or is invalid. Please login again to switch sessions.', @@ -739,15 +758,9 @@ export class SessionMessageHandler extends BaseMessageHandler { console.error('[SessionMessageHandler] Failed to switch session:', error); // Safely convert error to string - const errorMsg = error ? String(error) : 'Unknown error'; + const errorMsg = this.getErrorMessage(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') - ) { + if (this.shouldPromptLogin(error)) { // Show a more user-friendly error message for expired sessions await this.promptLogin( 'Your login session has expired or is invalid. Please login again to switch sessions.', @@ -761,7 +774,7 @@ export class SessionMessageHandler extends BaseMessageHandler { } else { this.sendToWebView({ type: 'error', - data: { message: `Failed to switch session: ${error}` }, + data: { message: `Failed to switch session: ${errorMsg}` }, }); } } @@ -794,15 +807,9 @@ export class SessionMessageHandler extends BaseMessageHandler { console.error('[SessionMessageHandler] Failed to get sessions:', error); // Safely convert error to string - const errorMsg = error ? String(error) : 'Unknown error'; + const errorMsg = this.getErrorMessage(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') - ) { + if (this.shouldPromptLogin(error)) { // Show a more user-friendly error message for expired sessions await this.promptLogin( 'Your login session has expired or is invalid. Please login again to view sessions.', @@ -816,88 +823,7 @@ export class SessionMessageHandler extends BaseMessageHandler { } else { this.sendToWebView({ type: 'error', - data: { message: `Failed to get sessions: ${error}` }, - }); - } - } - } - - /** - * Handle save session request - */ - private async handleSaveSession(tag: string): Promise { - try { - if (!this.currentConversationId) { - throw new Error('No active conversation to save'); - } - - // Try ACP save first - try { - const response = await this.agentManager.saveSessionViaAcp( - this.currentConversationId, - tag, - ); - - this.sendToWebView({ - type: 'saveSessionResponse', - data: response, - }); - } catch (acpError) { - // Safely convert error to string - const errorMsg = acpError ? String(acpError) : 'Unknown error'; - // Check for authentication/session expiration errors - if ( - errorMsg.includes('Authentication required') || - errorMsg.includes(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}`, - }, + data: { message: `Failed to get sessions: ${errorMsg}` }, }); } } @@ -913,21 +839,15 @@ export class SessionMessageHandler extends BaseMessageHandler { // Cancel the current streaming operation in the agent manager await this.agentManager.cancelCurrentPrompt(); - // Send streamEnd message to WebView to update UI - this.sendToWebView({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'user_cancelled' }, - }); + // Use sendStreamEnd to include requestId for proper correlation + this.sendStreamEnd('user_cancelled'); console.log('[SessionMessageHandler] Streaming cancelled successfully'); } catch (_error) { console.log('[SessionMessageHandler] Streaming cancelled (interrupted)'); - // Always send streamEnd to update UI, regardless of errors - this.sendToWebView({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'user_cancelled' }, - }); + // Use sendStreamEnd (with duplicate guard) to include requestId + this.sendStreamEnd('user_cancelled'); } } @@ -977,16 +897,8 @@ export class SessionMessageHandler extends BaseMessageHandler { await this.handleGetQwenSessions(); return; } catch (acpError) { - // Safely convert error to string - const errorMsg = acpError ? String(acpError) : 'Unknown error'; // Check for authentication/session expiration errors - if ( - errorMsg.includes('Authentication required') || - errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) || - errorMsg.includes('Unauthorized') || - errorMsg.includes('Invalid token') || - errorMsg.includes('No active ACP session') - ) { + if (this.shouldPromptLogin(acpError)) { // Show a more user-friendly error message for expired sessions await this.promptLogin( 'Your login session has expired or is invalid. Please login again to resume sessions.', @@ -1006,15 +918,9 @@ export class SessionMessageHandler extends BaseMessageHandler { console.error('[SessionMessageHandler] Failed to resume session:', error); // Safely convert error to string - const errorMsg = error ? String(error) : 'Unknown error'; + const errorMsg = this.getErrorMessage(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') - ) { + if (this.shouldPromptLogin(error)) { // Show a more user-friendly error message for expired sessions await this.promptLogin( 'Your login session has expired or is invalid. Please login again to resume sessions.', @@ -1028,7 +934,7 @@ export class SessionMessageHandler extends BaseMessageHandler { } else { this.sendToWebView({ type: 'error', - data: { message: `Failed to resume session: ${error}` }, + data: { message: `Failed to resume session: ${errorMsg}` }, }); } } @@ -1046,9 +952,10 @@ export class SessionMessageHandler extends BaseMessageHandler { // No explicit response needed; WebView listens for modeChanged } catch (error) { console.error('[SessionMessageHandler] Failed to set mode:', error); + const errorMsg = this.getErrorMessage(error); this.sendToWebView({ type: 'error', - data: { message: `Failed to set mode: ${error}` }, + data: { message: `Failed to set mode: ${errorMsg}` }, }); } } @@ -1068,7 +975,7 @@ export class SessionMessageHandler extends BaseMessageHandler { `Model switched to: ${modelId}`, ); } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); + const errorMsg = this.getErrorMessage(error); console.error('[SessionMessageHandler] Failed to set model:', error); vscode.window.showErrorMessage(`Failed to switch model: ${errorMsg}`); this.sendToWebView({ 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..24c3ce561 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; }, []); /** @@ -168,7 +161,20 @@ export const useMessageHandling = () => { if (idx === null) { idx = next.length; thinkingMessageIndexRef.current = idx; - next.push({ role: 'thinking', content: '', timestamp: Date.now() }); + // Use a timestamp just before the assistant placeholder so thinking + // sorts above the response text when messages are ordered by time. + const assistantIdx = streamingMessageIndexRef.current; + const assistantTs = + assistantIdx !== null && + assistantIdx >= 0 && + assistantIdx < next.length + ? next[assistantIdx].timestamp + : Date.now(); + next.push({ + role: 'thinking', + content: '', + timestamp: assistantTs - 1, + }); } if (idx >= 0 && idx < next.length) { const target = next[idx]; @@ -178,18 +184,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..52d1655e7 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -14,7 +14,8 @@ 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'; +import type { Question } from '../../types/acpTypes.js'; const FORCE_CLEAR_STREAM_END_REASONS = new Set([ 'user_cancelled', @@ -41,10 +42,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 +88,7 @@ interface UseWebViewMessagesProps { appendStreamChunk: (chunk: string) => void; endStreaming: () => void; breakAssistantSegment: () => void; + breakThinkingSegment: () => void; appendThinkingChunk: (chunk: string) => void; clearThinking: () => void; setWaitingForResponse: (message: string) => void; @@ -114,6 +112,17 @@ interface UseWebViewMessagesProps { } | null, ) => void; + // Ask User Question + handleAskUserQuestion: ( + request: { + questions: Question[]; + sessionId: string; + metadata?: { + source?: string; + }; + } | null, + ) => void; + // Input inputFieldRef: React.RefObject; setInputText: (text: string) => void; @@ -143,6 +152,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + handleAskUserQuestion, inputFieldRef, setInputText, setEditMode, @@ -158,6 +168,9 @@ export const useWebViewMessages = ({ // keep the bottom "waiting" message visible until all of them complete. const activeExecToolCallsRef = useRef>(new Set()); const modelInfoRef = useRef(null); + // Track the active requestId from the latest streamStart so we can + // discard stale streamEnd events from cancelled/previous requests. + const activeRequestIdRef = useRef(null); // Use ref to store callbacks to avoid useEffect dependency issues const handlersRef = useRef({ sessionManagement, @@ -167,6 +180,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + handleAskUserQuestion, setIsAuthenticated, setUsageStats, setModelInfo, @@ -216,6 +230,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + handleAskUserQuestion, setIsAuthenticated, setUsageStats, setModelInfo, @@ -449,11 +464,15 @@ export const useWebViewMessages = ({ break; } - case 'streamStart': - handlers.messageHandling.startStreaming( - (message.data as { timestamp?: number } | undefined)?.timestamp, - ); + case 'streamStart': { + const startData = message.data as + | { timestamp?: number; requestId?: string } + | undefined; + // Store the requestId so we can validate streamEnd events + activeRequestIdRef.current = startData?.requestId ?? null; + handlers.messageHandling.startStreaming(startData?.timestamp); break; + } case 'streamChunk': { handlers.messageHandling.appendStreamChunk(message.data.chunk); @@ -467,6 +486,24 @@ export const useWebViewMessages = ({ } case 'streamEnd': { + const endData = message.data as + | { reason?: string; requestId?: string } + | undefined; + const endRequestId = endData?.requestId ?? null; + + // Drop stale or untagged streamEnd when a tagged stream is active. + if (activeRequestIdRef.current) { + if (endRequestId !== activeRequestIdRef.current) { + console.log( + '[useWebViewMessages] Ignoring stale/untagged streamEnd:', + endRequestId, + 'active:', + activeRequestIdRef.current, + ); + break; + } + } + // Always end local streaming state and clear thinking state handlers.messageHandling.endStreaming(); handlers.messageHandling.clearThinking(); @@ -476,9 +513,7 @@ export const useWebViewMessages = ({ // This avoids UI getting stuck with Stop button visible after // rejecting a permission request. try { - const reason = ( - (message.data as { reason?: string } | undefined)?.reason || '' - ).toLowerCase(); + const reason = (endData?.reason || '').toLowerCase(); /** * Handle different types of stream end reasons that require a full reset: @@ -612,6 +647,7 @@ export const useWebViewMessages = ({ // Split assistant stream so subsequent chunks start a new assistant message handlers.messageHandling.breakAssistantSegment(); + handlers.messageHandling.breakThinkingSegment(); } break; } @@ -629,6 +665,19 @@ export const useWebViewMessages = ({ break; } + case 'askUserQuestion': { + // Handle ask user question request from extension + const questionsData = message.data as { + questions: Question[]; + sessionId: string; + metadata?: { + source?: string; + }; + }; + handlers.handleAskUserQuestion(questionsData); + break; + } + case 'plan': if (message.data.entries && Array.isArray(message.data.entries)) { const entries = message.data.entries as PlanEntry[]; @@ -686,6 +735,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 +761,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 +986,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/ChatProviderRegistry.test.ts b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.test.ts new file mode 100644 index 000000000..3820538c8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ChatProviderRegistry } from './ChatProviderRegistry.js'; + +describe('ChatProviderRegistry', () => { + it('tracks editor and view providers separately while exposing a combined list', () => { + const factory = vi + .fn() + .mockReturnValueOnce({ dispose: vi.fn(), kind: 'editor-1' }) + .mockReturnValueOnce({ dispose: vi.fn(), kind: 'view-1' }) + .mockReturnValueOnce({ dispose: vi.fn(), kind: 'editor-2' }); + + const registry = new ChatProviderRegistry(factory); + + const editor1 = registry.createEditorProvider(); + const view1 = registry.createViewProvider(); + const editor2 = registry.createEditorProvider(); + + expect(factory).toHaveBeenCalledTimes(3); + expect(registry.getEditorProviders()).toEqual([editor1, editor2]); + expect(registry.getPermissionAwareProviders()).toEqual([ + editor1, + editor2, + view1, + ]); + }); + + it('disposes all tracked providers and resets internal collections', () => { + const editorDispose = vi.fn(); + const viewDispose = vi.fn(); + const registry = new ChatProviderRegistry(() => ({ dispose: vi.fn() })); + + registry.createEditorProvider({ dispose: editorDispose }); + registry.createViewProvider({ dispose: viewDispose }); + + registry.disposeAll(); + + expect(editorDispose).toHaveBeenCalledTimes(1); + expect(viewDispose).toHaveBeenCalledTimes(1); + expect(registry.getEditorProviders()).toEqual([]); + expect(registry.getPermissionAwareProviders()).toEqual([]); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts new file mode 100644 index 000000000..94cacf47d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +type DisposableProvider = { + dispose(): void; +}; + +/** + * Tracks chat providers by host type while exposing a combined list for flows + * like permission handling and diff suppression. + */ +export class ChatProviderRegistry { + private editorProviders: T[] = []; + private viewProviders: T[] = []; + + constructor(private readonly createProvider: () => T) {} + + createEditorProvider(provider: T = this.createProvider()): T { + this.editorProviders.push(provider); + return provider; + } + + createViewProvider(provider: T = this.createProvider()): T { + this.viewProviders.push(provider); + return provider; + } + + getEditorProviders(): T[] { + return [...this.editorProviders]; + } + + getPermissionAwareProviders(): T[] { + return [...this.editorProviders, ...this.viewProviders]; + } + + disposeAll(): void { + for (const provider of this.getPermissionAwareProviders()) { + provider.dispose(); + } + this.editorProviders = []; + this.viewProviders = []; + } +} diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts new file mode 100644 index 000000000..a25860eb8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ChatWebviewViewProvider } from './ChatWebviewViewProvider.js'; + +vi.mock('vscode', () => ({})); + +describe('ChatWebviewViewProvider', () => { + it('lazily creates the WebViewProvider on first resolveWebviewView call', async () => { + const mockProvider = { + attachToView: vi.fn().mockResolvedValue(undefined), + }; + const factory = vi.fn(() => mockProvider); + + const viewProvider = new ChatWebviewViewProvider(factory as never); + + const mockWebviewView = { + webview: {}, + viewType: 'qwen-code.chatView.sidebar', + }; + + await viewProvider.resolveWebviewView(mockWebviewView as never); + + expect(factory).toHaveBeenCalledTimes(1); + expect(mockProvider.attachToView).toHaveBeenCalledWith( + mockWebviewView, + 'qwen-code.chatView.sidebar', + ); + }); + + it('reuses the same WebViewProvider on subsequent calls', async () => { + const mockProvider = { + attachToView: vi.fn().mockResolvedValue(undefined), + }; + const factory = vi.fn(() => mockProvider); + + const viewProvider = new ChatWebviewViewProvider(factory as never); + + const mockView1 = { webview: {}, viewType: 'sidebar' }; + const mockView2 = { webview: {}, viewType: 'sidebar' }; + + await viewProvider.resolveWebviewView(mockView1 as never); + await viewProvider.resolveWebviewView(mockView2 as never); + + // Factory should only be called once (lazy creation) + expect(factory).toHaveBeenCalledTimes(1); + // But attachToView should be called for each resolve + expect(mockProvider.attachToView).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts new file mode 100644 index 000000000..ffce1152a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as vscode from 'vscode'; +import type { WebViewProvider } from './WebViewProvider.js'; + +/** + * Factory function type that lazily creates a WebViewProvider instance. + * The provider is only instantiated when VS Code actually opens the view. + */ +export type WebViewProviderFactory = () => WebViewProvider; + +/** + * WebviewView host for placing the chat UI in sidebar / panel / secondary sidebar. + * + * Accepts a factory function instead of a pre-built WebViewProvider so the + * heavyweight provider (QwenAgentManager, ConversationStore, etc.) is only + * created when VS Code actually opens the view, not at extension startup. + */ +export class ChatWebviewViewProvider implements vscode.WebviewViewProvider { + private webViewProvider: WebViewProvider | null = null; + + /** + * @param createWebViewProvider - Factory that creates a WebViewProvider on demand + */ + constructor(private readonly createWebViewProvider: WebViewProviderFactory) {} + + /** + * Called by VS Code when the webview view becomes visible for the first time. + * Creates the WebViewProvider lazily and attaches the webview. + * + * @param webviewView - The webview view created by VS Code + */ + async resolveWebviewView(webviewView: vscode.WebviewView): Promise { + // Lazily create the provider on first resolve + if (!this.webViewProvider) { + this.webViewProvider = this.createWebViewProvider(); + } + + // Webview options (enableScripts, localResourceRoots) are configured + // inside WebViewProvider.attachToView — no duplication needed here. + await this.webViewProvider.attachToView(webviewView, webviewView.viewType); + } +} diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts similarity index 74% rename from packages/vscode-ide-companion/src/webview/MessageHandler.ts rename to packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index 30b9abe56..a06fd1a3b 100644 --- a/packages/vscode-ide-companion/src/webview/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -4,10 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { QwenAgentManager } from '../services/qwenAgentManager.js'; -import type { ConversationStore } from '../services/conversationStore.js'; -import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js'; -import { MessageRouter } from './handlers/MessageRouter.js'; +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../../types/webviewMessageTypes.js'; +import { MessageRouter } from '../handlers/MessageRouter.js'; /** * MessageHandler (Refactored Version) @@ -61,6 +64,15 @@ export class MessageHandler { this.router.setPermissionHandler(handler); } + /** + * Set ask user question handler + */ + setAskUserQuestionHandler( + handler: (message: AskUserQuestionResponseMessage) => void, + ): void { + this.router.setAskUserQuestionHandler(handler); + } + /** * Set login handler */ diff --git a/packages/vscode-ide-companion/src/webview/PanelManager.ts b/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts similarity index 96% rename from packages/vscode-ide-companion/src/webview/PanelManager.ts rename to packages/vscode-ide-companion/src/webview/providers/PanelManager.ts index 44f1a6ecc..0c02dc3ca 100644 --- a/packages/vscode-ide-companion/src/webview/PanelManager.ts +++ b/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts @@ -224,8 +224,18 @@ export class PanelManager { return; } + // Capture a reference to the current panel so the deferred callback can + // detect if the panel was disposed or replaced before it runs. + const scheduledPanel = this.panel; + // Defer slightly so the tab model is updated after create/reveal setTimeout(() => { + // The panel may have been disposed/replaced before this callback runs. + if (!this.panel || this.panel !== scheduledPanel) { + return; + } + + const panelTitle = this.panel.title; const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs); const match = allTabs.find((t) => { // Type guard for webview tab input @@ -234,7 +244,7 @@ export class PanelManager { !!inp && typeof inp === 'object' && 'viewType' in inp; const isWebview = isWebviewInput(input); const sameViewType = isWebview && input.viewType === 'qwenCode.chat'; - const sameLabel = t.label === this.panel!.title; + const sameLabel = t.label === panelTitle; return !!(sameViewType || sameLabel); }); this.panelTab = match ?? null; diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts new file mode 100644 index 000000000..3c2029fe5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { WebViewContent } from './WebViewContent.js'; + +vi.mock('vscode', () => ({ + Uri: { + joinPath: vi.fn((_base: unknown, ...parts: string[]) => ({ + fsPath: `/ext/${parts.join('/')}`, + })), + }, +})); + +/** + * Helper: create a minimal mock vscode.Webview + */ +function createMockWebview() { + return { + asWebviewUri: vi.fn((uri: { fsPath: string }) => ({ + toString: () => `https://webview/${uri.fsPath}`, + })), + cspSource: 'https://csp.source', + }; +} + +describe('WebViewContent', () => { + const fakeExtensionUri = { fsPath: '/ext' } as never; + + it('generates HTML when given a raw Webview', () => { + const webview = createMockWebview(); + const html = WebViewContent.generate(webview as never, fakeExtensionUri); + + expect(html).toContain(''); + expect(html).toContain('Qwen Code'); + expect(html).toContain(webview.cspSource); + expect(webview.asWebviewUri).toHaveBeenCalled(); + }); + + it('generates HTML when given a WebviewPanel (has .webview property)', () => { + const webview = createMockWebview(); + const panel = { webview }; + + const html = WebViewContent.generate(panel as never, fakeExtensionUri); + + expect(html).toContain(''); + expect(webview.asWebviewUri).toHaveBeenCalled(); + }); + + it('generates HTML when given a WebviewView (has .webview property)', () => { + const webview = createMockWebview(); + const view = { webview, viewType: 'sidebar' }; + + const html = WebViewContent.generate(view as never, fakeExtensionUri); + + expect(html).toContain(''); + expect(webview.asWebviewUri).toHaveBeenCalled(); + }); + + it('includes the script tag with the correct URI', () => { + const webview = createMockWebview(); + const html = WebViewContent.generate(webview as never, fakeExtensionUri); + + expect(html).toContain(' + - - - - @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/package.json b/packages/webui/package.json index f2d26978b..da5a463ab 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.12.0", + "version": "0.13.0", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", 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/layout/CompletionMenu.tsx b/packages/webui/src/components/layout/CompletionMenu.tsx index 159b35847..06727f7ee 100644 --- a/packages/webui/src/components/layout/CompletionMenu.tsx +++ b/packages/webui/src/components/layout/CompletionMenu.tsx @@ -123,6 +123,7 @@ export const CompletionMenu: FC = ({ setSelected((prev) => Math.max(prev - 1, 0)); break; case 'Enter': + case 'Tab': event.preventDefault(); if (items[selected]) { onSelect(items[selected]); diff --git a/packages/webui/src/components/messages/AskUserQuestionDialog.tsx b/packages/webui/src/components/messages/AskUserQuestionDialog.tsx new file mode 100644 index 000000000..d30926d99 --- /dev/null +++ b/packages/webui/src/components/messages/AskUserQuestionDialog.tsx @@ -0,0 +1,525 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * AskUserQuestionDialog component for displaying questions to the user + * and collecting their responses in the WebView + */ + +import type { FC } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; + +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +export interface AskUserQuestionDialogProps { + questions: Question[]; + onSubmit: (answers: Record) => void; + onCancel: () => void; +} + +interface AnswerState { + selectedOption?: string; + customInput?: string; + multiSelectedOptions?: string[]; + customInputChecked?: boolean; +} + +export const AskUserQuestionDialog: FC = ({ + questions, + onSubmit, + onCancel, +}) => { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + const [showCustomInput, setShowCustomInput] = useState(false); + const containerRef = useRef(null); + const customInputRef = useRef(null); + + const hasMultipleQuestions = questions.length > 1; + const totalTabs = hasMultipleQuestions + ? questions.length + 1 + : questions.length; + const isSubmitTab = + hasMultipleQuestions && currentQuestionIndex === totalTabs - 1; + + const currentQuestion = isSubmitTab ? null : questions[currentQuestionIndex]; + const isMultiSelect = currentQuestion?.multiSelect ?? false; + + // Get current answer state + const currentAnswer = answers[currentQuestionIndex] || {}; + + // Get answer for a specific question + const getAnswerForQuestion = useCallback( + (idx: number): string | undefined => { + const q = questions[idx]; + const answerState = answers[idx]; + if (!answerState) { + return undefined; + } + + if (q?.multiSelect) { + const selections = [...(answerState.multiSelectedOptions || [])]; + const customValue = (answerState.customInput || '').trim(); + if (answerState.customInputChecked && customValue) { + selections.push(customValue); + } + return selections.length > 0 ? selections.join(', ') : undefined; + } + + // Check if custom input was used (value doesn't match any option) + if (answerState.customInput && answerState.customInput.trim()) { + const matchesOption = q?.options.some( + (opt) => opt.label === answerState.customInput?.trim(), + ); + if (!matchesOption) { + return answerState.customInput.trim(); + } + } + + return answerState.selectedOption; + }, + [questions, answers], + ); + + // Handle submitting all answers + const handleSubmit = useCallback(() => { + const answersRecord: Record = {}; + questions.forEach((_, idx) => { + const answer = getAnswerForQuestion(idx); + if (answer !== undefined) { + answersRecord[idx] = answer; + } + }); + onSubmit(answersRecord); + }, [questions, onSubmit, getAnswerForQuestion]); + + // Handle confirming multi-select for current question + const handleMultiSelectConfirm = useCallback(() => { + if (!currentQuestion) { + return; + } + + const answerState = answers[currentQuestionIndex] || {}; + const selections = [...(answerState.multiSelectedOptions || [])]; + const customValue = (answerState.customInput || '').trim(); + if (answerState.customInputChecked && customValue) { + selections.push(customValue); + } + if (selections.length === 0) { + return; + } + + const value = selections.join(', '); + + const updatedAnswers = { + ...answers, + [currentQuestionIndex]: { + ...answerState, + selectedOption: value, + }, + }; + setAnswers(updatedAnswers); + + if (!hasMultipleQuestions) { + onSubmit({ [currentQuestionIndex]: value }); + } else if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShowCustomInput(false); + } + }, [ + currentQuestion, + answers, + currentQuestionIndex, + hasMultipleQuestions, + totalTabs, + onSubmit, + ]); + + // Handle option selection + const handleOptionSelect = useCallback( + (optionIndex: number) => { + if (!currentQuestion) { + return; + } + + if (isMultiSelect) { + const answerState = answers[currentQuestionIndex] || {}; + const current = answerState.multiSelectedOptions || []; + const option = currentQuestion.options[optionIndex]; + const isChecked = current.includes(option.label); + const updated = isChecked + ? current.filter((l) => l !== option.label) + : [...current, option.label]; + + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + multiSelectedOptions: updated, + }, + }); + } else { + const option = currentQuestion.options[optionIndex]; + const answerState = answers[currentQuestionIndex] || {}; + const updated = { + ...answerState, + selectedOption: option.label, + customInput: undefined, + }; + setAnswers({ ...answers, [currentQuestionIndex]: updated }); + + if (!hasMultipleQuestions) { + onSubmit({ [currentQuestionIndex]: option.label }); + } else if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShowCustomInput(false); + } + } + }, + [ + currentQuestion, + isMultiSelect, + answers, + currentQuestionIndex, + hasMultipleQuestions, + totalTabs, + onSubmit, + ], + ); + + // Handle custom input change + const handleCustomInputChange = (value: string) => { + const answerState = answers[currentQuestionIndex] || {}; + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + customInput: value, + customInputChecked: isMultiSelect && value.trim().length > 0, + }, + }); + }; + + // Handle custom input submit + const handleCustomInputSubmit = () => { + const value = currentAnswer.customInput?.trim() || ''; + if (!value) { + return; + } + + if (isMultiSelect) { + const answerState = answers[currentQuestionIndex] || {}; + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + customInputChecked: !answerState.customInputChecked, + }, + }); + } else { + const answerState = answers[currentQuestionIndex] || {}; + const updated = { + ...answerState, + selectedOption: value, + }; + setAnswers({ ...answers, [currentQuestionIndex]: updated }); + + if (!hasMultipleQuestions) { + onSubmit({ [currentQuestionIndex]: value }); + } else if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShowCustomInput(false); + } + } + }; + + // Escape to cancel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onCancel]); + + // Focus custom input when shown + useEffect(() => { + if (showCustomInput && customInputRef.current) { + customInputRef.current.focus(); + } + }, [showCustomInput]); + + // Reset custom input visibility when switching tabs + useEffect(() => { + setShowCustomInput(false); + }, [currentQuestionIndex]); + + // Shared tab bar renderer + const renderTabs = () => ( +
+ {questions.map((q, idx) => { + const isAnswered = getAnswerForQuestion(idx) !== undefined; + const isActive = idx === currentQuestionIndex; + return ( + + ); + })} + +
+ ); + + // Container style + const containerStyle = { + backgroundColor: 'var(--app-input-secondary-background)', + borderColor: 'var(--app-input-border)', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + }; + + // Render submit tab + if (isSubmitTab) { + return ( +
+ {renderTabs()} + + {/* Show selected answers */} +
+
+ Your answers: +
+ {questions.map((q, idx) => { + const answer = getAnswerForQuestion(idx); + return ( +
+ {q.header}:{' '} + {answer ? ( + + {answer} + + ) : ( + (not answered) + )} +
+ ); + })} +
+ + {/* Submit/Cancel buttons */} +
+ + +
+
+ ); + } + + // Render question tab + return ( +
+ {/* Tabs for multiple questions */} + {hasMultipleQuestions && renderTabs()} + + {/* Question */} +
+ {!hasMultipleQuestions && ( +
+ + {currentQuestion!.header} + +
+ )} +
+ {currentQuestion!.question} +
+
+ + {/* Options */} +
+ {currentQuestion!.options.map((opt, index) => { + const isSelected = + !isMultiSelect && currentAnswer.selectedOption === opt.label; + const isMultiChecked = + isMultiSelect && + currentAnswer.multiSelectedOptions?.includes(opt.label); + + return ( +
+ + {opt.description && ( +
+ {opt.description} +
+ )} +
+ ); + })} + + {/* Custom input ("Other") */} +
+ {showCustomInput ? ( +
+ {isMultiSelect && ( + { + const answerState = answers[currentQuestionIndex] || {}; + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + customInputChecked: !answerState.customInputChecked, + }, + }); + }} + > + {currentAnswer.customInputChecked ? '☑' : '☐'} + + )} + handleCustomInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleCustomInputSubmit(); + } + }} + placeholder="Type your answer..." + /> +
+ ) : ( + + )} +
+
+ + {/* Action buttons */} +
+ {isMultiSelect && ( + + )} + +
+
+ ); +}; 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/index.ts b/packages/webui/src/index.ts index 330c0cb6d..39e0a8cbf 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -86,6 +86,12 @@ export type { CollapsibleFileContentProps, ContentSegment, } from './components/messages/CollapsibleFileContent'; +export { AskUserQuestionDialog } from './components/messages/AskUserQuestionDialog'; +export type { + AskUserQuestionDialogProps, + Question, + QuestionOption, +} from './components/messages/AskUserQuestionDialog'; // ChatViewer - standalone chat display component export { 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/copy_bundle_assets.js b/scripts/copy_bundle_assets.js index 1b2b5099b..83ca91f3f 100644 --- a/scripts/copy_bundle_assets.js +++ b/scripts/copy_bundle_assets.js @@ -51,6 +51,27 @@ if (existsSync(coreVendorDir)) { console.warn(`Warning: Vendor directory not found at ${coreVendorDir}`); } +// Copy bundled skills (e.g. /review) so they are available at runtime. +// In the esbuild bundle, import.meta.url resolves to dist/cli.js, so +// SkillManager looks for bundled skills at dist/bundled/. +const bundledSkillsDir = join( + root, + 'packages', + 'core', + 'src', + 'skills', + 'bundled', +); +if (existsSync(bundledSkillsDir)) { + const destBundledDir = join(distDir, 'bundled'); + copyRecursiveSync(bundledSkillsDir, destBundledDir); + console.log('Copied bundled skills to dist/bundled/'); +} else { + console.warn( + `Warning: Bundled skills directory not found at ${bundledSkillsDir}`, + ); +} + console.log('\n✅ All bundle assets copied to dist/'); /** diff --git a/scripts/generate-settings-schema.ts b/scripts/generate-settings-schema.ts index 9d13e8166..903131219 100644 --- a/scripts/generate-settings-schema.ts +++ b/scripts/generate-settings-schema.ts @@ -21,6 +21,7 @@ import { fileURLToPath } from 'node:url'; import type { SettingDefinition, + SettingItemDefinition, SettingsSchema, } from '../packages/cli/src/config/settingsSchema.js'; import { getSettingsSchema } from '../packages/cli/src/config/settingsSchema.js'; @@ -37,6 +38,57 @@ interface JsonSchemaProperty { enum?: (string | number)[]; default?: unknown; additionalProperties?: boolean | JsonSchemaProperty; + required?: string[]; +} + +function convertItemDefinitionToJsonSchema( + itemDef: SettingItemDefinition, +): JsonSchemaProperty { + const schema: JsonSchemaProperty = {}; + + if (itemDef.description) { + schema.description = itemDef.description; + } + + schema.type = itemDef.type; + + if (itemDef.enum) { + schema.enum = itemDef.enum; + } + + if (itemDef.type === 'object' && itemDef.properties) { + schema.properties = {}; + const requiredFields: string[] = []; + + for (const [key, childDef] of Object.entries(itemDef.properties)) { + const childSchema = convertItemDefinitionToJsonSchema(childDef); + schema.properties[key] = childSchema; + if (childDef.required) { + requiredFields.push(key); + } + } + + if (requiredFields.length > 0) { + schema.required = requiredFields; + } + } + + if (itemDef.type === 'object' && itemDef.additionalProperties !== undefined) { + if (typeof itemDef.additionalProperties === 'boolean') { + schema.additionalProperties = itemDef.additionalProperties; + } else { + schema.additionalProperties = convertItemDefinitionToJsonSchema( + itemDef.additionalProperties, + ); + } + } + + if (itemDef.items) { + schema.type = 'array'; + schema.items = convertItemDefinitionToJsonSchema(itemDef.items); + } + + return schema; } function convertSettingToJsonSchema( @@ -60,7 +112,11 @@ function convertSettingToJsonSchema( break; case 'array': schema.type = 'array'; - schema.items = { type: 'string' }; + if (setting.items) { + schema.items = convertItemDefinitionToJsonSchema(setting.items); + } else { + schema.items = { type: 'string' }; + } break; case 'enum': if (setting.options && setting.options.length > 0) { diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index fcc9d9ac3..fe5263e0e 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -134,18 +134,20 @@ call :CheckCommandExists qwen if %ERRORLEVEL% EQU 0 ( echo SUCCESS: Qwen Code is available as 'qwen' command. call qwen --version + echo. + echo INFO: Starting Qwen Code... + echo. + call qwen ) else ( echo WARNING: Qwen Code may not be in PATH. Please check your npm global bin directory. + echo. + echo =========================================== + echo SUCCESS: Installation completed! + echo The source information is stored in %USERPROFILE%\.qwen\source.json + echo. + echo =========================================== ) -echo. -echo =========================================== -echo SUCCESS: Installation completed! -echo The source information is stored in %USERPROFILE%\.qwen\source.json -echo Tips: Please restart your terminal and run: qwen -echo. -echo =========================================== - endlocal exit /b 0 diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 6f67e469b..ce6d46c26 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -553,14 +553,18 @@ main() { if command_exists qwen; then log_success "Qwen Code is ready to use!" echo "" - log_info "Tips: Please restart your terminal and run: qwen" + echo "You can now run: qwen" echo "" + # Auto-start qwen + log_info "Starting Qwen Code..." + echo "" + exec qwen else - log_warning "Tips: To start using Qwen Code, please run:" + log_warning "Qwen Code command not found in current session" echo "" - local PROFILE_FILE - PROFILE_FILE=$(get_shell_profile) - echo " source ${PROFILE_FILE}" + echo "To use Qwen Code immediately without restarting your terminal," + echo "run the following command in your current shell:" + echo " eval \$(${0} --print-env)" echo "" log_info "Or simply restart your terminal, then run: qwen" fi diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 3ae9d3e08..497fdaff9 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -13,7 +13,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { execSync } from 'node:child_process'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -179,4 +178,17 @@ fs.writeFileSync( console.log('\n✅ Package prepared for publishing at dist/'); console.log('\nPackage structure:'); -execSync('ls -lh dist/', { stdio: 'inherit', cwd: rootDir }); +// Use Node.js to list directory contents (cross-platform) +const distFiles = fs.readdirSync(distDir); +for (const file of distFiles) { + const filePath = path.join(distDir, file); + const stats = fs.statSync(filePath); + const size = stats.isDirectory() ? '' : formatBytes(stats.size); + console.log(` ${size.padEnd(12)} ${file}`); +} + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +}