mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
Merge branch 'main' into feat/hook_sessionstart_sessionend
This commit is contained in:
commit
b236e4152f
195 changed files with 7605 additions and 3975 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
||||
```
|
||||
<type>(<scope>): <short description>
|
||||
- <detail point 1> (optional)
|
||||
|
|
@ -59,12 +66,14 @@ This <explains the why/impact of the changes>.
|
|||
```
|
||||
|
||||
### 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 "<commit-message>"`
|
||||
- `git push -u origin <branch-name>` (use `-u` for new branches)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
@{.github/pull_request_template.md}
|
||||
|
|
|
|||
71
.qwen/skills/docs-audit-and-refresh/SKILL.md
Normal file
71
.qwen/skills/docs-audit-and-refresh/SKILL.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
73
.qwen/skills/docs-update-from-diff/SKILL.md
Normal file
73
.qwen/skills/docs-update-from-diff/SKILL.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 <server-name>` 命令进行认证
|
||||
- **问题**:
|
||||
- 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<OpenDialogActionReturn> => ({
|
||||
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 <Text>{t('Authenticating...')}</Text>;
|
||||
```
|
||||
|
||||
##### 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<ToolListStepProps> = ({
|
||||
tools,
|
||||
serverName,
|
||||
onSelect,
|
||||
onBack,
|
||||
}) => {
|
||||
// 添加禁用状态检查
|
||||
if (tools.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No tools available for this server.')}
|
||||
</Text>
|
||||
{/* 添加提示:服务器可能被禁用 */}
|
||||
<Text color={theme.text.warning}>
|
||||
{t('Note: This server may be disabled. Please enable it in the server settings.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// ... 其余代码保持不变
|
||||
};
|
||||
```
|
||||
|
||||
**方案 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 && (
|
||||
<Text color={theme.status.warning}>
|
||||
{' '}
|
||||
{t('(disabled - no connection possible)')}
|
||||
</Text>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 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
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Scope:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
{t(server.scope)}
|
||||
{server.source === 'extension' && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}({t('provided by {{name}}', { name: server.config.extensionName })})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
// 禁用按钮文本根据 scope 调整
|
||||
{server.isDisabled ? (
|
||||
<Text>{t('Enable (will remove from exclusion list)')}</Text>
|
||||
) : server.source === 'extension' ? (
|
||||
<Text color={theme.text.secondary}>{t('Cannot disable extension server')}</Text>
|
||||
) : (
|
||||
<Text>{t('Disable (in {{scope}})', { scope: server.scope })}</Text>
|
||||
)}
|
||||
```
|
||||
|
||||
**方案 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. **错误友好**: 提供清晰、有帮助的错误信息
|
||||
|
||||
建议按优先级分阶段实施,确保每个问题都得到妥善解决。
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
||||

|
||||
|
||||
For users using JetBrains AI Assistant and/or other ACP agents, click **Install From ACP Registry** in Agents List, then install Qwen Code ACP.
|
||||
|
||||

|
||||
|
||||
4. The Qwen Code agent should now be available in the AI Assistant panel.
|
||||
|
||||

|
||||
|
||||
#### Manual Install (for older version of JetBrains IDEs)
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### Manual Install
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@
|
|||
"maxSessionTurns": 50,
|
||||
"preferredEditor": "vscode",
|
||||
"sandbox": false,
|
||||
"summarizeToolOutput": true,
|
||||
"telemetry": {
|
||||
"enabled": false
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
async readTextFile(
|
||||
params: Omit<ReadTextFileRequest, 'sessionId'>,
|
||||
): Promise<ReadTextFileResponse> {
|
||||
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<FileReadResult> {
|
||||
// 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<void> {
|
||||
params: Omit<WriteTextFileRequest, 'sessionId'>,
|
||||
): Promise<WriteTextFileResponse> {
|
||||
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<boolean> {
|
||||
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[] {
|
||||
|
|
|
|||
|
|
@ -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<void> | null = null;
|
||||
private turn: number = 0;
|
||||
|
||||
// Modular components
|
||||
|
|
@ -143,10 +151,43 @@ export class Session implements SessionContext {
|
|||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
// 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<void>((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.#executePrompt(params, pendingSend);
|
||||
} finally {
|
||||
resolveCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
async #executePrompt(
|
||||
params: PromptRequest,
|
||||
pendingSend: AbortController,
|
||||
): Promise<PromptResponse> {
|
||||
// Increment turn counter for each user prompt
|
||||
this.turn += 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ export const V1_TO_V2_MIGRATION_MAP: Record<string, string> = {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -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<SettingScope>();
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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<string, { tokenBudget?: number }>
|
||||
| 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',
|
||||
|
|
|
|||
|
|
@ -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 <extension-names>|--all':
|
||||
'Erweiterungen aktualisieren. Verwendung: update <Erweiterungsnamen>|--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',
|
||||
|
|
|
|||
|
|
@ -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 <extension-names>|--all':
|
||||
'Update extensions. Usage: update <extension-names>|--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:',
|
||||
|
|
|
|||
|
|
@ -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 <extension-names>|--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: '再接続',
|
||||
|
|
|
|||
|
|
@ -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 <extension-names>|--all':
|
||||
'Atualizar extensões. Uso: update <nomes-das-extensoes>|--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',
|
||||
|
|
|
|||
|
|
@ -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 <extension-names>|--all':
|
||||
'Обновить расширения. Использование: update <extension-names>|--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: 'Переподключить',
|
||||
|
|
|
|||
|
|
@ -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 <extension-names>|--all':
|
||||
'更新扩展。用法:update <extension-names>|--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)': '(已禁用)',
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>,
|
||||
): 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();
|
||||
|
|
|
|||
128
packages/cli/src/services/BundledSkillLoader.test.ts
Normal file
128
packages/cli/src/services/BundledSkillLoader.test.ts
Normal file
|
|
@ -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> = {}): 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<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
64
packages/cli/src/services/BundledSkillLoader.ts
Normal file
64
packages/cli/src/services/BundledSkillLoader.ts
Normal file
|
|
@ -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 /<skill-name> (e.g., /review).
|
||||
*/
|
||||
export class BundledSkillLoader implements ICommandLoader {
|
||||
constructor(private readonly config: Config | null) {}
|
||||
|
||||
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
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<SlashCommandActionReturn> => {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InsightData, 'facets' | 'qualitative'>;
|
||||
|
||||
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<InsightData, 'facets' | 'qualitative'>,
|
||||
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<InsightData, 'facets' | 'qualitative'>,
|
||||
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<InsightData, 'facets' | 'qualitative'>,
|
||||
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[] = [
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ export class DataProcessor {
|
|||
const generate = async <T>(
|
||||
promptTemplate: string,
|
||||
schema: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
): Promise<T | undefined> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderStyle="single"
|
||||
borderColor={theme?.border?.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function AuthInProgress({
|
|||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
|
|
|
|||
|
|
@ -19,14 +19,14 @@ import {
|
|||
} from '../utils/export/index.js';
|
||||
|
||||
const mockSessionServiceMocks = vi.hoisted(() => ({
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ export enum CommandKind {
|
|||
BUILT_IN = 'built-in',
|
||||
FILE = 'file',
|
||||
MCP_PROMPT = 'mcp-prompt',
|
||||
SKILL = 'skill',
|
||||
}
|
||||
|
||||
export interface CommandCompletionItem {
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe('<Header />', () => {
|
|||
|
||||
it('renders with border around info panel', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('╭');
|
||||
expect(lastFrame()).toContain('╯');
|
||||
expect(lastFrame()).toContain('┌');
|
||||
expect(lastFrame()).toContain('┐');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||
{/* Right side: Info panel (flexible width, max 60 in two-column layout) */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={infoPanelPaddingX}
|
||||
flexGrow={showLogo ? 0 : 1}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,13 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
|
|||
availableHeight,
|
||||
childWidth,
|
||||
}) => {
|
||||
const { message, plan } = data;
|
||||
const { message, plan, rejected } = data;
|
||||
const messageColor = rejected ? Colors.AccentYellow : Colors.AccentGreen;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text color={Colors.AccentGreen} wrap="wrap">
|
||||
<Text color={messageColor} wrap="wrap">
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={customAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<QwenOAuthProgress
|
||||
|
|
@ -294,9 +257,10 @@ describe('QwenOAuthProgress', () => {
|
|||
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(
|
||||
<QwenOAuthProgress
|
||||
|
|
@ -305,9 +269,10 @@ describe('QwenOAuthProgress', () => {
|
|||
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(
|
||||
<QwenOAuthProgress
|
||||
|
|
@ -316,110 +281,8 @@ describe('QwenOAuthProgress', () => {
|
|||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization...');
|
||||
|
||||
// Advance by another 500ms to reset dots
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Manually trigger the QR code callback
|
||||
if (qrCallback && typeof qrCallback === 'function') {
|
||||
qrCallback('Mock QR Code Data');
|
||||
}
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
|
||||
);
|
||||
|
||||
expect(mockGenerate).not.toHaveBeenCalled();
|
||||
const after1500ms = lastFrame();
|
||||
expect(after1500ms).toContain('Waiting for authorization');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
{t('Qwen OAuth Authentication')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Please visit this URL to authorize:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={verificationUrl} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} bold>
|
||||
{verificationUrl}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Or scan the QR code below:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{qrCodeData}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic Status Display Component
|
||||
* Shows the loading spinner, timer, and status messages
|
||||
*/
|
||||
function StatusDisplay({
|
||||
timeRemaining,
|
||||
dots,
|
||||
}: {
|
||||
timeRemaining: number;
|
||||
dots: string;
|
||||
}): React.JSX.Element {
|
||||
const formatTime = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> {t('Waiting for authorization')}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
{t('(Press ESC or CTRL+C to cancel)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
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<number>(defaultTimeout);
|
||||
const [dots, setDots] = useState<string>('');
|
||||
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
|
||||
const [dots, setDots] = useState<string>('...');
|
||||
|
||||
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 (
|
||||
<QrCodeDisplay
|
||||
verificationUrl={deviceAuth.verification_uri_complete}
|
||||
qrCodeData={qrCodeData}
|
||||
/>
|
||||
);
|
||||
}, [deviceAuth?.verification_uri_complete, qrCodeData]);
|
||||
|
||||
// Handle timeout state
|
||||
if (authStatus === 'timeout') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
<Text bold color={theme.status.error}>
|
||||
{t('Qwen OAuth Authentication Timeout')}
|
||||
</Text>
|
||||
|
||||
|
|
@ -238,7 +109,7 @@ export function QwenOAuthProgress({
|
|||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press any key to return to authentication type selection.')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
@ -249,26 +120,26 @@ export function QwenOAuthProgress({
|
|||
if (authStatus === 'error') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
Qwen OAuth Authentication Error
|
||||
<Text bold color={theme.status.error}>
|
||||
{t('Qwen OAuth Authentication Error')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{authMessage ||
|
||||
'An error occurred during authentication. Please try again.'}
|
||||
t('An error occurred during authentication. Please try again.')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Press any key to return to authentication type selection.
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press any key to return to authentication type selection.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -279,38 +150,61 @@ export function QwenOAuthProgress({
|
|||
if (!deviceAuth) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box>
|
||||
<Text bold>{t('Qwen OAuth Authentication')}</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text>{t('Waiting for Qwen OAuth authentication...')}</Text>
|
||||
<Text>
|
||||
<Spinner type="dots" />
|
||||
{t('Waiting for Qwen OAuth authentication...')}
|
||||
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
{t('Time remaining:')} {Math.floor(timeRemaining / 60)}:
|
||||
{(timeRemaining % 60).toString().padStart(2, '0')}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
{t('(Press ESC or CTRL+C to cancel)')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Static QR Code Display */}
|
||||
{qrCodeDisplay}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>{t('Qwen OAuth Authentication')}</Text>
|
||||
|
||||
{/* Dynamic Status Display */}
|
||||
<StatusDisplay timeRemaining={timeRemaining} dots={dots} />
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Please visit this URL to authorize:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={deviceAuth.verification_uri_complete || ''} fallback={false}>
|
||||
<Text color={theme.text.link} bold>
|
||||
{deviceAuth.verification_uri_complete}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text>
|
||||
{t('Waiting for authorization')}
|
||||
{dots}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ShellInputPromptProps> = ({
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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<MCPManagementDialogProps> = ({
|
|||
(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<MCPManagementDialogProps> = ({
|
|||
invalidToolCount,
|
||||
promptCount: serverPrompts.length,
|
||||
isDisabled,
|
||||
hasOAuthTokens,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -249,6 +261,36 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
}
|
||||
}, [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<MCPManagementDialogProps> = ({
|
|||
onReconnect={handleReconnect}
|
||||
onDisable={handleDisable}
|
||||
onAuthenticate={handleAuthenticate}
|
||||
onClearAuth={handleClearAuth}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
|
@ -569,10 +612,10 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
return (
|
||||
<AuthenticateStep
|
||||
server={selectedServer}
|
||||
onSuccess={() => {
|
||||
onBack={() => {
|
||||
handleNavigateBack();
|
||||
void reloadServers();
|
||||
}}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -594,6 +637,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
handleReconnect,
|
||||
handleDisable,
|
||||
handleAuthenticate,
|
||||
handleClearAuth,
|
||||
handleNavigateBack,
|
||||
handleSelectTool,
|
||||
handleSelectDisableScope,
|
||||
|
|
|
|||
|
|
@ -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<AuthenticateStepProps> = ({
|
||||
server,
|
||||
onSuccess,
|
||||
onBack,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
|
|
@ -39,9 +41,12 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
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<AuthenticateStepProps> = ({
|
|||
}),
|
||||
]);
|
||||
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<AuthenticateStepProps> = ({
|
|||
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<AuthenticateStepProps> = ({
|
|||
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<AuthenticateStepProps> = ({
|
|||
{t('Authenticating... Please complete the login in your browser.')}
|
||||
</Text>
|
||||
)}
|
||||
{authState === 'success' && (
|
||||
<Text color={theme.status.success}>
|
||||
{t('Authentication successful.')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ type ServerAction =
|
|||
| 'view-tools'
|
||||
| 'reconnect'
|
||||
| 'toggle-disable'
|
||||
| 'authenticate';
|
||||
| 'authenticate'
|
||||
| 'clear-auth';
|
||||
|
||||
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
||||
server,
|
||||
|
|
@ -32,6 +33,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
onReconnect,
|
||||
onDisable,
|
||||
onAuthenticate,
|
||||
onClearAuth,
|
||||
onBack,
|
||||
}) => {
|
||||
const statusColor = server
|
||||
|
|
@ -77,15 +79,24 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
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<ServerDetailStepProps> = ({
|
|||
case 'authenticate':
|
||||
onAuthenticate?.();
|
||||
break;
|
||||
case 'clear-auth':
|
||||
onClearAuth?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,33 +174,6 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
unmount();
|
||||
});
|
||||
|
||||
it('navigates down with arrow key and selects', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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('<AskUserQuestionDialog />', () => {
|
|||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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('<AskUserQuestionDialog />', () => {
|
|||
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(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<ToolGroupMessageProps> = ({
|
|||
contentWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -300,4 +300,55 @@ describe('<ToolMessage />', () => {
|
|||
);
|
||||
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(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
name="ExitPlanMode"
|
||||
description="Plan:"
|
||||
status={ToolCallStatus.Canceled}
|
||||
resultDisplay={planResultDisplay}
|
||||
/>,
|
||||
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(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
name="ExitPlanMode"
|
||||
description="Plan:"
|
||||
status={ToolCallStatus.Success}
|
||||
resultDisplay={planResultDisplay}
|
||||
/>,
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<void>((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<void>((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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -252,7 +252,6 @@ export function mapToDisplay(
|
|||
status: mapCoreStatusToDisplayStatus(trackedCall.status),
|
||||
resultDisplay: trackedCall.response.resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
outputFile: trackedCall.response.outputFile,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ export interface IndividualToolCallDisplay {
|
|||
confirmationDetails: ToolCallConfirmationDetails | undefined;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
ptyId?: number;
|
||||
outputFile?: string;
|
||||
}
|
||||
|
||||
export interface CompressionProps {
|
||||
|
|
|
|||
|
|
@ -22,4 +22,6 @@
|
|||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
)
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string, SummarizeToolOutputSettings>;
|
||||
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<string, SummarizeToolOutputSettings>
|
||||
| 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<string, unknown>;
|
||||
private readonly hooksConfig?: Record<string, unknown>;
|
||||
|
|
@ -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<string, SummarizeToolOutputSettings>
|
||||
| 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<number>('max_tokens', 'maxOutputTokens');
|
||||
const modelId = this.contentGeneratorConfig.model;
|
||||
const modelLimit = tokenLimit(modelId, 'output');
|
||||
const isKnownModel = hasExplicitOutputLimit(modelId);
|
||||
|
||||
const maxTokens =
|
||||
getParam<number>('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,
|
||||
|
|
|
|||
|
|
@ -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<GeminiChat> = {
|
||||
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<GeminiChat> = {
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -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<ServerGeminiStreamEvent, Turn> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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: [] };
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string, string | undefined> {
|
||||
override buildHeaders(): Record<string, string | undefined> {
|
||||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ export interface ToolCallResponseInfo {
|
|||
resultDisplay: ToolResultDisplay | undefined;
|
||||
error: Error | undefined;
|
||||
errorType: ToolErrorType | undefined;
|
||||
outputFile?: string | undefined;
|
||||
contentLength?: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue