diff --git a/.gitignore b/.gitignore index 115964554..493296158 100644 --- a/.gitignore +++ b/.gitignore @@ -55,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/OPTIMIZATION_PLAN.md b/OPTIMIZATION_PLAN.md deleted file mode 100644 index b56e14ea9..000000000 --- a/OPTIMIZATION_PLAN.md +++ /dev/null @@ -1,962 +0,0 @@ -# Qwen Code 0.12.0 MCP & Extension Management 优化方案 - -## 问题梳理与解决方案 - -根据钉钉文档《0.12.0 体验反馈》中提出的问题,本文件详细分析了每个问题的根本原因,并提供具体的解决方案和代码修改建议。 - ---- - -## 文档问题概览 - -本文档共包含 **6 个问题** (3 个 P1 + 3 个 P2),分为两个主要部分: - -### Part 1: MCP Management TUI (5 个问题) - -- **P1 级别**: 3 个问题 -- **P2 级别**: 2 个细节问题 (共 10 个小点) - -### Part 2: Extension Management TUI (1 个问题) - -- **P2 级别**: 1 个命令报错问题 - -## 问题 1: 【P1】Auth 属于 manage 的一部分,应该加到 manage 里 - -### 问题描述 - -- **现状**: 当前 MCP Management Dialog 中**没有 OAuth 认证功能**,用户必须使用 `/mcp auth ` 命令进行认证 -- **问题**: - - Auth 功能独立于 Manage Dialog 之外,用户体验割裂 - - 需要记住命令行才能认证,不够直观 - - MCP 管理对话框中只能查看服务器状态和工具,无法进行认证操作 -- **文档建议**: Auth 应该整合到 manage dialog 中,在 UI 界面内完成所有 MCP 管理操作 - -### 根本原因分析 - -#### 当前实现 - -```typescript -// packages/cli/src/ui/commands/mcpCommand.ts -const mcpCommand: SlashCommand = { - name: 'mcp', - subCommands: [manageCommand, authCommand], // auth 作为独立子命令存在 - action: async (): Promise => ({ - type: 'dialog', - dialog: 'mcp', // 默认打开管理对话框 - }), -}; -``` - -#### MCP Management Dialog 现状 - -```typescript -// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx -// 当前的步骤类型 -export const MCP_MANAGEMENT_STEPS = { - SERVER_LIST: 'server-list', - SERVER_DETAIL: 'server-detail', - DISABLE_SCOPE_SELECT: 'disable-scope-select', - TOOL_LIST: 'tool-list', - TOOL_DETAIL: 'tool-detail', -} as const; - -// ServerDetailStep 中的操作选项 -const actions = [ - { label: 'View tools', value: 'view-tools' }, - { label: 'Reconnect', value: 'reconnect' }, - { label: 'Enable/Disable', value: 'toggle-disable' }, - // ❌ 缺少 'Authenticate' 选项 -]; -``` - -#### 问题分析 - -1. **UI 层面**: MCP Management Dialog 中没有认证相关的 UI 组件和操作入口 -2. **代码层面**: OAuth 认证逻辑只在命令行 handler 中实现 (`mcpCommand.ts` 的 `authCommand`) -3. **体验层面**: 用户需要在 TUI 和 CLI 之间切换,无法在一个界面内完成所有操作 - -### 解决方案 - -#### 方案 A: 在 MCP Dialog 中集成完整的 OAuth 认证功能 (强烈推荐) - -**核心思路**: - -- 在 Server Detail 页面添加 "Authenticate" 操作选项 -- 复用现有的 `MCPOAuthProvider` 和 OAuth 流程 -- 通过事件系统显示认证过程中的提示信息 - -**实现步骤**: - -##### 1. 扩展 MCP_MANAGEMENT_STEPS - -```typescript -// packages/cli/src/ui/components/mcp/types.ts -export const MCP_MANAGEMENT_STEPS = { - SERVER_LIST: 'server-list', - SERVER_DETAIL: 'server-detail', - DISABLE_SCOPE_SELECT: 'disable-scope-select', - TOOL_LIST: 'tool-list', - TOOL_DETAIL: 'tool-detail', - AUTHENTICATE: 'authenticate', // 新增:认证步骤 -} as const; -``` - -##### 2. 在 ServerDetailStep 中添加认证选项 - -```typescript -// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx -type ServerAction = - | 'view-tools' - | 'reconnect' - | 'toggle-disable' - | 'authenticate'; // 新增 - -const actions = useMemo(() => { - const result: Array<{ label: string; value: ServerAction }> = []; - - result.push({ label: t('View Tools'), value: 'view-tools' }); - - if (!server.isDisabled && server.status === MCPServerStatus.DISCONNECTED) { - result.push({ label: t('Reconnect'), value: 'reconnect' }); - } - - // 新增:显示认证选项的场景 - const needsAuth = - server.config.oauth?.enabled || - server.status === MCPServerStatus.DISCONNECTED || - server.errorMessage?.includes('401') || - server.errorMessage?.includes('OAuth'); - - if (needsAuth) { - result.push({ - label: t('Authenticate'), - value: 'authenticate', - icon: '🔐', // 可选:添加图标增强视觉提示 - }); - } - - result.push({ - label: server.isDisabled ? t('Enable') : t('Disable'), - value: 'toggle-disable', - }); - - return result; -}, [server]); -``` - -##### 3. 在 MCPManagementDialog 中实现认证逻辑 - -```typescript -// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx -import { MCPOAuthProvider, MCPOAuthConfig } from '@qwen-code/qwen-code-core'; -import { appEvents, AppEvent } from '../../utils/events.js'; - -// 新增:处理认证 -const handleAuthenticate = useCallback(async () => { - if (!config || !selectedServer) return; - - try { - setIsLoading(true); - - // 显示开始认证提示 - context.ui.addItem( - { - type: 'info', - text: t("Starting OAuth authentication for '{{name}}'...", { - name: selectedServer.name, - }), - }, - Date.now() - ); - - // 监听并显示认证过程中的消息 - const displayListener = (message: string) => { - context.ui.addItem({ type: 'info', text: message }, Date.now()); - }; - appEvents.on(AppEvent.OauthDisplayMessage, displayListener); - - // 准备 OAuth 配置 - let oauthConfig: MCPOAuthConfig = selectedServer.config.oauth || { enabled: false }; - - // 执行认证 - const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); - await authProvider.authenticate( - selectedServer.name, - oauthConfig, - selectedServer.config.httpUrl || selectedServer.config.url - ); - - // 认证成功 - context.ui.addItem( - { - type: 'success', - text: t("✓ Authentication successful for '{{name}}'", { - name: selectedServer.name, - }), - }, - Date.now() - ); - - // 移除消息监听器 - appEvents.off(AppEvent.OauthDisplayMessage, displayListener); - - // 重新加载服务器数据以更新状态 - await reloadServers(); - - // 返回上一级 - handleNavigateBack(); - } catch (error) { - debugLogger.error( - `Authentication failed for '${selectedServer.name}':`, - error - ); - context.ui.addItem( - { - type: 'error', - text: t("✗ Authentication failed: {{error}}", { - error: getErrorMessage(error), - }), - }, - Date.now() - ); - } finally { - setIsLoading(false); - } -}, [config, selectedServer, reloadServers, handleNavigateBack, context]); - -// 在 renderStepContent 中添加认证步骤的处理 -case MCP_MANAGEMENT_STEPS.AUTHENTICATE: - // 可以直接执行认证,或者显示一个确认对话框 - void handleAuthenticate(); - return {t('Authenticating...')}; -``` - -##### 4. 更新 i18n 翻译文件 - -```javascript -// packages/cli/src/i18n/locales/en.js -{ - 'Authenticate': 'Authenticate', - 'Authenticate with OAuth': 'Authenticate with OAuth', - "Starting OAuth authentication for '{{name}}'...": "Starting OAuth authentication for '{{name}}'...", - "✓ Authentication successful for '{{name}}'": "✓ Authentication successful for '{{name}}'", - "✗ Authentication failed: {{error}}": "✗ Authentication failed: {{error}}", -} -``` - -**优点**: - -- ✅ 用户体验统一,所有 MCP 管理操作在一个界面完成 -- ✅ 复用现有 OAuth 认证逻辑,开发成本低 -- ✅ 直观的视觉反馈,认证过程透明 -- ✅ 符合现代 UI/UX 设计原则 - -**缺点**: - -- ⚠️ 需要处理浏览器跳转和回调 (已有完善实现,风险低) - -#### 方案 B: 保留命令行但改进引导提示 - -如果某些场景下确实需要命令行认证 (如自动化脚本),可以: - -- 保留 `/mcp auth` 命令 -- 在 Dialog 中提供快速复制的命令模板 -- 添加"Copy Auth Command"按钮 - -但这会增加复杂性,不如方案 A 简洁。 - ---- - -## 问题 2: 【P1】一些异常状态 - -### 2.1 禁用之后还可以点击"查看工具",点进去是空的 - -#### 问题描述 - -- **现象**: MCP Server 被禁用后,仍然可以在 UI 中看到"查看工具"选项,点击进入后显示空列表 -- **期望**: 禁用后的服务器不应该显示"查看工具"选项,或者应该给出明确的提示信息 - -#### 根本原因分析 - -当前代码逻辑: - -```typescript -// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx -const actions = useMemo(() => { - const result: Array<{ label: string; value: ServerAction }> = []; - - // 无论服务器是否禁用,都添加"查看工具"选项 - result.push({ label: t('View Tools'), value: 'view-tools' }); - - if (server.status === 'disconnected') { - result.push({ label: t('Reconnect'), value: 'reconnect' }); - } - - result.push({ - label: server.isDisabled ? t('Enable') : t('Disable'), - value: 'toggle-disable', - }); - - return result; -}, [server]); -``` - -问题在于: - -1. 没有根据 `server.isDisabled` 状态过滤操作选项 -2. 禁用服务器的工具列表获取逻辑可能存在问题 -3. 缺少用户友好的提示信息 - -#### 解决方案 - -**方案 A: 禁用时隐藏"查看工具"选项 (推荐)** - -**代码修改**: - -```typescript -// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx -const actions = useMemo(() => { - const result: Array<{ label: string; value: ServerAction }> = []; - - // 只在服务器启用且已连接时显示"查看工具"选项 - if (!server.isDisabled && server.status === MCPServerStatus.CONNECTED) { - result.push({ - label: t('View Tools'), - value: 'view-tools', - disabled: server.toolCount === 0, // 可选:工具数量为 0 时禁用 - }); - } - - // 禁用状态下显示提示信息 - if (server.isDisabled) { - result.push({ - label: t('Enable to view tools'), - value: 'toggle-disable', - }); - } else { - if (server.status === MCPServerStatus.DISCONNECTED) { - result.push({ label: t('Reconnect'), value: 'reconnect' }); - } - - result.push({ - label: t('Disable'), - value: 'toggle-disable', - }); - } - - return result; -}, [server]); -``` - -**同时修改 ToolListStep**: - -```typescript -// packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx -export const ToolListStep: React.FC = ({ - tools, - serverName, - onSelect, - onBack, -}) => { - // 添加禁用状态检查 - if (tools.length === 0) { - return ( - - - {t('No tools available for this server.')} - - {/* 添加提示:服务器可能被禁用 */} - - {t('Note: This server may be disabled. Please enable it in the server settings.')} - - - ); - } - // ... 其余代码保持不变 -}; -``` - -**方案 B: 显示友好提示并阻止导航** - -在 `MCPManagementDialog` 中添加拦截逻辑: - -```typescript -// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx -const handleViewTools = useCallback(() => { - if (!selectedServer) return; - - // 检查服务器是否禁用 - if (selectedServer.isDisabled) { - // 显示提示信息,不执行导航 - debugLogger.warn( - `Cannot view tools for disabled server '${selectedServer.name}'`, - ); - // 可选:在 UI 上显示临时消息 - return; - } - - // 检查是否有工具 - if (selectedServer.toolCount === 0) { - debugLogger.info(`No tools available for server '${selectedServer.name}'`); - // 仍然可以进入查看,但会显示空状态提示 - } - - handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST); -}, [selectedServer, handleNavigateToStep]); -``` - -#### 推荐方案:方案 A + ToolListStep 的提示增强 - ---- - -### 2.2 禁用之后还能重新连接 - -#### 问题描述 - -- **现象**: MCP Server 被禁用后,仍然可以看到"重新连接"选项 -- **期望**: 禁用之后应该没有"重新连接"入口 -- **文档建议**: 禁用之后应该没有"重新连接"入口 - -#### 根本原因分析 - -当前代码逻辑: - -```typescript -// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx -if (server.status === 'disconnected') { - result.push({ label: t('Reconnect'), value: 'reconnect' }); -} -``` - -问题在于: - -1. 只检查了连接状态,没有检查禁用状态 -2. 禁用的服务器不应该允许重新连接操作 -3. 逻辑上矛盾:既然禁用了就不应该尝试连接 - -#### 解决方案 - -**代码修改**: - -```typescript -// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx -const actions = useMemo(() => { - const result: Array<{ label: string; value: ServerAction }> = []; - - // View Tools 选项 - if (!server.isDisabled && server.toolCount > 0) { - result.push({ label: t('View Tools'), value: 'view-tools' }); - } - - // Reconnect 选项:只在未禁用且断开连接时显示 - if (!server.isDisabled && server.status === MCPServerStatus.DISCONNECTED) { - result.push({ label: t('Reconnect'), value: 'reconnect' }); - } - - // Enable/Disable 选项 - result.push({ - label: server.isDisabled ? t('Enable Server') : t('Disable Server'), - value: 'toggle-disable', - }); - - return result; -}, [server]); -``` - -**同时在 ServerListStep 中添加视觉提示**: - -```typescript -// packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx -{server.isDisabled && ( - - {' '} - {t('(disabled - no connection possible)')} - -)} -``` - ---- - -### 问题 3: 【P1】禁用有个选择设置的 dialog,有点费解 - -#### 问题描述 - -- **现象**: 禁用服务器时会弹出一个对话框让用户选择禁用范围 (user/workspace) -- **问题**: 这个选择让用户体验困惑,特别是当 MCP server 在项目级配置时,在用户级别禁用就有点费解 -- **文档建议**: MCP server 在哪里,就在哪里禁用(如果 MCP server 在项目级,在用户级别禁用就有点费解) - -#### 根本原因分析 - -当前实现逻辑: - -```typescript -// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx -const handleSelectDisableScope = useCallback( - async (scope: 'user' | 'workspace') => { - // 允许用户在 user 或 workspace 层面禁用服务器 - // 即使服务器配置在 workspace 层面,也允许在 user 层面禁用 - }, - [config, selectedServer, handleNavigateBack, reloadServers], -); -``` - -问题在于: - -1. 用户可以跨 scope 禁用服务器,造成配置混乱 -2. 不符合"在哪里配置就在哪里管理"的直觉 -3. 增加了不必要的复杂性 - -#### 解决方案 - -**方案 A: 根据服务器来源自动确定禁用 scope (强烈推荐)** - -**核心思路**: - -- User 级别的配置 → 只能在 User 级别禁用 -- Workspace 级别的配置 → 只能在 Workspace 级别禁用 -- Extension 级别的配置 → 不允许禁用 (只能卸载扩展) - -**代码修改**: - -```typescript -// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx - -// 修改 handleDisable 函数 -const handleDisable = useCallback(() => { - if (!selectedServer) return; - - // 如果服务器已经被禁用,直接启用 - if (selectedServer.isDisabled) { - void handleEnableServer(); - return; - } - - // Extension 提供的服务器不允许禁用 - if (selectedServer.source === 'extension') { - debugLogger.warn( - `Cannot disable extension-provided server '${selectedServer.name}'`, - ); - // 显示提示信息 - return; - } - - // 根据服务器 scope 直接禁用,不再询问 - const scope = - selectedServer.scope === 'extension' - ? SettingScope.User - : selectedServer.scope === 'workspace' - ? SettingScope.Workspace - : SettingScope.User; - - // 直接执行禁用操作 - void executeDisable(scope); -}, [selectedServer, handleEnableServer]); - -// 新增执行禁用函数 -const executeDisable = useCallback( - async (scope: SettingScope) => { - if (!config || !selectedServer) return; - - try { - setIsLoading(true); - - const settings = loadSettings(); - const scopeSettings = settings.forScope(scope).settings; - const currentExcluded = scopeSettings.mcp?.excluded || []; - - if (!currentExcluded.includes(selectedServer.name)) { - const newExcluded = [...currentExcluded, selectedServer.name]; - settings.setValue(scope, 'mcp.excluded', newExcluded); - } - - const toolRegistry = config.getToolRegistry(); - if (toolRegistry) { - await toolRegistry.disableMcpServer(selectedServer.name); - } - - await reloadServers(); - handleNavigateBack(); - } catch (error) { - debugLogger.error( - `Error disabling server '${selectedServer.name}':`, - error, - ); - } finally { - setIsLoading(false); - } - }, - [config, selectedServer, reloadServers, handleNavigateBack], -); - -// 移除 DisableScopeSelectStep 相关的代码和导航逻辑 -``` - -**同时修改 UI 提示**: - -```typescript -// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx - - - {t('Scope:')} - - - - {t(server.scope)} - {server.source === 'extension' && ( - - {' '}({t('provided by {{name}}', { name: server.config.extensionName })}) - - )} - - - - -// 禁用按钮文本根据 scope 调整 -{server.isDisabled ? ( - {t('Enable (will remove from exclusion list)')} -) : server.source === 'extension' ? ( - {t('Cannot disable extension server')} -) : ( - {t('Disable (in {{scope}})', { scope: server.scope })} -)} -``` - -**方案 B: 保留选择但改进 UX** - -如果确实需要支持跨 scope 禁用 (考虑到某些特殊场景),至少应该: - -1. 明确显示当前服务器的配置位置 -2. 说明不同选择的影响 -3. 给出推荐选项 - -但这会增加复杂性,不如方案 A 简洁明了。 - -#### 推荐方案:方案 A - ---- - -## 实施计划 - ---- - -## 问题 6: 【P2】Extension Management - /extension manage 报错 - -### 问题描述 - -- **现象**: 使用 `/extension manage` 命令时直接报错 -- **期望**: 应该能正常打开 Extension Management Dialog - -### 根本原因分析 - -#### 可能的原因 - -1. **命令拼写错误** (最可能) - - 正确的命令是 `/extensions manage` (复数形式) - - 用户可能输入了 `/extension manage` (单数形式) -2. **ExtensionManager 未正确初始化** - - ```typescript - // packages/cli/src/ui/commands/extensionsCommand.ts#L103-108 - async function listAction(_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; // ❌ 这里直接返回,没有给用户任何提示 - } - // ... - } - ``` - -3. **环境限制** - - 某些环境下无法加载 ExtensionManager - - 沙箱模式可能限制扩展管理功能 - -#### 当前错误处理问题 - -- 如果 `getExtensionManager()` 返回 null 或不是 ExtensionManager 实例 -- 代码只是记录 debug 日志并静默返回 -- **用户看不到任何错误提示**,只会感到困惑 - -### 解决方案 - -#### 方案 A: 改进错误提示 (强烈推荐) - -**代码修改**: - -```typescript -// packages/cli/src/ui/commands/extensionsCommand.ts -async function listAction(context: CommandContext, _args: string) { - const extensionManager = context.services.config?.getExtensionManager(); - - if (!(extensionManager instanceof ExtensionManager)) { - debugLogger.error( - `Cannot ${context.invocation?.name} extensions in this environment`, - ); - - // ✅ 添加用户友好的错误提示 - context.ui.addItem( - { - type: MessageType.ERROR, - text: t( - 'Extension management is not available in the current environment. ' + - 'This feature may not be supported in your current mode or configuration.', - ), - }, - Date.now(), - ); - return; - } - - return { - type: 'dialog' as const, - dialog: 'extensions_manage' as const, - }; -} -``` - -#### 方案 B: 检查命令拼写并给出提示 - -在命令解析层面添加提示: - -```typescript -// packages/cli/src/ui/commands/registry.ts 或相关位置 -// 当检测到用户输入 '/extension'(单数) 时,给出提示 -if (commandName === 'extension') { - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Did you mean "/extensions"? (plural form)'), - }, - Date.now(), - ); -} -``` - -#### 方案 C: 同时支持单复数形式 - -为了用户体验,可以同时支持两种形式: - -```typescript -// packages/cli/src/ui/commands/extensionsCommand.ts -export const extensionsCommand: SlashCommand = { - name: 'extensions', // 主要命令 (复数) - aliases: ['extension'], // ✅ 添加别名 (单数) - get description() { - return t('Manage extensions'); - }, - kind: CommandKind.BUILT_IN, - subCommands: [ - manageExtensionsCommand, - installCommand, - exploreExtensionsCommand, - ], - action: async (context, args) => - manageExtensionsCommand.action!(context, args), -}; -``` - -**注意**: 需要检查 SlashCommand 类型定义是否支持 `aliases` 属性 - -### 推荐方案 - -**采用方案 A + 方案 C**: - -1. 改进错误提示,让用户知道发生了什么 -2. 如果可能,同时支持单复数形式 - ---- - -## 实施计划 - -### Phase 1: 修复异常状态问题 (优先级:高) - -1. **修复问题 2.1**: 禁用后可查看工具 - - 修改 `ServerDetailStep.tsx` 的操作列表逻辑 - - 修改 `ToolListStep.tsx` 添加友好提示 - - 预计工时:2 小时 - -2. **修复问题 2.2**: 禁用后可重新连接 - - 修改 `ServerDetailStep.tsx` 的 reconnect 选项条件 - - 预计工时:1 小时 - -### Phase 2: 在 Dialog 中集成 Auth 功能 (优先级:高) - -3. **修复问题 1**: MCP Dialog 集成 OAuth 认证 - - 扩展 `MCP_MANAGEMENT_STEPS` 添加认证步骤 - - 在 `ServerDetailStep` 中添加"Authenticate"选项 - - 在 `MCPManagementDialog` 中实现认证逻辑 - - 更新 i18n 翻译文件 - - 预计工时:4 小时 - -### Phase 3: 改进禁用体验 (优先级:中) - -4. **修复问题 3**: 简化禁用流程 - - 移除 `DisableScopeSelectStep` - - 实现自动 scope 判断逻辑 - - 更新 UI 提示 - - 预计工时:4 小时 - -### Phase 4: UI 细节优化 (优先级:中) - -5. **修复问题 4**: Dialog 1 细节优化 - - 移除重复的来源显示 - - 优化错误信息显示逻辑 (只在有错误时显示) - - 移除多余的空格 - - 优化布局紧凑度 - - 预计工时:3 小时 - -6. **修复问题 5**: Dialog 2 细节优化 - - 统一来源颜色与其他部分一致 - - 添加功能说明 tooltip - - 统一选中色为 theme.text.accent - - 优化工具标注文案 (如"destructive, open-world") - - 移除不必要的序号 - - 预计工时:3 小时 - -### Phase 5: Extension Management 修复 (优先级:低) - -7. **修复问题 6**: Extension 命令报错 - - 改进错误提示 (方案 A) - - 考虑支持单复数形式 (方案 C) - - 预计工时:2 小时 - -### Phase 6: 测试与验证 (优先级:高) - -8. **回归测试** - - 更新所有相关测试用例 - - 手动测试各个场景 - - 确保没有破坏性变更 - - 预计工时:4 小时 - -**总预计工时**: 约 23 小时 (约 3 个工作日) - ---- - -## 影响评估 - -### 兼容性影响 - -- **Breaking Changes**: 无 -- **Deprecation**: 无 -- **新功能**: MCP Dialog 集成 OAuth 认证功能 - -### 需要更新的文档 - -1. `docs/developers/tools/mcp-server.md` - 更新 MCP 管理对话框使用说明 -2. `docs/users/features/mcp-servers.md` - 更新用户指南 -3. `docs/users/features/extensions.md` - 更新扩展管理说明 -4. 内联帮助文本和 i18n 文件 - -### 需要更新的测试 - -1. `packages/cli/src/ui/commands/mcpCommand.test.ts` -2. `packages/cli/src/ui/components/mcp/MCPManagementDialog.test.tsx` -3. `packages/cli/src/ui/components/mcp/steps/ServerDetailStep.test.tsx` -4. `packages/cli/src/ui/commands/extensionsCommand.test.ts` -5. `packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx` - ---- - -## 验收标准 - -### 问题 1 验收标准 - -- [ ] MCP Management Dialog 中显示"Authenticate"选项 (针对需要认证的服务器) -- [ ] 点击认证后能正确启动 OAuth 流程 -- [ ] 认证过程中显示友好的提示信息 -- [ ] 认证成功后自动刷新服务器状态 -- [ ] 认证失败时显示明确的错误信息 -- [ ] 保留 `/mcp auth` 命令作为备选方案 (可选) - -### 问题 2.1 验收标准 - -- [ ] 禁用的服务器不显示"查看工具"选项,或显示友好提示 -- [ ] 工具列表为空时,明确提示原因 -- [ ] 用户不会看到空的工具列表页面 - -### 问题 2.2 验收标准 - -- [ ] 禁用的服务器不显示"重新连接"选项 -- [ ] UI 逻辑自洽,不会出现矛盾的操作选项 -- [ ] 禁用状态下只能看到"启用"选项 - -### 问题 3 验收标准 - -- [ ] 禁用操作一键完成,无需选择 scope -- [ ] 禁用范围自动匹配配置范围 -- [ ] UI 明确显示服务器的配置位置 -- [ ] 用户体验流畅,无困惑点 - -### 问题 4 验收标准 (Dialog 1 细节优化) - -- [ ] 移除重复的来源显示 -- [ ] 只在有错误时显示"运行 qwen --debug..."提示 -- [ ] 没有错误时不显示多余的空格 -- [ ] 布局更加紧凑,接近 claude code 的视觉效果 - -### 问题 5 验收标准 (Dialog 2 细节优化) - -- [ ] 来源颜色与其他部分统一 -- [ ] 添加清晰的功能说明 -- [ ] 统一选中色为 theme.text.accent -- [ ] 工具标注文案更易懂 (如改为"Destructive, Open-world") -- [ ] 移除列表项前的序号 (1、2、3...) - -### 问题 6 验收标准 (Extension Management) - -- [ ] `/extensions manage` 命令能正常工作 -- [ ] 如果 ExtensionManager 不可用,显示明确的错误提示 -- [ ] 考虑支持 `/extension`(单数) 作为别名 (可选) -- [ ] 测试不同环境下的行为 (普通模式、沙箱模式等) - ---- - -## 技术细节补充 - -### 关键文件清单 - -``` -# MCP Management -packages/cli/src/ui/commands/mcpCommand.ts -packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx -packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx -packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx -packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx -packages/cli/src/ui/components/mcp/types.ts -packages/core/src/tools/mcp-client-manager.ts -packages/core/src/config/config.ts - -# Extension Management -packages/cli/src/ui/commands/extensionsCommand.ts -packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx -packages/cli/src/ui/components/extensions/types.ts -packages/core/src/extension/extensionManager.ts -``` - -### 依赖关系 - -- MCP Management Dialog 依赖于 Config、ToolRegistry、PromptRegistry -- 禁用逻辑涉及 Settings 的多 scope 管理 -- 状态跟踪通过 `getMCPServerStatus` 和状态监听器实现 - -### 潜在风险点 - -1. **OAuth 认证流程**: 确保在 Dialog 中集成的认证功能不影响现有命令行认证 -2. **多 Scope 配置**: 确保自动 scope 判断不会误删其他 scope 的配置 -3. **Extension 集成**: 确保扩展提供的服务器正确处理 -4. **环境兼容性**: 确保 Extension Management 在不同环境下都能给出正确的错误提示 - ---- - -## 总结 - -本文档针对 0.12.0 版本体验反馈中提出的 **6 个问题** (3 个 P1 + 3 个 P2) 进行了详细分析,并提供了具体的解决方案。所有修改都遵循以下原则: - -1. **用户体验优先**: 简化操作流程,减少困惑 -2. **逻辑一致性**: 确保 UI 状态和行为逻辑自洽 -3. **向后兼容**: 避免破坏性变更 -4. **代码质量**: 简化代码结构,提高可维护性 -5. **错误友好**: 提供清晰、有帮助的错误信息 - -建议按优先级分阶段实施,确保每个问题都得到妥善解决。 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/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..bc56a437e 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"], 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/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/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 6834e60eb..92813beff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.6", "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", @@ -4538,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", @@ -14711,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", @@ -18800,7 +18784,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.6", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", @@ -18825,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", @@ -19458,7 +19441,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.12.1", + "version": "0.12.6", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22889,7 +22872,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.12.1", + "version": "0.12.6", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22901,7 +22884,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.12.1", + "version": "0.12.6", "license": "LICENSE", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", @@ -23149,7 +23132,7 @@ }, "packages/web-templates": { "name": "@qwen-code/web-templates", - "version": "0.12.1", + "version": "0.12.6", "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -23677,7 +23660,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.12.1", + "version": "0.12.6", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 001b2deda..76eb3450a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.6", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.6" }, "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 11fdb8d96..96a3f577b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.6", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.6" }, "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", @@ -59,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", diff --git a/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts index 628807fe2..a8683c7c5 100644 --- a/packages/cli/src/acp-integration/service/filesystem.test.ts +++ b/packages/cli/src/acp-integration/service/filesystem.test.ts @@ -13,22 +13,23 @@ 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' }), + readTextFile: vi.fn().mockResolvedValue(mockResponse), } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( @@ -38,78 +39,15 @@ 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', - 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 AgentSideConnection; - - 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 AgentSideConnection; - - 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 AgentSideConnection; - - 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: RESOURCE_NOT_FOUND_CODE, @@ -126,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', @@ -149,7 +89,9 @@ describe('AcpFileSystemService', () => { createFallback(), ); - await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({ + await expect( + svc.readTextFile({ path: '/some/file.txt' }), + ).rejects.toMatchObject({ code: INTERNAL_ERROR_CODE, message: 'Internal error', }); @@ -161,8 +103,12 @@ describe('AcpFileSystemService', () => { } 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( @@ -172,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 25ad296fb..201c86808 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -7,15 +7,38 @@ import type { AgentSideConnection, FileSystemCapability, + ReadTextFileRequest, + WriteTextFileRequest, + WriteTextFileResponse, } from '@agentclientprotocol/sdk'; import { RequestError } from '@agentclientprotocol/sdk'; import type { - FileReadResult, FileSystemService, + ReadTextFileResponse, } from '@qwen-code/qwen-code-core'; 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 connection: AgentSideConnection, @@ -24,82 +47,50 @@ export class AcpFileSystemService implements FileSystemService { 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.connection.readTextFile({ - path: filePath, + ...params, sessionId: this.sessionId, }); } catch (error) { - const errorCode = - error instanceof RequestError - ? error.code - : typeof error === 'object' && error !== null && 'code' in error - ? (error as { code?: unknown }).code - : undefined; + const errorCode = getErrorCode(error); if (errorCode === RESOURCE_NOT_FOUND_CODE) { - const err = new Error( - `File not found: ${filePath}`, - ) as NodeJS.ErrnoException; - err.code = 'ENOENT'; - err.errno = -2; - err.path = filePath; - throw err; + 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); } - const finalContent = options?.bom ? '\uFEFF' + content : content; + const finalContent = params._meta?.['bom'] + ? '\uFEFF' + params.content + : params.content; await this.connection.writeTextFile({ - path: filePath, + ...params, content: finalContent, sessionId: this.sessionId, }); - } - async detectFileBOM(filePath: string): Promise { - if (this.capabilities.readTextFile) { - try { - const response = await this.connection.readTextFile({ - path: filePath, - sessionId: this.sessionId, - limit: 1, - }); - return ( - response.content.length > 0 && - response.content.codePointAt(0) === 0xfeff - ); - } catch { - // Fall through to fallback if ACP read fails - } - } - 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/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 04b9c7292..1458ce177 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -90,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 @@ -143,10 +151,43 @@ export class Session implements SessionContext { } 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; 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.ts b/packages/cli/src/config/config.ts index 88153fe75..e0aaaa962 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, @@ -1013,7 +1012,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 +1025,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 +1040,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.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 663e36dbc..afc913f16 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', @@ -1233,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', @@ -1244,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, }, Notification: { type: 'array', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 9a007a68f..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 @@ -916,6 +959,8 @@ export default { Disable: 'Deaktivieren', Enable: 'Aktivieren', Authenticate: 'Authentifizieren', + 'Re-authenticate': 'Erneut authentifizieren', + 'Clear Authentication': 'Authentifizierung löschen', disabled: 'deaktiviert', 'Server:': 'Server:', Reconnect: 'Neu verbinden', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 768506c06..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': @@ -459,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', @@ -659,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.': @@ -811,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 @@ -843,6 +885,8 @@ export default { Enable: 'Enable', Disable: 'Disable', Authenticate: 'Authenticate', + 'Re-authenticate': 'Re-authenticate', + 'Clear Authentication': 'Clear Authentication', 'Server:': 'Server:', 'Command:': 'Command:', 'Working Directory:': 'Working Directory:', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 3a1bf21c6..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: '切断', @@ -655,6 +698,8 @@ export default { Disable: '無効化', Enable: '有効化', Authenticate: '認証', + 'Re-authenticate': '再認証', + 'Clear Authentication': '認証をクリア', disabled: '無効', 'Server:': 'サーバー:', Reconnect: '再接続', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 37efeda6f..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 @@ -922,6 +965,8 @@ export default { Disable: 'Desativar', Enable: 'Ativar', Authenticate: 'Autenticar', + 'Re-authenticate': 'Reautenticar', + 'Clear Authentication': 'Limpar autenticação', disabled: 'desativado', 'Server:': 'Servidor:', Reconnect: 'Reconectar', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index eaecb4228..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 — он может занимать несколько строк.', // ============================================================================ // Команды - Чат @@ -900,6 +943,8 @@ export default { Disable: 'Отключить', Enable: 'Включить', Authenticate: 'Аутентификация', + 'Re-authenticate': 'Повторная аутентификация', + 'Clear Authentication': 'Очистить аутентификацию', disabled: 'отключен', 'Server:': 'Сервер:', Reconnect: 'Переподключить', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index d6f6b2ead..9a06554ff 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -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': @@ -437,6 +438,7 @@ export default { '当前支持以下编辑器。请注意,某些编辑器无法在沙箱模式下使用。', 'Your preferred editor is:': '您的首选编辑器是:', 'Manage extensions': '管理扩展', + 'Manage installed extensions': '管理已安装的扩展', 'List active extensions': '列出活动扩展', 'Update extensions. Usage: update |--all': '更新扩展。用法:update |--all', @@ -623,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.': @@ -763,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 @@ -793,6 +835,8 @@ export default { Enable: '启用', Disable: '禁用', Authenticate: '认证', + 'Re-authenticate': '重新认证', + 'Clear Authentication': '清空认证', disabled: '已禁用', 'Server:': '服务器:', '(disabled)': '(已禁用)', 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/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/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 19db869ea..76eda2c07 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -211,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/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) */} = ({ 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/mcp/MCPManagementDialog.tsx b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx index ce84814a7..94910fd72 100644 --- a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -25,6 +25,7 @@ import { useConfig } from '../../contexts/ConfigContext.js'; import { getMCPServerStatus, DiscoveredMCPTool, + MCPOAuthTokenStorage, type MCPServerConfig, type AnyDeclarativeTool, type DiscoveredMCPPrompt, @@ -109,6 +110,16 @@ 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, @@ -118,6 +129,7 @@ export const MCPManagementDialog: React.FC = ({ invalidToolCount, promptCount: serverPrompts.length, isDisabled, + hasOAuthTokens, }); } @@ -249,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; @@ -537,6 +579,7 @@ export const MCPManagementDialog: React.FC = ({ onReconnect={handleReconnect} onDisable={handleDisable} onAuthenticate={handleAuthenticate} + onClearAuth={handleClearAuth} onBack={handleNavigateBack} /> ); @@ -569,10 +612,10 @@ export const MCPManagementDialog: React.FC = ({ return ( { + onBack={() => { + handleNavigateBack(); void reloadServers(); }} - onBack={handleNavigateBack} /> ); @@ -594,6 +637,7 @@ export const MCPManagementDialog: React.FC = ({ handleReconnect, handleDisable, handleAuthenticate, + handleClearAuth, handleNavigateBack, handleSelectTool, handleSelectDisableScope, diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx index e4d4e373a..6e0011a77 100644 --- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -16,13 +16,15 @@ import { 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, - onSuccess, onBack, }) => { const config = useConfig(); @@ -39,9 +41,12 @@ export const AuthenticateStep: React.FC = ({ setMessages([]); setErrorMessage(null); - // Listen for OAuth display messages (same as mcpCommand.ts) - const displayListener = (message: string) => { - setMessages((prev) => [...prev, message]); + // 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); @@ -83,6 +88,16 @@ export const AuthenticateStep: React.FC = ({ }), ]); 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 @@ -91,8 +106,12 @@ export const AuthenticateStep: React.FC = ({ await geminiClient.setTools(); } + setMessages((prev) => [ + ...prev, + t('Authentication complete. Returning to server details...'), + ]); + setAuthState('success'); - onSuccess?.(); } catch (error) { setErrorMessage(getErrorMessage(error)); setAuthState('error'); @@ -100,13 +119,22 @@ export const AuthenticateStep: React.FC = ({ isRunning.current = false; appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); } - }, [server, config, onSuccess]); + }, [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') { @@ -158,6 +186,11 @@ export const AuthenticateStep: React.FC = ({ {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 a4463476f..3718f5e87 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -24,7 +24,8 @@ type ServerAction = | 'view-tools' | 'reconnect' | 'toggle-disable' - | 'authenticate'; + | 'authenticate' + | 'clear-auth'; export const ServerDetailStep: React.FC = ({ server, @@ -32,6 +33,7 @@ export const ServerDetailStep: React.FC = ({ onReconnect, onDisable, onAuthenticate, + onClearAuth, onBack, }) => { const statusColor = server @@ -77,15 +79,24 @@ export const ServerDetailStep: React.FC = ({ value: 'toggle-disable', }); - // 待补充准确的认证判断方案,暂时全部开放 + // 已认证的服务器显示"重新认证",未认证的显示"认证" if (!server.isDisabled) { result.push({ key: 'authenticate', - label: t('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]); @@ -222,6 +233,9 @@ export const ServerDetailStep: React.FC = ({ case 'authenticate': onAuthenticate?.(); break; + case 'clear-auth': + onClearAuth?.(); + break; default: break; } diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts index 8812c5f12..82d9ab7ba 100644 --- a/packages/cli/src/ui/components/mcp/types.ts +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -48,6 +48,8 @@ export interface MCPServerDisplayInfo { errorMessage?: string; /** 是否被禁用(在排除列表中) */ isDisabled: boolean; + /** 是否存储有 OAuth 认证信息 */ + hasOAuthTokens?: boolean; } /** @@ -132,6 +134,8 @@ export interface ServerDetailStepProps { onDisable?: () => void; /** OAuth 认证回调 */ onAuthenticate?: () => void; + /** 清空认证信息回调 */ + onClearAuth?: () => void; /** 返回回调 */ onBack: () => void; } @@ -178,8 +182,6 @@ export interface ToolDetailStepProps { export interface AuthenticateStepProps { /** 服务器信息 */ server: MCPServerDisplayInfo | null; - /** 认证成功回调 */ - onSuccess?: () => void; /** 返回回调 */ onBack: () => void; } diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx index a88b1bb4a..2a19e9328 100644 --- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx @@ -174,33 +174,6 @@ describe('', () => { unmount(); }); - it('navigates down with arrow key and selects', async () => { - const onConfirm = vi.fn(); - const details = createConfirmationDetails(); - - const { stdin, unmount } = renderWithProviders( - , - ); - await wait(); - - // Navigate down to "Blue" - stdin.write('\u001B[B'); // Down arrow - await wait(); - - // Press Enter - stdin.write('\r'); - await wait(); - - expect(onConfirm).toHaveBeenCalledWith( - ToolConfirmationOutcome.ProceedOnce, - { answers: { 0: 'Blue' } }, - ); - unmount(); - }); - it('navigates with number keys', async () => { const onConfirm = vi.fn(); const details = createConfirmationDetails(); @@ -271,72 +244,9 @@ describe('', () => { expect(lastFrame()).toContain('[✓]'); unmount(); }); - - it('submits multi-select with Space to toggle then Enter to confirm', async () => { - const onConfirm = vi.fn(); - const details = createConfirmationDetails({ - questions: [createSingleQuestion({ multiSelect: true })], - }); - - const { stdin, unmount } = renderWithProviders( - , - ); - await wait(); - - // Space to toggle first option - stdin.write(' '); - await wait(); - - // Enter to confirm and submit - stdin.write('\r'); - await wait(); - - expect(onConfirm).toHaveBeenCalledWith( - ToolConfirmationOutcome.ProceedOnce, - { answers: { 0: 'Red' } }, - ); - unmount(); - }); }); describe('multiple questions', () => { - it('navigates between tabs with left/right arrows', async () => { - const onConfirm = vi.fn(); - const details = createConfirmationDetails({ - questions: [ - createSingleQuestion({ header: 'Q1' }), - createSingleQuestion({ - header: 'Q2', - question: 'Second question?', - }), - ], - }); - - const { stdin, lastFrame, unmount } = renderWithProviders( - , - ); - await wait(); - - // Navigate right to Q2 - stdin.write('\u001B[C'); // Right arrow - await wait(); - - expect(lastFrame()).toContain('Second question?'); - - // Navigate left back to Q1 - stdin.write('\u001B[D'); // Left arrow - await wait(); - - expect(lastFrame()).toContain('What is your favorite color?'); - unmount(); - }); - it('shows Submit tab for multiple questions', async () => { const onConfirm = vi.fn(); const details = createConfirmationDetails({ @@ -367,41 +277,6 @@ describe('', () => { unmount(); }); - it('cancels from Submit tab', async () => { - const onConfirm = vi.fn(); - const details = createConfirmationDetails({ - questions: [ - createSingleQuestion({ header: 'Q1' }), - createSingleQuestion({ header: 'Q2' }), - ], - }); - - const { stdin, unmount } = renderWithProviders( - , - ); - await wait(); - - // Navigate to submit tab - stdin.write('\u001B[C'); // Right - await wait(); - stdin.write('\u001B[C'); // Right - await wait(); - - // Navigate down to Cancel option - stdin.write('\u001B[B'); // Down - await wait(); - - // Press Enter - stdin.write('\r'); - await wait(); - - expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); - unmount(); - }); - it('shows unanswered questions as (not answered) in Submit tab', async () => { const onConfirm = vi.fn(); const details = createConfirmationDetails({ 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/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/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 11686bf2d..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'; @@ -311,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( diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index c4a5a6117..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([ [], @@ -2669,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 @@ -2721,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 }, ); }); @@ -2751,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 1d0851501..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,7 +1110,7 @@ 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. @@ -1127,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; @@ -1142,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( @@ -1159,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(); @@ -1180,6 +1187,10 @@ export const useGeminiStream = ( setThought(null); } + if (submitType === SendMessageType.Retry) { + logUserRetry(config, new UserRetryEvent(prompt_id)); + } + setIsResponding(true); setInitError(null); @@ -1188,7 +1199,7 @@ export const useGeminiStream = ( finalQueryToSend, abortSignal, prompt_id!, - options, + { type: submitType }, ); const processingStatus = await processGeminiStreamEvents( @@ -1276,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). @@ -1301,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) => { @@ -1463,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/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 daa01de83..e00474aa1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.12.1", + "version": "0.12.6", "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 eedf5a8ec..76f028472 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1111,10 +1111,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 6a5ea0de1..32061dd21 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -37,7 +37,6 @@ import { type FileSystemService, StandardFileSystemService, type FileEncodingType, - FileEncoding, } from '../services/fileSystemService.js'; import { GitService } from '../services/gitService.js'; @@ -102,6 +101,7 @@ import { fireNotificationHook } from '../core/toolHookTriggers.js'; // 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'; @@ -200,10 +200,6 @@ export interface ChatCompressionSettings { contextPercentageThreshold?: number; } -export interface SummarizeToolOutputSettings { - tokenBudget?: number; -} - export interface TelemetrySettings { enabled?: boolean; target?: TelemetryTarget; @@ -344,7 +340,6 @@ export interface ConfigParameters { allowedMcpServers?: string[]; excludedMcpServers?: string[]; noBrowser?: boolean; - summarizeToolOutput?: Record; folderTrustFeature?: boolean; folderTrust?: boolean; ideMode?: boolean; @@ -380,7 +375,6 @@ export interface ConfigParameters { skipLoopDetection?: boolean; truncateToolOutputThreshold?: number; truncateToolOutputLines?: number; - enableToolOutputTruncation?: boolean; eventEmitter?: EventEmitter; output?: OutputSettings; inputFormat?: InputFormat; @@ -503,9 +497,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; @@ -535,10 +526,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; @@ -619,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; @@ -642,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, @@ -655,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); @@ -1687,12 +1676,6 @@ export class Config { return this.getNoBrowser() || !shouldAttemptBrowserLaunch(); } - getSummarizeToolOutputConfig(): - | Record - | undefined { - return this.summarizeToolOutput; - } - // Web search provider configuration getWebSearchConfig() { return this.webSearch; @@ -1753,7 +1736,7 @@ export class Config { * Get the default file encoding for new files. * @returns FileEncodingType */ - getDefaultFileEncoding(): FileEncodingType { + getDefaultFileEncoding(): FileEncodingType | undefined { return this.defaultFileEncoding; } @@ -1821,15 +1804,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; } @@ -1837,7 +1813,7 @@ export class Config { } getTruncateToolOutputLines(): number { - if (!this.enableToolOutputTruncation || this.truncateToolOutputLines <= 0) { + if (this.truncateToolOutputLines <= 0) { return Number.POSITIVE_INFINITY; } 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 b562cad9e..f7147acd1 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, @@ -1558,7 +1558,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 ); @@ -2311,6 +2311,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', () => { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 5c7cfb2a8..a7d47027d 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; @@ -414,13 +429,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 +483,7 @@ export class GeminiClient { } } - if (!options?.isContinuation) { + if (messageType === SendMessageType.UserQuery) { this.loopDetector.reset(prompt_id); this.lastPromptId = prompt_id; @@ -472,14 +493,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 +568,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 +661,7 @@ export class GeminiClient { continueRequest, signal, prompt_id, - { isContinuation: true }, + { type: SendMessageType.Hook }, boundedTurns - 1, ); } diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index ea14948a9..dfffc1224 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -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,8 +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'; import { MessageBusType } from '../confirmation-bus/types.js'; import type { HookExecutionResponse } from '../confirmation-bus/types.js'; import { type NotificationType } from '../hooks/types.js'; @@ -2332,227 +2329,6 @@ describe('CoreToolScheduler Sequential Execution', () => { }); }); -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 threshold', async () => { - const content = 'Short content'; - const callId = 'test-call-id'; - const projectTempDir = '/tmp'; - - const result = await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, - ); - - expect(result).toEqual({ content }); - expect(mockWriteFile).not.toHaveBeenCalled(); - }); - - 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'; - - mockWriteFile.mockResolvedValue(undefined); - - const result = await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, - ); - - expect(result.outputFile).toBe( - path.join(projectTempDir, `${callId}.output`), - ); - expect(mockWriteFile).toHaveBeenCalledWith( - path.join(projectTempDir, `${callId}.output`), - content, - ); - - // 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 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, - ); - - 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 call ID', async () => { - const content = 'a'.repeat(200_000); - const callId = 'unique-call-123'; - const projectTempDir = '/custom/temp/dir'; - 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 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, - ); - - 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 callId to prevent path traversal', async () => { - const content = 'a'.repeat(200_000); - const callId = '../../../../../etc/passwd'; - const projectTempDir = '/tmp/safe_dir'; - 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 expectedFileContent = wrappedLines.join('\n'); - - await truncateAndSaveToFile( - content, - callId, - projectTempDir, - THRESHOLD, - TRUNCATE_LINES, - ); - - const expectedPath = path.join(projectTempDir, 'passwd.output'); - expect(mockWriteFile).toHaveBeenCalledWith( - expectedPath, - expectedFileContent, - ); - }); -}); - describe('CoreToolScheduler plan mode with ask_user_question', () => { function createAskUserQuestionMockTool() { let wasAnswered = false; diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index a6c5660d3..911e1f5ec 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -36,12 +36,8 @@ import { ToolConfirmationOutcome, ApprovalMode, logToolCall, - ReadFileTool, ToolErrorType, ToolCallEvent, - ShellTool, - logToolOutputTruncated, - ToolOutputTruncatedEvent, InputFormat, Kind, SkillTool, @@ -60,8 +56,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'; @@ -317,67 +311,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; @@ -520,6 +453,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; @@ -531,6 +465,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 @@ -1358,43 +1299,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, - }), - ); - } - } // PostToolUse Hook if (hooksEnabled && messageBus) { @@ -1441,7 +1348,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 44f86b4f2..866370837 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -104,7 +104,6 @@ describe('executeToolCall', () => { callId: 'call1', error: undefined, errorType: undefined, - outputFile: undefined, resultDisplay: 'Success!', contentLength: typeof toolResult.llmContent === 'string' @@ -309,7 +308,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/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/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/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 4fe830e45..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', ]); 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 aa955cb74..0e9c4fef7 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/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 d21916143..272d3001d 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -516,6 +516,118 @@ Skill 3 content`); expect(baseDir).toBe(path.join('/home/user', '.qwen', '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 05eabdd5a..b6636a627 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 { @@ -39,8 +40,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. @@ -89,7 +96,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; @@ -102,7 +109,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( @@ -110,7 +117,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})`, @@ -133,7 +140,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 @@ -164,7 +171,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`); @@ -175,10 +182,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; } /** @@ -226,7 +242,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) { @@ -415,16 +431,24 @@ export class SkillManager { * @returns Absolute directory path */ getSkillsBaseDir(level: SkillLevel): string { - const baseDir = - level === 'project' - ? path.join( - this.config.getProjectRoot(), - QWEN_CONFIG_DIR, - SKILLS_CONFIG_DIR, - ) - : path.join(os.homedir(), QWEN_CONFIG_DIR, SKILLS_CONFIG_DIR); - - return baseDir; + switch (level) { + case 'project': + return path.join( + this.config.getProjectRoot(), + QWEN_CONFIG_DIR, + SKILLS_CONFIG_DIR, + ); + case 'user': + return path.join(os.homedir(), QWEN_CONFIG_DIR, 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 +485,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; + } + const baseDir = this.getSkillsBaseDir(level); debugLogger.debug(`Loading ${level} level skills from: ${baseDir}`); const skills = await this.loadSkillsFromDir(baseDir, level); @@ -580,6 +618,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/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/ls.test.ts b/packages/core/src/tools/ls.test.ts index 39a6b7b31..da6273eb1 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: { getUserSkillsDir: () => 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 b8edbe163..877a1274b 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 ec07a6995..f6f140afc 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 d03509451..e9aa4f850 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: { getUserSkillsDir: 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 01a9ac5cf..1de48b599 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-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 649b0cb4f..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 { 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/test-utils/package.json b/packages/test-utils/package.json index b9ac81d5b..24ee088d1 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.1", + "version": "0.12.6", "private": true, "main": "src/index.ts", "license": "Apache-2.0", 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 179f345e0..5e82f608b 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.1", + "version": "0.12.6", "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" } ] }, diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 373ba1298..1159dfcbc 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" + ] } }, "Notification": { 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/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 8c4994d14..5d263b618 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -8,6 +8,7 @@ import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, + RequestError, } from '@agentclientprotocol/sdk'; import type { Client, @@ -37,6 +38,7 @@ import { spawn } from 'child_process'; 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 @@ -163,7 +165,7 @@ export class AcpConnection { const stream = ndJsonStream(stdin, stdout); - // Build the SDK Client implementation that bridges to our callbacks + // 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( @@ -266,13 +268,17 @@ export class AcpConnection { async readTextFile( params: ReadTextFileRequest, ): Promise { - const result = await self.fileHandler.handleReadTextFile({ - path: params.path, - sessionId: params.sessionId, - line: params.line ?? null, - limit: params.limit ?? null, - }); - return { content: result.content }; + 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( @@ -334,6 +340,22 @@ export class AcpConnection { return this.sdkConnection; } + private mapReadTextFileError(error: unknown, filePath: string): unknown { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as { code?: unknown }).code + : undefined; + + if (errorCode === 'ENOENT') { + throw new RequestError( + ACP_ERROR_CODES.RESOURCE_NOT_FOUND, + `File not found: ${filePath}`, + ); + } + + return error; + } + private resolvePermissionOptionId( request: RequestPermissionRequest, preferredOptionId?: string, diff --git a/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts b/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts index fa87c9ab0..e5c68da8e 100644 --- a/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts @@ -5,28 +5,91 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AcpFileHandler } from './acpFileHandler.js'; -import { promises as fs } from 'fs'; -vi.mock('fs', () => ({ - promises: { - readFile: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), +// 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 () => { - vi.mocked(fs.readFile).mockResolvedValue('line1\nline2\nline3\n'); + mockGetText.mockReturnValue('line1\nline2\nline3\n'); const result = await handler.handleReadTextFile({ path: '/test/file.txt', @@ -39,9 +102,7 @@ describe('AcpFileHandler', () => { }); it('uses 1-based line indexing (ACP spec)', async () => { - vi.mocked(fs.readFile).mockResolvedValue( - 'line1\nline2\nline3\nline4\nline5', - ); + mockGetText.mockReturnValue('line1\nline2\nline3\nline4\nline5'); const result = await handler.handleReadTextFile({ path: '/test/file.txt', @@ -54,7 +115,7 @@ describe('AcpFileHandler', () => { }); it('treats line=1 as first line', async () => { - vi.mocked(fs.readFile).mockResolvedValue('first\nsecond\nthird'); + mockGetText.mockReturnValue('first\nsecond\nthird'); const result = await handler.handleReadTextFile({ path: '/test/file.txt', @@ -67,7 +128,7 @@ describe('AcpFileHandler', () => { }); it('defaults to line=1 when line is null but limit is set', async () => { - vi.mocked(fs.readFile).mockResolvedValue('a\nb\nc\nd'); + mockGetText.mockReturnValue('a\nb\nc\nd'); const result = await handler.handleReadTextFile({ path: '/test/file.txt', @@ -80,7 +141,7 @@ describe('AcpFileHandler', () => { }); it('clamps negative line values to 0', async () => { - vi.mocked(fs.readFile).mockResolvedValue('a\nb\nc'); + mockGetText.mockReturnValue('a\nb\nc'); const result = await handler.handleReadTextFile({ path: '/test/file.txt', @@ -95,7 +156,7 @@ describe('AcpFileHandler', () => { it('propagates ENOENT errors', async () => { const err = new Error('ENOENT') as NodeJS.ErrnoException; err.code = 'ENOENT'; - vi.mocked(fs.readFile).mockRejectedValue(err); + mockOpenTextDocument.mockRejectedValue(err); await expect( handler.handleReadTextFile({ @@ -106,12 +167,30 @@ describe('AcpFileHandler', () => { }), ).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 directories and writes file', async () => { - vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.writeFile).mockResolvedValue(undefined); + 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', @@ -120,11 +199,25 @@ describe('AcpFileHandler', () => { }); expect(result).toBeNull(); - expect(fs.mkdir).toHaveBeenCalledWith('/test/dir', { recursive: true }); - expect(fs.writeFile).toHaveBeenCalledWith( - '/test/dir/file.txt', - 'hello', - 'utf-8', + 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 e41240788..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,9 +44,14 @@ 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. @@ -64,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}`); @@ -97,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/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 4fb044a73..c5a0920d7 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -36,10 +36,41 @@ import { 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 * @@ -413,14 +444,7 @@ export class QwenAgentManager { console.log('[QwenAgentManager] ACP session list response:', response); const res: unknown = response; - let items: Array> = []; - - if (res && typeof res === 'object' && 'sessions' in res) { - const sessionsValue = (res as { sessions?: unknown }).sessions; - items = Array.isArray(sessionsValue) - ? (sessionsValue as Array>) - : []; - } + const items = extractSessionListItems(res); console.log( '[QwenAgentManager] Sessions retrieved via ACP:', @@ -514,14 +538,7 @@ export class QwenAgentManager { ...(cursor !== undefined ? { cursor } : {}), }); const res: unknown = response; - let items: Array> = []; - - if (res && typeof res === 'object' && 'sessions' in res) { - const sessionsValue = (res as { sessions?: unknown }).sessions; - items = Array.isArray(sessionsValue) - ? (sessionsValue as Array>) - : []; - } + const items = extractSessionListItems(res); const mapped = items.map((item) => ({ id: item.sessionId || item.id, @@ -997,8 +1014,7 @@ export class QwenAgentManager { 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, diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 5e33b548d..60c0b3ac5 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -18,6 +18,7 @@ import { extractSessionModeState, extractSessionModelState, } from '../utils/acpModelInfo.js'; +import { getErrorMessage } from '../utils/errorMessage.js'; import type { ModelInfo } from '@agentclientprotocol/sdk'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; @@ -167,6 +168,8 @@ export class QwenConnectionHandler { authMethod: string, autoAuthenticate: boolean, ): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log( @@ -176,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, @@ -221,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); @@ -232,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/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 56b81d98c..bb503f307 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -307,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({ 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/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 868838a1d..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 @@ -101,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; @@ -160,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', @@ -221,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 */ @@ -279,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({ @@ -367,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.', ); @@ -384,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); @@ -407,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) @@ -429,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( @@ -451,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'); @@ -474,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); } } } @@ -524,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.', @@ -546,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}` }, }); } } @@ -632,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.', @@ -691,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.', @@ -734,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.', @@ -756,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}` }, }); } } @@ -789,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.', @@ -811,7 +823,7 @@ export class SessionMessageHandler extends BaseMessageHandler { } else { this.sendToWebView({ type: 'error', - data: { message: `Failed to get sessions: ${error}` }, + data: { message: `Failed to get sessions: ${errorMsg}` }, }); } } @@ -827,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'); } } @@ -891,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.', @@ -920,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.', @@ -942,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}` }, }); } } @@ -960,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}` }, }); } } @@ -982,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 507da7e2a..24c3ce561 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts @@ -161,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]; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 4400c54b4..52d1655e7 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -168,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, @@ -461,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); @@ -479,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(); @@ -488,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: 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 87% rename from packages/vscode-ide-companion/src/webview/MessageHandler.ts rename to packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index b89c8fd86..a06fd1a3b 100644 --- a/packages/vscode-ide-companion/src/webview/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { QwenAgentManager } from '../services/qwenAgentManager.js'; -import type { ConversationStore } from '../services/conversationStore.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'; +} from '../../types/webviewMessageTypes.js'; +import { MessageRouter } from '../handlers/MessageRouter.js'; /** * MessageHandler (Refactored Version) 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('