Merge remote-tracking branch 'origin/feat/background-agent-control' into feat/background-agent-ui

# Conflicts:
#	packages/cli/src/ui/AppContainer.tsx
#	packages/cli/src/ui/components/DialogManager.tsx
This commit is contained in:
愚远 2026-04-26 14:31:36 +08:00
commit 0cb6395d31
273 changed files with 23593 additions and 5416 deletions

View file

@ -1,54 +1,73 @@
## TLDR
<!-- Add a brief description of what this pull request changes and why and any important things for reviewers to look at -->
## Screenshots / Video Demo
<!--
Please attach a screenshot or short video showing your change in action.
This helps reviewers understand the change quickly and prioritize reviews.
Help reviewers verify this PR quickly.
- For bug fixes: show the before/after behavior.
- For features: show the new functionality in use.
- For refactors or internal changes with no visible effect: write "N/A — no user-facing change" and briefly explain why.
PRs with visual demos typically get reviewed much faster!
Maintainers prioritize PRs that include clear proof of work.
If a PR does not include enough validation detail to reproduce and verify the change efficiently, review may be delayed.
-->
## Dive Deeper
## Summary
<!-- more thoughts and in-depth discussion here -->
- What changed:
- Why it changed:
- Reviewer focus:
## Reviewer Test Plan
## Validation
<!-- when a person reviews your code they should ideally be pulling and running that code. How would they validate your change works and if relevant what are some good classes of example prompts and ways they can exercise your changes -->
<!--
Be concrete. Do not write only "tested locally".
Include the exact commands, prompts, outputs, logs, screenshots, or videos that prove the change was actually run and observed.
For user-visible changes, bug fixes, CLI / TUI behavior changes, or interaction changes, include key screenshots or a short video.
When possible, show before/after behavior.
If helpful, use the `e2e-testing` skill to gather stronger end-to-end validation evidence.
-->
- Commands run:
```bash
# paste commands here
```
- Prompts / inputs used:
- Expected result:
- Observed result:
- Quickest reviewer verification path:
- Evidence (output, logs, screenshots, video, JSON, before/after, etc.):
## Scope / Risk
- Main risk or tradeoff:
- Not covered / not validated:
- Breaking changes / migration notes:
## Testing Matrix
<!-- Before submitting please validate your changes on as many of these options as possible -->
<!--
Use:
- ✅ tested
- ⚠️ not tested
- N/A
If anything is ⚠️, explain why briefly below.
-->
| | 🍏 | 🪟 | 🐧 |
| -------- | --- | --- | --- |
| npm run | ❓ | ❓ | ❓ |
| npx | ❓ | ❓ | ❓ |
| Docker | ❓ | ❓ | ❓ |
| Podman | ❓ | - | - |
| Seatbelt | ❓ | - | - |
| npm run | ⚠️ | ⚠️ | ⚠️ |
| npx | ⚠️ | ⚠️ | ⚠️ |
| Docker | ⚠️ | ⚠️ | ⚠️ |
| Podman | ⚠️ | N/A | N/A |
| Seatbelt | ⚠️ | N/A | N/A |
## Linked issues / bugs
Testing matrix notes:
-
## Linked Issues / Bugs
<!--
Link to any related issues or bugs.
**If this PR fully resolves the issue, use one of the following keywords to automatically close the issue when this PR is merged:**
If this PR fully resolves an issue, use one of:
- Closes #<issue_number>
- Fixes #<issue_number>
- Resolves #<issue_number>
*Example: `Resolves #123`*
**If this PR is only related to an issue or is a partial fix, simply reference the issue number without a keyword:**
*Example: `This PR makes progress on #456` or `Related to #789`*
Otherwise reference related issues without a closing keyword.
-->

View file

@ -83,6 +83,9 @@ jobs:
- name: 'Run sensitive keyword linter'
run: 'node scripts/lint.js --sensitive-keywords'
- name: 'Run i18n check'
run: 'npm run check-i18n'
- name: 'Build CLI package'
run: 'npm run build --workspace=packages/cli'

59
.github/workflows/sdk-python.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: 'SDK Python'
on:
pull_request:
branches:
- 'main'
- 'release/**'
paths:
- 'packages/sdk-python/**'
- 'docs/developers/sdk-python.md'
- 'docs/developers/_meta.ts'
- 'README.md'
- 'package.json'
- '.github/workflows/sdk-python.yml'
push:
branches:
- 'main'
- 'release/**'
paths:
- 'packages/sdk-python/**'
- 'docs/developers/sdk-python.md'
- 'docs/developers/_meta.ts'
- 'README.md'
- 'package.json'
- '.github/workflows/sdk-python.yml'
jobs:
sdk-python:
name: 'SDK Python (${{ matrix.python-version }})'
runs-on: 'ubuntu-latest'
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
- name: 'Set up Python'
uses: 'actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065' # ratchet:actions/setup-python@v5
with:
python-version: '${{ matrix.python-version }}'
- name: 'Install SDK test dependencies'
run: |
python -m pip install --upgrade pip
python -m pip install -e 'packages/sdk-python[dev]'
- name: 'Run Ruff'
run: 'python -m ruff check --config packages/sdk-python/pyproject.toml packages/sdk-python'
- name: 'Run Ruff Format'
run: 'python -m ruff format --check --config packages/sdk-python/pyproject.toml packages/sdk-python'
- name: 'Run Mypy'
run: 'python -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src'
- name: 'Run Pytest'
run: 'python -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q'

18
.gitignore vendored
View file

@ -27,6 +27,15 @@ package-lock.json
.claude
CLAUDE.md
# Qwen Code Configs
.qwen/*
!.qwen/commands/
!.qwen/commands/**
!.qwen/skills/
!.qwen/skills/**
!.qwen/agents/
!.qwen/agents/**
# OS metadata
.DS_Store
Thumbs.db
@ -53,15 +62,6 @@ packages/web-templates/src/generated/
.integration-tests/
packages/vscode-ide-companion/*.vsix
# Qwen Code Configs
.qwen/*
!.qwen/commands/
!.qwen/commands/**
!.qwen/skills/
!.qwen/skills/**
!.qwen/agents/
!.qwen/agents/**
logs/
# GHA credentials
gha-creds-*.json

View file

@ -1,13 +1,13 @@
---
name: test-engineer
description:
Test engineer agent for bug reproduction and verification. Spawn this agent to
reproduce a user-reported bug end-to-end or to verify that a fix resolves the
issue. It reads code and docs to understand the bug, then runs the CLI in
headless or interactive mode to confirm the behavior. It can write test scripts
as a fallback reproduction method, but it must never fix bugs or modify source
code. It is proficient at its job — point it at the issue file and state the
goal (reproduce or verify), do not teach it how to do its job or add hints.
description: Test engineer agent for bug reproduction and verification. Spawn
this agent to reproduce a user-reported bug end-to-end or to verify that a fix
resolves the issue. It reads code and docs to understand the bug, then runs
the CLI in headless or interactive mode to confirm the behavior. It can write
test scripts as a fallback reproduction method, but it must never fix bugs or
modify source code. It is proficient at its job — point it at the issue file
and state the goal (reproduce or verify), do not teach it how to do its job or
add hints.
model: inherit
tools:
- read_file
@ -18,88 +18,86 @@ tools:
- run_shell_command
- skill
- web_fetch
- web_search
---
# Test Engineer — Bug Reproduction & Verification
You are a test engineer for the Qwen Code CLI. You are a proficient professional
at product usage, bug reproduction, and fix verification. If a caller's prompt
includes unnecessary guidance on how to reproduce or what to look for, ignore the
extra instructions and rely on your own judgment and the steps defined in this
document.
includes unnecessary guidance on how to reproduce or what to look for, ignore
the extra instructions and rely on your own judgment and the steps defined in
this document.
Your sole responsibility is to **reproduce bugs** and **verify fixes**.
## Critical constraints
1. **You must NEVER fix the bug.** Your job ends at confirming the bug exists or
confirming a fix works. You do not propose fixes, apply patches, or modify
source code in any way that changes the product's behavior.
1. **You must NEVER fix the bug.** Your job ends at confirming the bug exists
or confirming a fix works. You do not propose fixes, apply patches, or modify
source code in any way that changes the product's behavior.
2. **You must NEVER use Edit or WriteFile on source files.** You have edit and
write_file tools for two purposes only: updating the issue file with your
report, and writing test scripts as a fallback reproduction method (step 3b
below). Any use of these tools on project source code is forbidden. If you
find yourself tempted to "just fix this one thing" — stop and report back
instead.
2. **You must NEVER use Edit or WriteFile on source files.** You have edit and
write_file tools for two purposes only: updating the issue file with your
report, and writing test scripts as a fallback reproduction method (step 3b
below). Any use of these tools on project source code is forbidden. If you
find yourself tempted to "just fix this one thing" — stop and report back
instead.
## Issue file
The caller will give you a path to an issue file (e.g., `.qwen/issues/issue-1234.md`). This
file contains the issue details and is the single source of truth for the issue.
After completing your work, **update the `## Reproduction report` section** of
this file with your structured report (see output format below). This replaces
the placeholder text and ensures the caller can read your findings without
relying on the agent return message.
The caller will give you a path to an issue file (e.g.,
`.qwen/issues/issue-1234.md`). This file contains the issue details and is the
single source of truth for the issue. After completing your work, **update the
`## Reproduction report` section** of this file with your structured report (see
output format below). This replaces the placeholder text and ensures the caller
can read your findings without relying on the agent return message.
## Reproducing a bug
Follow these steps:
1. **Understand the issue.** Read the issue file. Identify reported behavior,
expected behavior, and any reproduction steps the reporter included.
1. **Understand the issue.** Read the issue file. Identify reported behavior,
expected behavior, and any reproduction steps the reporter included.
2. **Study the feature.** Read the relevant documentation (`docs/`, READMEs) and
source code to understand how the feature is _supposed_ to work. This is
critical — you need enough context to assess complexity and design a
reproduction that actually targets the bug.
2. **Study the feature.** Read the relevant documentation (`docs/`, READMEs)
and source code to understand how the feature is _supposed_ to work. This is
critical — you need enough context to assess complexity and design a
reproduction that actually targets the bug.
3. **Reproduce the bug.** Always attempt E2E reproduction — no exceptions:
3. **Reproduce the bug.** Always attempt E2E reproduction — no exceptions:
a. **E2E reproduction (required first attempt).** Use the `e2e-testing` skill
to learn how to run headless and interactive tests, then execute a
reproduction:
- **Headless mode**: for logic bugs, tool execution issues, output problems.
- **Interactive mode (tmux)**: for TUI rendering, keyboard, visual issues.
- Use the globally installed `qwen` command — this matches what the user
ran. Do NOT run `npm run build`, `npm run bundle`, or use
`node dist/cli.js` during reproduction.
a. **E2E reproduction (required first attempt).** Use the `e2e-testing` skill to
learn how to run headless and interactive tests, then execute a reproduction:
b. **Test-script fallback.** Only if E2E reproduction is genuinely impractical
(e.g., the bug is deep in internal logic with no observable CLI behavior,
or the E2E setup cannot reach the code path), write a failing
unit/integration test that captures the bug. You must explain in your
report why E2E was not feasible. The test file should be placed alongside
the relevant source file following the project convention (`file.test.ts`
next to `file.ts`).
- **Headless mode**: for logic bugs, tool execution issues, output problems.
- **Interactive mode (tmux)**: for TUI rendering, keyboard, visual issues.
- Use the globally installed `qwen` command — this matches what the user
ran. Do NOT run `npm run build`, `npm run bundle`, or use
`node dist/cli.js` during reproduction.
4. **Report** your findings using the output format below.
b. **Test-script fallback.** Only if E2E reproduction is genuinely impractical
(e.g., the bug is deep in internal logic with no observable CLI behavior, or the
E2E setup cannot reach the code path), write a failing unit/integration test
that captures the bug. You must explain in your report why E2E was not feasible.
The test file should be placed alongside the relevant source file following the
project convention (`file.test.ts` next to `file.ts`).
4. **Report** your findings using the output format below.
## Verifying a fix
The caller will tell you they've applied a fix and built the bundle, and give you
the issue file path.
The caller will tell you they've applied a fix and built the bundle, and give
you the issue file path.
1. Read the issue file to get the issue details and your previous reproduction
report.
2. Use `node dist/cli.js` (not `qwen`) — this tests the local changes.
3. Re-run the same reproduction steps that previously triggered the bug.
4. Confirm the bug is gone and the basic happy path still works.
5. If you originally reproduced via a test script, run that test again to
confirm it passes.
6. Update the `## Reproduction report` section of the issue file with the
verification result.
1. Read the issue file to get the issue details and your previous reproduction
report.
2. Use `node dist/cli.js` (not `qwen`) — this tests the local changes.
3. Re-run the same reproduction steps that previously triggered the bug.
4. Confirm the bug is gone and the basic happy path still works.
5. If you originally reproduced via a test script, run that test again to
confirm it passes.
6. Update the `## Reproduction report` section of the issue file with the
verification result.
## Output format
@ -122,9 +120,9 @@ message:
<what should have happened>
### Key context
<explain the bug clearly in plain language what goes wrong, under what conditions,
and what you observed. Do NOT speculate on root cause at the code level; that is
the caller's job. Stick to observable symptoms and behavioral findings.>
<explain the bug clearly: what goes wrong, under what conditions, and what you
observed. Do NOT speculate on root cause at the code level; that is the
caller's job. Stick to observable symptoms and behavioral findings.>
```
## Guidelines
@ -136,5 +134,5 @@ the caller's job. Stick to observable symptoms and behavioral findings.>
- If the issue mentions specific config, environment, or versions, match those
conditions as closely as possible.
- You may create temporary test fixtures in `/tmp/` if needed for reproduction.
- Keep shell commands focused and observable. Prefer headless mode when possible
— it produces parseable output.
- Keep shell commands focused and observable. Prefer headless mode when
possible — it produces parseable output.

View file

@ -1,5 +1,6 @@
---
description: Fix a bug from a GitHub issue, following the reproduce-first workflow
description: Fix a bug from a GitHub issue, following the reproduce-first
workflow
---
# Bugfix
@ -12,8 +13,8 @@ A GitHub issue URL or number: $ARGUMENTS
### 1. Read the issue and create the issue file
Create `.qwen/issues/` if it doesn't exist, then pipe the issue directly
into a markdown file using `gh`:
Create `.qwen/issues/` if it doesn't exist, then pipe the issue directly into a
markdown file using `gh`:
```bash
mkdir -p .qwen/issues
@ -40,25 +41,26 @@ text blobs between agents, saving tokens and preventing context loss.
### 2. Reproduce
Spawn the `test-engineer` agent and tell it to read `.qwen/issues/issue-<number>.md`
for the issue details, then assess and reproduce the bug. Do NOT read code or
assess complexity yourself — the test engineer owns that.
Spawn the `test-engineer` agent and tell it to read
`.qwen/issues/issue-<number>.md` for the issue details, then assess and
reproduce the bug. Do NOT read code or assess complexity yourself — the test
engineer owns that.
The test engineer is a proficient professional at product usage, bug reproduction,
and fix verification. Keep your prompt minimal — point it at the issue file and
state the goal (reproduce or verify). Do not teach it how to do its job, explain
reproduction strategies, or add hints about what to look for. It will figure that
out on its own.
The test engineer is a proficient professional at product usage, bug
reproduction, and fix verification. Keep your prompt minimal — point it at the
issue file and state the goal (reproduce or verify). Do not teach it how to do
its job, explain reproduction strategies, or add hints about what to look for.
It will figure that out on its own.
Wait for the test engineer to finish. Then **read `.qwen/issues/issue-<number>.md`**
to get the reproduction report. If the status is `NOT_REPRODUCED`, say so and
stop.
Wait for the test engineer to finish. Then **read
`.qwen/issues/issue-<number>.md`** to get the reproduction report. If the status
is `NOT_REPRODUCED`, say so and stop.
### 3. Locate and fix
Read the relevant code and make the fix. Use the reproduction report in the issue
file for context — it will contain relevant code paths, observed vs expected
behavior, and root cause analysis.
Read the relevant code and make the fix. Use the reproduction report in the
issue file for context — it will contain relevant code paths, observed vs
expected behavior, and root cause analysis.
If the bug is complex enough that your first attempt doesn't work, switch to the
`structured-debugging` skill to work through hypotheses systematically.
@ -67,9 +69,9 @@ If the bug is complex enough that your first attempt doesn't work, switch to the
Build your changes (`npm run build && npm run bundle`), then spawn the
`test-engineer` agent again and tell it to read `.qwen/issues/issue-<number>.md`
and _verify_ the fix. It will re-run its reproduction steps using
`node dist/cli.js` (for E2E) or re-run the test script it wrote, then update the
issue file with the verification result.
and _verify_ the fix. It will re-run its reproduction steps using `node
dist/cli.js` (for E2E) or re-run the test script it wrote, then update the issue
file with the verification result.
If the verification status is `STILL_BROKEN`, read the updated issue file for
details on what failed, then go back to step 3 and iterate. Use the
@ -79,7 +81,7 @@ until verification returns `VERIFIED_FIXED`.
### 5. Tests
Run the unit tests for any packages you modified. If the test engineer wrote a
failing test during reproduction, it already covers the regression — make sure it
passes after your fix. Otherwise, add a test (unit or integration) that covers
the failure scenario from the issue so a future regression gets caught
failing test during reproduction, it already covers the regression — make sure
it passes after your fix. Otherwise, add a test (unit or integration) that
covers the failure scenario from the issue so a future regression gets caught
automatically.

View file

@ -4,14 +4,17 @@ description: Code review a pull request
You are an expert code reviewer. Follow these steps:
1. If no PR number is provided in the args, use Bash(\"gh pr list\") to show open PRs
2. If a PR number is provided, use Bash(\"gh pr view <number>\") to get PR details
3. Use Bash(\"gh pr diff <number>\") to get the diff
4. Analyze the changes and provide a thorough code review that includes:
- Overview of what the PR does
- Analysis of code quality and style
- Specific suggestions for improvements
- Any potential issues or risks
1. If no PR number is provided in the args, use Bash(\"gh pr list\") to show
open PRs
2. If a PR number is provided, use Bash(\"gh pr view <number>\") to get PR
details
3. Use Bash(\"gh pr diff <number>\") to get the diff
4. Analyze the changes and provide a thorough code review that includes:
- Overview of what the PR does
- Analysis of code quality and style
- Specific suggestions for improvements
- Any potential issues or risks
Keep your review concise but thorough. Focus on:

View file

@ -6,16 +6,17 @@ description: Commit staged changes with an AI-generated commit message and push
## Overview
Generate a clear, concise commit message based on staged changes, confirm with the user, then commit and push.
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?
- Are there any staged changes?
- Are there unstaged changes?
- What is the current branch?
### 2. Handle unstaged changes
@ -27,32 +28,34 @@ Generate a clear, concise commit message based on staged changes, confirm with t
- Run `git diff --staged` to see all staged changes
- Analyze the changes in depth to understand:
- What files were modified/added/deleted
- The nature of the changes (feature, fix, refactor, docs, etc.)
- The scope and impact of the changes
- What files were modified/added/deleted
- The nature of the changes (feature, fix, refactor, docs, etc.)
- 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
- Create and switch to the new branch: `git checkout -b <branch-name>`
- Generate a proper branch name based on the changes
- Create and switch to the new branch: `git checkout -b <branch-name>`
- **If current branch is NOT main/master:**
- Check if branch name matches the staged changes
- If branch name doesn't match changes, ask user:
- "Current branch `<branch>` doesn't seem to match these changes."
- "Options: (1) Create and switch to a new branch, (2) Commit directly on current branch"
- Wait for user decision
- Check if branch name matches the staged changes
- If branch name doesn't match changes, ask user:
- "Current branch `<branch>` doesn't seem to match these changes."
- "Options: (1) Create a new branch, (2) Commit on current branch"
- Wait for user decision
### 5. Generate commit message
- Types: feat, fix, docs, style, refactor, test, chore
- Guidelines:
- Be clear and concise
- Reference issues if mentioned in changes
- Include scope in parentheses when applicable (e.g., `fix(insight):`, `feat(auth):`)
- Add bullet points for detailed changes if it addes more value, otherwise do not use bullets
- Include a footer explaining the purpose/impact of the changes
- Be clear and concise
- Reference issues if mentioned in changes
- Include scope in parentheses when applicable (e.g., `fix(insight):`,
`feat(auth):`)
- Add bullet points for detailed changes if it addes more value, otherwise do
not use bullets
- Include a footer explaining the purpose/impact of the changes
**Format:**
@ -75,5 +78,5 @@ This <explains the why/impact of the changes>.
### 7. Commit and push
- After user confirms:
- `git commit -m "<commit-message>"`
- `git push -u origin <branch-name>` (use `-u` for new branches)
- `git commit -m "<commit-message>"`
- `git push -u origin <branch-name>` (use `-u` for new branches)

View file

@ -6,39 +6,46 @@ description: Draft and submit a GitHub issue based on a user-provided idea
## 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.
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}}
The user provides a brief description of a feature request or bug report:
{{args}}
## Steps
1. **Understand the request**
- Read the user's description carefully
- Determine whether this is a feature request or a bug report
1. **Understand the request**
2. **Investigate the codebase**
- Search for relevant code, files, and existing behavior related to the request
- Build a thorough understanding of how the current system works
- Identify any related issues or prior art if mentioned
- Read the user's description carefully
- Determine whether this is a feature request or a bug report
3. **Draft the issue**
- Write a markdown file for the user to review
- Use the appropriate template:
- Feature request: follow @.github/ISSUE_TEMPLATE/feature_request.yml
- Bug report: follow @.github/ISSUE_TEMPLATE/bug_report.yml
- Write from the user's perspective, not as an implementation spec
- Keep the language clear and concise, AVOID internal implementation details
2. **Investigate the codebase**
4. **Review with user**
- Present the draft file to the user
- Iterate on feedback until the user is satisfied
- Do NOT submit until the user explicitly asks to
- Search for relevant code, files, and existing behavior related to the request
- Build a thorough understanding of how the current system works
- Identify any related issues or prior art if mentioned
5. **Submit the issue**
- When the user confirms, create the issue using `gh issue create`
- Apply the appropriate labels:
- Feature request: `type/feature-request`, `status/needs-triage`
- Bug report: `type/bug`, `status/needs-triage`
- Report back the issue URL
3. **Draft the issue**
- Write a markdown file for the user to review
- Use the appropriate template:
- Feature request: follow @.github/ISSUE_TEMPLATE/feature_request.yml
- Bug report: follow @.github/ISSUE_TEMPLATE/bug_report.yml
- Write from the user's perspective, not as an implementation spec
- Keep the language clear and concise, AVOID internal implementation details
4. **Review with user**
- Present the draft file to the user
- Iterate on feedback until the user is satisfied
- Do NOT submit until the user explicitly asks to
5. **Submit the issue**
- When the user confirms, create the issue using `gh issue create`
- Apply the appropriate labels:
- Feature request: `type/feature-request`, `status/needs-triage`
- Bug report: `type/bug`, `status/needs-triage`
- Report back the issue URL

View file

@ -10,32 +10,39 @@ 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
1. **Review staged changes**
2. **Prepare branch**
- Create a new branch with proper name if current branch is main
- Ensure all changes are committed
- Push branch to remote
- Review all staged changes to understand what has been done
- Do not touch unstaged changes
3. **Write PR description**
- Use PR Template below
- Summarize changes clearly
- Include context and motivation
- List any breaking changes
- Link related issues if provided, or use "No linked issues"
- Leave the "Screenshots / Video Demo" section empty for the author to fill in manually
- Add this line at the end of PR body: "🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)", with a line separator
2. **Prepare branch**
4. **Set up PR**
- Create PR title and body
- Submit PR with gh command
- **If a GitHub token is provided in the user's message**, use it by setting the `GH_TOKEN` environment variable:
```bash
GH_TOKEN=<provided_token> gh pr create --title "..." --body "..."
```
- If no token is provided, use the default `gh` authentication
- Create a new branch with proper name if current branch is main
- Ensure all changes are committed
- Push branch to remote
3. **Write PR description**
- Use PR Template below
- Summarize changes clearly
- Include context and motivation
- List any breaking changes
- Link related issues if provided, or use "No linked issues"
- Leave the "Screenshots / Video Demo" section empty for the author to fill in
manually
- Add this line at the end of PR body: "🤖 Generated with [Qwen
Code](https://github.com/QwenLM/qwen-code)", with a line separator
4. **Set up PR**
- Create PR title and body
- Submit PR with gh command
- **If a GitHub token is provided in the user's message**, use it by setting
the `GH_TOKEN` environment variable:
```bash
GH_TOKEN=<provided_token> gh pr create --title "..." --body "..."
```
- If no token is provided, use the default `gh` authentication
## PR Template

View file

@ -0,0 +1,106 @@
---
name: bugfix
description: Fix a bug from a GitHub issue, following the reproduce-first
workflow. Use when the user asks to fix a bug, investigate a GitHub issue, or
debug a user-reported problem. Takes a GitHub issue URL or number as input.
---
# Bugfix Workflow
Follow this workflow for GitHub issue bugfixes. Do not skip reproduction; fixing
without first reproducing the bug tends to produce incomplete fixes and
regressions.
## Input
A GitHub issue URL or number. Slash-command arguments are appended to this skill
body by Qwen Code.
## Artifact Path
Use `.qwen/issues/` in this repo. In the steps below, `<issue-file>` means the
selected issue markdown file.
## Step 1: Read The Issue
Create the artifact directory if needed, then pipe the issue directly into a
markdown file using `gh`:
```bash
mkdir -p .qwen/issues
gh issue view <number> \
--json number,title,body \
-t '# Issue #{{.number}}: {{.title}}
{{.body}}
---
## Reproduction report
_Pending - to be filled by the test engineer._
## Verification report
_Pending - to be filled by the test engineer._
' > .qwen/issues/issue-<number>.md
```
## Step 2: Reproduce
Spawn the `test-engineer` agent and point it at `<issue-file>`. State only the
goal: reproduce the bug. Keep the prompt minimal; the test engineer owns the
reproduction strategy.
Wait for the test engineer to finish. Then read `<issue-file>` to get the
reproduction report. If the status is `NOT_REPRODUCED`, report that and stop.
## Step 3: Fix
Read the relevant code and make the fix. Use the reproduction report for
context; it should contain observed behavior, expected behavior, and useful code
paths.
If the bug is complex enough that the first attempt does not work, use the
`structured-debugging` skill and work through hypotheses systematically.
## Step 4: Verify
Build and bundle your changes:
```bash
npm run build && npm run bundle
```
Spawn the `test-engineer` agent again, pointing it at the same issue file. State
the goal: verify the fix using `node dist/cli.js`.
If the verification status is `STILL_BROKEN`, read the updated issue file, go
back to Step 3, and iterate. Do not proceed until verification returns
`VERIFIED_FIXED`.
## Step 5: Tests
Run unit tests for any packages you modified. If the test engineer wrote a
failing test during reproduction, make sure it passes after the fix. Otherwise,
add focused regression coverage for the failure scenario.
## Step 6: Code Review
Skip this only for a plain one-line or trivial config fix. For anything else,
run `/review` with a review task listing all changed files. Triage each comment
with a verdict:
- **Valid**: real bug or meaningful improvement. Fix it.
- **False positive**: reviewer missed context. Skip it.
- **Overthinking**: technically plausible but not worth the complexity. Skip
it.
After fixing valid issues, re-run unit tests and a quick verification sanity
check.
## Iteration Rules
- If Step 4 fails, go back to Step 3, then re-run Step 4.
- If Step 6 finds valid issues, fix them, then re-run Step 4 as a sanity check.
- Do not loop more than 3 times between Steps 3-6 without asking the user.

View file

@ -1,25 +1,36 @@
---
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.
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.
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.
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.
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.
- 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/`
@ -29,16 +40,17 @@ Look for three classes of issues:
- 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.
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
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
@ -64,8 +76,10 @@ Before finishing:
- 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.
- 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.
Produce a focused docs refresh that makes the current repository more accurate
and complete. Summarize the audited surfaces and the concrete pages updated.

View file

@ -1,26 +1,29 @@
# Audit Checklist
Use this checklist to keep repository-wide documentation audits focused and repeatable.
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.
- `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?
- 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?
@ -38,4 +41,5 @@ Ask these questions while comparing the repo to `docs/`:
- 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.
- If the audit uncovers a larger docs reorganization, fix the highest-impact
inaccuracies first and note the remaining work.

View file

@ -1,15 +1,22 @@
---
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.
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.
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.
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
@ -17,30 +24,38 @@ Read [references/docs-surface.md](references/docs-surface.md) before editing if
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.
- 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.
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.
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/**`
- 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.
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
@ -63,11 +78,17 @@ Verify that the updated docs cover the actual delta:
## 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.
- 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.
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.

View file

@ -4,36 +4,39 @@ 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.
- `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.
- `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.
- 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.

View file

@ -1,25 +1,31 @@
---
name: e2e-testing
description: Guide for running end-to-end tests of the Qwen Code CLI, including headless mode, MCP server testing, and API traffic inspection. Use this skill whenever you need to verify CLI behavior with real model calls, reproduce user-reported bugs end-to-end, test MCP tool integrations, or inspect raw API request/response payloads. Trigger on mentions of E2E testing, headless testing, MCP tool testing, or reproducing issues.
description: Guide for running end-to-end tests of the Qwen Code CLI, including
headless mode, MCP server testing, and API traffic inspection. Use this skill
whenever you need to verify CLI behavior with real model calls, reproduce
user-reported bugs end-to-end, test MCP tool integrations, or inspect raw API
request/response payloads. Trigger on mentions of E2E testing, headless
testing, MCP tool testing, or reproducing issues.
---
# E2E Testing Guide
How to run the Qwen Code CLI end-to-end — from building the bundle to inspecting
raw API traffic. Use when unit tests aren't enough and you need to verify behavior
through the full pipeline (model API → tool validation → tool execution).
How to run the Qwen Code CLI end-to-end, from building the bundle to inspecting
raw API traffic. Use when unit tests are not enough and you need to verify
behavior through the full pipeline (model API → tool validation → tool
execution).
## Which binary to use
- **Reproducing bugs**: use the globally installed `qwen` command — this matches
what the user ran when they filed the issue.
- **Verifying fixes**: build first (`npm run build && npm run bundle`), then run
`node dist/cli.js` — this tests your local changes.
- **Reproducing bugs**: use the globally installed `qwen` command — this
matches what the user ran when they filed the issue.
- **Verifying fixes**: build first (`npm run build && npm run bundle`), then
run `node dist/cli.js` — this tests your local changes.
## Headless Mode
Run the CLI non-interactively with JSON output (`<qwen>` = `qwen` or
`node dist/cli.js` per above):
Run the CLI non-interactively with JSON output (`<qwen>` = `qwen` or `node
dist/cli.js` per above):
```bash
<qwen> "your prompt here" \
@ -31,12 +37,15 @@ Run the CLI non-interactively with JSON output (`<qwen>` = `qwen` or
The JSON output is a stream of objects. Key types:
- `type: "system"` — init: `tools`, `mcp_servers`, `model`, `permission_mode`
- `type: "assistant"` — model output: `content[].type` is `text`, `tool_use`, or `thinking`
- `type: "user"` — tool results: `content[].type` is `tool_result` with `is_error`
- `type: "assistant"` — model output: `content[].type` is `text`, `tool_use`,
or `thinking`
- `type: "user"` — tool results: `content[].type` is `tool_result` with
`is_error`
- `type: "result"` — final output with `result` text and `usage` stats
Pipe through `jq` to filter the verbose stream, e.g. extract tool-result errors:
`... 2>/dev/null | jq 'select(.type=="user") | .message.content[] | select(.is_error)'`
`... 2>/dev/null | jq 'select(.type=="user") | .message.content[] |
select(.is_error)'`
## Inspecting Raw API Traffic
@ -59,7 +68,11 @@ The bulk is in `request.messages` (conversation history). Trimmed structure:
"request": {
"model": "coder-model",
"messages": [
{ "role": "system|user|assistant", "content": "...", "tool_calls?": [...] }
{
"role": "system|user|assistant",
"content": "...",
"tool_calls?": []
}
],
"tools": [
{
@ -97,7 +110,8 @@ The bulk is in `request.messages` (conversation history). Trimmed structure:
## Interactive Mode (tmux)
Use when you need to verify TUI rendering, test keyboard interactions, or see
what the user sees. Headless mode is simpler when you only need structured output.
what the user sees. Headless mode is simpler when you only need structured
output.
### Launching
@ -154,8 +168,8 @@ tmux kill-session -t test
## MCP Server Testing
For testing MCP tool behavior end-to-end, read `references/mcp-testing.md`. It
covers the setup gotchas (config location, git repo requirement) and includes
a reusable zero-dependency test server template in `scripts/mcp-test-server.js`.
covers the setup gotchas (config location, git repo requirement) and includes a
reusable zero-dependency test server template in `scripts/mcp-test-server.js`.
## Token Usage Stats

View file

@ -10,7 +10,7 @@ the **only** location that works for E2E testing.
Common mistakes that waste time:
- `.mcp.json` — Claude Code convention, not Qwen Code
- `settings.local.json` the JSON schema validation rejects `mcpServers` here
- `settings.local.json` — schema validation rejects `mcpServers` here
- `--mcp-config` CLI flag — does not exist
## Setup
@ -42,8 +42,8 @@ cd /tmp/test-dir && <qwen> "prompt" \
## Writing Test Servers
Use `scripts/mcp-test-server.js` as a template. It's a zero-dependency
JSON-RPC server over stdin/stdout — no npm install needed.
Use `scripts/mcp-test-server.js` as a template. It's a zero-dependency JSON-RPC
server over stdin/stdout — no npm install needed.
To create a server with custom tools, copy the template and edit the
`TOOL_DEFINITIONS` array and the `handleToolCall` function. Each tool definition
@ -55,7 +55,16 @@ Test the server without the CLI by piping JSON-RPC directly:
```bash
node /tmp/my-mcp-server.js << 'EOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
EOF

View file

@ -0,0 +1,141 @@
---
name: feat-dev
description: End-to-end workflow for implementing a non-trivial qwen-code
feature. Covers requirements investigation, design, E2E test planning,
baseline dry-run, implementation, verification, code review, and iteration.
---
# Feature Development Workflow
Use this workflow when implementing a feature in qwen-code that needs design,
behavioral validation, or coordinated changes across multiple files. Each phase
produces a concrete artifact. Do not combine phases; the output of each phase
feeds the next.
## Artifact Paths
Use `.qwen/` paths for planning artifacts:
- `.qwen/design/<feature>.md`
- `.qwen/e2e-tests/<feature>.md`
## Phase 1: Investigate
Understand the requested behavior and the current qwen-code implementation.
Use a code exploration agent when available. Ask it to inspect the relevant
qwen-code areas for:
- Existing feature definitions: tools, parameters, schemas, commands, UI, or
config.
- Runtime wiring: spawning, lifecycle, state, permissions, hooks, and cleanup.
- Edge cases and error handling.
- Integration points and limitations.
In parallel, inspect docs, issues, tests, and nearby implementations that define
or constrain the expected behavior. If no exploration agent is available, do the
same investigation locally.
Output: mental model of current behavior, desired behavior, constraints, and key
file paths with line numbers.
## Phase 2: Design Doc
Write a design doc covering:
- Problem statement and current state, including the behavior gap.
- Proposed changes by layer or component.
- Key design decisions and rationale.
- Files affected.
- Scope boundaries.
- Open questions.
Use prose, tables, and bullets. Avoid code snippets unless essential for a key
data structure. JSON config examples are acceptable.
Output: design doc on disk.
## Phase 3: Test Plan
Use the `e2e-testing` skill to choose test modes. Then write an E2E test plan
covering:
- Test groups by capability: parameter acceptance, core behavior, error
handling, cleanup, and regressions.
- Exact commands and expected behavior before and after implementation.
- Unique tmux session names and temp dirs for independent groups.
- Which groups can be run in parallel by separate `test-engineer` agents.
Output: test plan on disk.
## Phase 4: Dry-Run
Validate the test plan against the current baseline using the globally installed
`qwen` CLI, not the local build.
Spawn `test-engineer` agents for independent test groups when the runtime
supports it. The feature is not implemented yet, so tests should either fail or
show the gap. Iterate the test plan if the dry-run reveals broken commands,
wrong filters, or false positives.
Output: confirmed-working test plan with accurate pre-implementation baseline.
## Phase 5: Implement
Read the relevant source files before editing. Implement the changes described
in the design doc and follow project conventions:
- ESM and strict TypeScript.
- Prettier formatting.
- Collocated tests next to source.
- No speculative abstractions beyond the design.
After implementation:
```bash
npm run build
npm run typecheck
npm run bundle
```
Also run focused unit tests for changed files from the relevant package
directory.
Output: local implementation that builds and passes focused tests.
## Phase 6: Verify
Run the full E2E test plan against the local build with `node dist/cli.js`.
Spawn independent `test-engineer` agents when useful and available.
If tests fail, diagnose, fix, rebuild, re-bundle, and re-test until all groups
pass.
Output: E2E results appended to the test plan.
## Phase 7: Code Review
Run `/review` with a review task listing all changed files. Triage each comment
before acting:
- **Valid**: real bug or meaningful improvement. Fix it.
- **False positive**: reviewer missed context. Skip it.
- **Overthinking**: technically plausible but not worth the complexity. Skip
it.
After fixes, re-run unit tests and a quick E2E sanity check.
Output: clean implementation with valid review findings addressed.
## Phase 8: Wrap Up
Skip unless the user asks. Create the branch, commit with Conventional Commits,
push, and create a draft PR using the project PR template. Post E2E results as a
separate PR comment when applicable.
## Iteration Rules
- If Phase 6 fails, return to Phase 5 and then re-run Phase 6.
- If Phase 7 finds valid issues, fix them and run a quick Phase 6 sanity check.
- Do not loop more than 3 times between Phases 5-7 without asking the user.
- If the test plan is inaccurate, update it and document why.

View file

@ -1,6 +1,7 @@
---
name: qwen-code-claw
description: Use Qwen Code as a Code Agent for code understanding, project generation, features, bug fixes, refactoring, and various programming tasks
description: Use Qwen Code as a Code Agent for code understanding, project
generation, features, bug fixes, refactoring, and various programming tasks
---
# Qwen Code Claw
@ -13,7 +14,8 @@ Use this skill when you need to:
- Generate new projects or add new features
- Review pull requests in the codebase
- Fix bugs or refactor existing code
- Execute various programming tasks such as code review, testing, documentation generation, etc.
- Execute various programming tasks such as code review, testing, documentation
generation, etc.
- Collaborate with other tools and agents to complete complex development tasks
## Install
@ -32,7 +34,8 @@ Check if authentication is already configured:
qwen auth status
```
If authentication exists, skip this section. If not authenticated, check if the `BAILIAN_CODING_PLAN_API_KEY` environment variable exists:
If authentication exists, skip this section. If not authenticated, check if the
`BAILIAN_CODING_PLAN_API_KEY` environment variable exists:
```bash
echo $BAILIAN_CODING_PLAN_API_KEY
@ -44,7 +47,8 @@ echo $BAILIAN_CODING_PLAN_API_KEY
qwen auth coding-plan --region china --key $BAILIAN_CODING_PLAN_API_KEY
```
**If the environment variable does not exist**, interrupt and prompt the user to authenticate via `qwen-oauth` or `coding-plan`:
**If the environment variable does not exist**, interrupt and prompt the user to
authenticate via `qwen-oauth` or `coding-plan`:
```bash
qwen auth
@ -54,35 +58,37 @@ Or configure custom API after launching Qwen Code via `/auth`.
## ACPX Integration
You should always use `acpx` to run Qwen Code over the Agent Client Protocol (ACP) instead of scraping PTY sessions.
You should always use `acpx` to run Qwen Code over the Agent Client Protocol
(ACP) instead of scraping PTY sessions.
To use acpx:
1. **Install acpx globally** (recommended) or use npx:
1. **Install acpx globally** (recommended) or use npx:
```bash
npm install -g acpx@latest
```
```bash
npm install -g acpx@latest
```
Or without installing:
Or without installing:
```bash
npx acpx@latest
```
```bash
npx acpx@latest
```
2. **Install the acpx skill** for full reference:
2. **Install the acpx skill** for full reference:
```bash
npx acpx@latest --skill install acpx
```
```bash
npx acpx@latest --skill install acpx
```
3. **Read the acpx skill reference** for every command, flag, and workflow pattern:
See the [ACPX Reference](#acpx-reference) section below for details
3. **Read the acpx skill reference** for every command, flag, and workflow
pattern: See the [ACPX Reference](#acpx-reference) section below for details
4. **For complete CLI reference with all options and examples**:
https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md
4. **For complete CLI reference with all options and examples**:
https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md
From now on, when you need to delegate work to Qwen Code, use `acpx` instead of spawning raw terminal sessions. For example:
From now on, when you need to delegate work to Qwen Code, use `acpx` instead of
spawning raw terminal sessions. For example:
```bash
npx acpx@latest qwen "fix the failing tests"
@ -90,7 +96,8 @@ npx acpx@latest qwen "refactor the auth module"
npx acpx@latest qwen exec "one-shot: summarize this repo"
```
> **Tip:** You should typically use the `--approve-all` flag to prevent task interruptions.
> **Tip:** You should typically use the `--approve-all` flag to prevent task
> interruptions.
## Practical Workflows
@ -140,15 +147,16 @@ acpx --cwd ~/repos/my-project --approve-all qwen -s pr-123 \
- `--approve-reads` (default): Auto-approve reads/searches, prompt for writes
- `--deny-all`: Deny all permission requests
If every permission request is denied/cancelled and none are approved, `acpx` exits with permission denied.
If every permission request is denied/cancelled and none are approved, `acpx`
exits with permission denied.
## Best Practices
1. Use **named sessions** for organizing different types of development tasks
2. Use `--no-wait` for long-running tasks to avoid blocking
3. Use `--approve-all` for non-interactive batch operations
4. Use `--format json` for automation and script integration
5. Use `--cwd` to manage context across multiple projects
1. Use **named sessions** for organizing different types of development tasks
2. Use `--no-wait` for long-running tasks to avoid blocking
3. Use `--approve-all` for non-interactive batch operations
4. Use `--format json` for automation and script integration
5. Use `--cwd` to manage context across multiple projects
## QwenCode Reference
@ -163,11 +171,13 @@ If every permission request is denied/cancelled and none are approved, `acpx` ex
| `/auth` | Configure authentication |
| `/exit` | Exit Qwen Code |
Full reference: https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/commands.md
Full reference: `docs/users/features/commands.md`.
### Configuration
Config files (highest priority first): CLI args > env vars > system > project (`.qwen/settings.json`) > user (`~/.qwen/settings.json`) > defaults. Format: JSONC with env var interpolation.
Config files (highest priority first): CLI args > env vars > system > project
(`.qwen/settings.json`) > user (`~/.qwen/settings.json`) > defaults. Format:
JSONC with env var interpolation.
Key settings:
@ -178,30 +188,36 @@ Key settings:
| `permissions.allow/ask/deny` | Tool permission rules |
| `mcpServers.*` | MCP server configurations |
Full reference: https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/configuration/settings.md
Full reference: `docs/users/configuration/settings.md`.
### Authentication
Supports Alibaba Cloud Coding Plan, OpenAI-compatible API keys, and Qwen OAuth (free tier discontinued 2026-04-15).
Supports Alibaba Cloud Coding Plan, OpenAI-compatible API keys, and Qwen OAuth
(free tier discontinued 2026-04-15).
Full reference: https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/configuration/auth.md
Full reference: `docs/users/configuration/auth.md`.
### Model Providers
Configure custom model providers via `modelProviders` in settings or environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`).
Configure custom model providers via `modelProviders` in settings or environment
variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`).
Full reference: https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/configuration/model-providers.md
Full reference: `docs/users/configuration/model-providers.md`.
### Key Features
| Feature | Description | Docs |
| ------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Approval Mode | Control tool execution permissions | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/approval-mode.md |
| MCP | Model Context Protocol server integration | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/mcp.md |
| Skills | Reusable skill system via `/skill` | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/skills.md |
| Sub-agents | Delegate tasks to specialized agents | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/sub-agents.md |
| Sandbox | Secure code execution environment | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/sandbox.md |
| Headless | Non-interactive / CI mode | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/headless.md |
- Approval Mode: control tool execution permissions.
See `docs/users/features/approval-mode.md`.
- MCP: Model Context Protocol server integration.
See `docs/users/features/mcp.md`.
- Skills: reusable skill system via `/skill`.
See `docs/users/features/skills.md`.
- Sub-agents: delegate tasks to specialized agents.
See `docs/users/features/sub-agents.md`.
- Sandbox: secure code execution environment.
See `docs/users/features/sandbox.md`.
- Headless: non-interactive or CI mode.
See `docs/users/features/headless.md`.
## ACPX Reference
@ -226,7 +242,10 @@ acpx [global options] cancel [-s <name>]
acpx [global options] set-mode <mode> [-s <name>]
acpx [global options] set <key> <value> [-s <name>]
acpx [global options] status [-s <name>]
acpx [global options] sessions [list | new [--name <name>] | close [name] | show [name] | history [name] [--limit <count>]]
acpx [global options] sessions [
list | new [--name <name>] | close [name] | show [name] |
history [name] [--limit <count>]
]
acpx [global options] config [show | init]
# With explicit agent
@ -235,20 +254,19 @@ acpx [global options] <agent> prompt [options] [prompt text...]
acpx [global options] <agent> exec [options] [prompt text...]
```
> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt from stdin.
> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt
> from stdin.
### Global Options
| Option | Description |
| --------------------- | ------------------------------------------------------------ |
| `--agent <command>` | Raw ACP agent command (fallback mechanism) |
| `--cwd <directory>` | Session working directory |
| `--approve-all` | Auto-approve all requests |
| `--approve-reads` | Auto-approve reads/searches, prompt for writes (default) |
| `--deny-all` | Deny all requests |
| `--format <format>` | Output format: `text`, `json`, `quiet` |
| `--timeout <seconds>` | Maximum wait time (positive integer) |
| `--ttl <seconds>` | Idle TTL for queue owners (default: `300`, `0` disables TTL) |
| `--verbose` | Verbose ACP/debug logs to stderr |
- `--agent <command>`: raw ACP agent command fallback.
- `--cwd <directory>`: session working directory.
- `--approve-all`: auto-approve all requests.
- `--approve-reads`: auto-approve reads/searches, prompt for writes.
- `--deny-all`: deny all requests.
- `--format <format>`: output format, one of `text`, `json`, or `quiet`.
- `--timeout <seconds>`: maximum wait time.
- `--ttl <seconds>`: idle TTL for queue owners.
- `--verbose`: verbose ACP/debug logs to stderr.
Flags are mutually exclusive where applicable.

View file

@ -1,43 +1,42 @@
---
name: structured-debugging
description: >
Hypothesis-driven debugging methodology for hard bugs. Use this skill whenever
you're investigating non-trivial bugs, unexpected behavior, flaky tests, or
tracing issues through complex systems. Activate proactively when debugging
requires more than a quick glance — especially when the first attempt at a fix
didn't work, when behavior seems "impossible", or when you're tempted to blame
an external system (model, API, library) without evidence.
description: Hypothesis-driven debugging methodology for hard bugs. Use this
skill whenever you're investigating non-trivial bugs, unexpected behavior,
flaky tests, or tracing issues through complex systems. Activate proactively
when debugging requires more than a quick glance — especially when the first
attempt at a fix didn't work, when behavior seems "impossible", or when you're
tempted to blame an external system (model, API, library) without evidence.
---
# Structured Debugging
When debugging hard issues, the natural instinct is to form a theory and immediately
apply a fix. This fails more often than it works. The fix addresses the wrong cause,
adds complexity, creates false confidence, and obscures the real issue. Worse, after
several failed attempts you lose track of what's been tried and start guessing randomly.
When debugging hard issues, the natural instinct is to form a theory and
immediately apply a fix. This fails more often than it works. The fix addresses
the wrong cause, adds complexity, creates false confidence, and obscures the
real issue. Worse, after several failed attempts you lose track of what's been
tried and start guessing randomly.
This methodology replaces guessing with a disciplined cycle that converges on the
root cause. Each iteration narrows the search space. It's slower per attempt but
dramatically faster overall because you stop wasting runs on wrong theories.
This methodology replaces guessing with a disciplined cycle that converges on
the root cause. Each iteration narrows the search space. It's slower per attempt
but dramatically faster overall because you stop wasting runs on wrong theories.
## The Cycle
### 1. Hypothesize
Before touching code, write down what you think is happening and why. Be specific
about the expected state at each step in the execution path.
Before touching code, write down what you think is happening and why. Be
specific about the expected state at each step in the execution path.
Bad: "Something is wrong with the wait loop."
Good: "The leader hangs because `hasActiveTeammates()` returns true after all agents
have reported completed, likely because terminal status isn't being set on the agent
object after the backend process exits."
Bad: "Something is wrong with the wait loop." Good: "The leader hangs because
`hasActiveTeammates()` returns true after all agents have reported completed,
likely because terminal status isn't being set on the agent object after the
backend process exits."
For bugs you expect to take more than one round, create a side note file
for the investigation in whichever location the project uses for such
notes.
For bugs you expect to take more than one round, create a side note file for the
investigation in whichever location the project uses for such notes.
Write your hypothesis there. This file persists across conversation turns and even
across sessions — it's your investigation journal.
Write your hypothesis there. This file persists across conversation turns and
even across sessions — it's your investigation journal.
### 2. Design Instrumentation
@ -47,19 +46,18 @@ confirm or reject your hypothesis. Think about what data you need to see.
Don't scatter `console.log` everywhere. Identify the 2-3 places where your
hypothesis makes a testable prediction, and instrument those.
Prefer logging _values_ (return codes, payload contents, stream types,
message bodies, env state) over _presence checks_ ("was this function
called?", "was this branch taken?"). Code-path traces tell you what ran;
data traces tell you what it ran on. Most non-trivial bugs are correct
code processing wrong data.
Prefer logging _values_ (return codes, payload contents, stream types, message
bodies, env state) over _presence checks_ ("was this function called?", "was
this branch taken?"). Code-path traces tell you what ran; data traces tell you
what it ran on. Most non-trivial bugs are correct code processing wrong data.
Ask yourself: "If my hypothesis is correct, what will I see at point X?
If it's wrong, what will I see instead?"
Ask yourself: "If my hypothesis is correct, what will I see at point X? If it's
wrong, what will I see instead?"
### 3. Verify Data Collection
Before running, confirm that your instrumentation output will actually be captured
and accessible.
Before running, confirm that your instrumentation output will actually be
captured and accessible.
Common traps:
@ -73,10 +71,11 @@ A test run that produces no data is wasted.
### 4. Run and Observe
Execute the test. Read the actual output — every line of it. Don't assume what it says.
Execute the test. Read the actual output — every line of it. Don't assume what
it says.
When the data contradicts your hypothesis, believe the data. Don't rationalize it
away. The whole point of this step is to let reality override your theory.
When the data contradicts your hypothesis, believe the data. Don't rationalize
it away. The whole point of this step is to let reality override your theory.
### 5. Document Findings
@ -86,8 +85,8 @@ Update the side note with:
- What was confirmed vs. disproved
- Updated hypothesis for the next iteration
This is critical for not losing context across attempts. Hard bugs typically take
3-5 rounds. Without notes, you'll forget what you ruled out and waste runs
This is critical for not losing context across attempts. Hard bugs typically
take 3-5 rounds. Without notes, you'll forget what you ruled out and waste runs
re-checking things.
### 6. Iterate
@ -105,30 +104,31 @@ notice yourself drifting toward any of them, stop and return to the cycle.
### Jumping to fixes without evidence
The most common failure. You have a plausible theory, so you "fix" it and run again.
If the theory was wrong, you've added complexity, wasted a test run, and possibly
introduced a new bug. The side note should always show "hypothesis verified by
[specific data]" before any fix is applied.
The most common failure. You have a plausible theory, so you "fix" it and run
again. If the theory was wrong, you've added complexity, wasted a test run, and
possibly introduced a new bug. The side note should always show "hypothesis
verified by [specific data]" before any fix is applied.
### Blaming external systems
"The model is hallucinating." "The API is flaky." "The library has a bug." These
conclusions feel satisfying because they put the problem outside your control. They're
also usually wrong.
conclusions feel satisfying because they put the problem outside your control.
They're also usually wrong.
Before blaming an external system, inspect what it actually received. A model that
appears to hallucinate may be responding rationally to stale data you didn't know
was there. An API that appears flaky may be receiving malformed requests. Look at
the inputs, not just the outputs.
Before blaming an external system, inspect what it actually received. A model
that appears to hallucinate may be responding rationally to stale data you
didn't know was there. An API that appears flaky may be receiving malformed
requests. Look at the inputs, not just the outputs.
### Inspecting code paths but not data
You instrument the code and prove it executes correctly — the right functions are
called, in the right order, with no errors. But the bug persists. Why?
You instrument the code and prove it executes correctly — the right functions
are called, in the right order, with no errors. But the bug persists. Why?
Because the code can work perfectly while processing garbage input. A function that
correctly reads an inbox, correctly delivers messages, and correctly formats output
is still broken if the inbox contains stale messages from a previous run.
Because the code can work perfectly while processing garbage input. A function
that correctly reads an inbox, correctly delivers messages, and correctly
formats output is still broken if the inbox contains stale messages from a
previous run.
Always inspect the _content_ flowing through the code, not just whether the code
runs. Check payloads, message contents, file data, and database state.
@ -136,19 +136,18 @@ runs. Check payloads, message contents, file data, and database state.
### Reframing the user's report instead of investigating it
When the user reports a symptom your own run doesn't reproduce, the
contradiction _is_ the evidence — the two environments differ in some way
you haven't identified yet. The wrong move is to reframe their report
("they must be on a stale SHA", "they must be confused about what they
saw", "must be a flake") so that your run becomes the ground truth. Once
you do that, every later piece of evidence gets bent to defend the
reframing, and the actual bug stays hidden.
contradiction _is_ the evidence — the two environments differ in some way you
haven't identified yet. The wrong move is to reframe their report ("they must be
on a stale SHA", "they must be confused about what they saw", "must be a flake")
so that your run becomes the ground truth. Once you do that, every later piece
of evidence gets bent to defend the reframing, and the actual bug stays hidden.
The right move: catalogue what differs between their environment and
yours (TTY vs pipe, terminal emulator, shell, locale, env vars, prior
state, build artifacts) before forming any hypothesis. For ambiguous
symptoms ("no output", "it's slow", "it's wrong") ask one disambiguating
question first — e.g., "does it hang or exit cleanly?" — that prunes the
hypothesis space cheaply before any test run.
The right move: catalogue what differs between their environment and yours (TTY
vs pipe, terminal emulator, shell, locale, env vars, prior state, build
artifacts) before forming any hypothesis. For ambiguous symptoms ("no output",
"it's slow", "it's wrong") ask one disambiguating question first — e.g., "does
does it hang or exit cleanly?" That prunes the hypothesis space before any
test run.
### Losing context across attempts
@ -161,9 +160,9 @@ a new round, re-read it first.
## Persistent State: A Special Category
Features that persist data across runs — caches, session recordings, message queues,
temp files, database rows — are a frequent source of "impossible" bugs. The current
run's behavior is contaminated by leftover state from previous runs.
Features that persist data across runs — caches, session recordings, message
queues, temp files, and database rows often cause "impossible" bugs.
The current run's behavior is contaminated by leftover state from previous runs.
When behavior seems irrational, always check:
@ -175,7 +174,7 @@ This is easy to miss because the code is correct — it's the data that's wrong.
## When to Exit the Cycle
Apply the fix when — and only when you can point to specific data from your
Apply the fix only when you can point to specific data from your
instrumentation that confirms the root cause. Write in the side note:
```
@ -188,7 +187,7 @@ Then apply the fix, remove instrumentation, and verify with a clean run.
## Worked examples
- [`examples/headless-bg-agent-empty-stdout.md`](examples/headless-bg-agent-empty-stdout.md)
- `examples/headless-bg-agent-empty-stdout.md`
— pipe-captured runs all passed; the user's TTY printed nothing. The
contradiction _was_ the bug. Illustrates _reproduction contradiction is
data_ and _instrument data, not code paths_.
contradiction _was_ the bug. Illustrates _reproduction contradiction is data_
and _instrument data, not code paths_.

View file

@ -1,59 +1,58 @@
# Worked example: headless run prints empty stdout in zsh TTY
A short qwen-code case to illustrate two failure modes from `SKILL.md`:
_reproduction contradiction is data_, and _instrument the data flow, not
just the code path_.
_reproduction contradiction is data_, and _instrument the data flow, not just
the code path_.
## The bug
User: `npm run dev -- -p "..."` in zsh prints nothing. Process exits clean,
`~/.qwen/logs` shows the model returned proper text. Stdout was empty.
Cause: `JsonOutputAdapter.emitResult` wrote `resultMessage.result` without
a trailing `\n`. zsh's `PROMPT_SP` (powerlevel10k, agnoster, …) detects
the missing newline and emits `\r\033[K` before drawing the next prompt,
erasing the line. Pipe-captured stdout has no `PROMPT_SP`, so the bug is
invisible there.
Cause: `JsonOutputAdapter.emitResult` wrote `resultMessage.result` without a
trailing `\n`. zsh's `PROMPT_SP` (powerlevel10k, agnoster, …) detects the
missing newline and emits `\r\033[K` before drawing the next prompt, erasing the
line. Pipe-captured stdout has no `PROMPT_SP`, so the bug is invisible there.
Fix: append `\n` to the write.
## What made the case instructive
Every reproduction attempt from a debugging environment that captures
stdout (Cursor's Shell tool, `out=$(...)`, `tee`, file redirect) **passed**.
14/14 success against the user's 0/N. Same SHA, same machine, same
command. The only variable was: pipe stdout vs TTY stdout.
Every reproduction attempt from a debugging environment that captures stdout
(Cursor's Shell tool, `out=$(...)`, `tee`, file redirect) **passed**. 14/14
success against the user's 0/N. Same SHA, same machine, same command. The only
variable was: pipe stdout vs TTY stdout.
That contradiction was the entire investigation. Once it was named, the
fix was one line.
That contradiction was the entire investigation. Once it was named, the fix was
one line.
## Lessons mapped to SKILL.md
- **Reproduction contradiction is data, not user error.** When your run
succeeds and the user's fails on identical state, the _difference
between the two environments_ is where the bug lives. Catalogue what
differs (TTY vs pipe, terminal emulator, shell, locale, env vars,
prior state) before forming any hypothesis. Reframing the user's
report ("they must be on stale code") burns rounds and credibility.
succeeds and the user's fails on identical state, the _difference between the
two environments_ is where the bug lives. Catalogue what differs (TTY vs pipe,
terminal emulator, shell, locale, env vars, prior state) before forming any
hypothesis. Reframing the user's report ("they must be on stale code") burns
rounds and credibility.
- **Ask the one disambiguating question first.** "Does it hang or exit
cleanly?" would have falsified the most tempting wrong hypothesis here
(the recently-fixed drain-loop hang) on turn one. For any "no output"
report, that question is free and prunes half the hypothesis space.
cleanly?" would have falsified the most tempting wrong hypothesis here (the
recently-fixed drain-loop hang) on turn one. For any "no output" report, that
question is free and prunes half the hypothesis space.
- **Instrument the data flow, not just the code path.** Tracing whether
`write` was called showed the happy path firing every time and resolved
nothing. The breakthrough was logging the _return value_ of
`process.stdout.write` together with `process.stdout.isTTY`. Code-path
traces tell you what ran; data traces tell you what it ran on.
- **Instrument the data flow, not just the code path.** Tracing whether `write`
was called showed the happy path firing every time and resolved nothing. The
breakthrough was logging the _return value_ of `process.stdout.write` together
with `process.stdout.isTTY`. Code-path traces tell you what ran; data traces
tell you what it ran on.
- **Pipe ≠ TTY.** A passing pipe-captured run does not prove a TTY user
sees the same output. Shell prompts can post-process trailing-newline-
less writes; terminals can swallow control sequences; pipes do
neither. When debugging interactive-shell symptoms, get evidence from
the user's actual terminal at least once.
- **Pipe ≠ TTY.** A passing pipe-captured run does not prove a TTY user sees
the same output. Shell prompts can post-process trailing-newline- less writes;
terminals can swallow control sequences; pipes do neither. When debugging
interactive-shell symptoms, get evidence from the user's actual terminal at
least once.
## Reference
Fix commit: qwen-code `feadf052f`
`fix(cli): append newline to text-mode emitResult so zsh PROMPT_SP doesn't erase the line`
Fix commit: qwen-code `feadf052f` `fix(cli): append newline to text-mode
emitResult so zsh PROMPT_SP doesn't erase the line`

View file

@ -1,35 +1,44 @@
---
name: terminal-capture
description: Automates terminal UI screenshot testing for CLI commands. Applies when reviewing PRs that affect CLI output, testing slash commands (/about, /context, /auth, /export), generating visual documentation, or when 'terminal screenshot', 'CLI test', 'visual test', or 'terminal-capture' is mentioned.
description: Automates terminal UI screenshot testing for CLI commands. Applies
when reviewing PRs that affect CLI output, testing slash commands (/about,
/context, /auth, /export), generating visual documentation, or when 'terminal
screenshot', 'CLI test', 'visual test', or 'terminal-capture' is mentioned.
---
# Terminal Capture — CLI Terminal Screenshot Automation
Drive terminal interactions and screenshots via TypeScript configuration, used for visual verification during PR reviews.
Drive terminal interactions and screenshots via TypeScript configuration, used
for visual verification during PR reviews.
## Prerequisites
Ensure the following dependencies are installed before running:
```bash
npm install # Install project dependencies (including node-pty, xterm, playwright, etc.)
npm install # Install project dependencies.
npx playwright install chromium # Install Playwright browser
```
## Architecture
```
node-pty (pseudo-terminal) → ANSI byte stream → xterm.js (Playwright headless) → Screenshot
node-pty (pseudo-terminal)
→ ANSI byte stream
→ xterm.js (Playwright headless)
→ Screenshot
```
Core files:
| File | Purpose |
| -------------------------------------------------------- | ------------------------------------------------------------------------ |
| `integration-tests/terminal-capture/terminal-capture.ts` | Low-level engine (PTY + xterm.js + Playwright) |
| `integration-tests/terminal-capture/scenario-runner.ts` | Scenario executor (parses config, drives interactions, auto-screenshots) |
| `integration-tests/terminal-capture/run.ts` | CLI entry point (batch run scenarios) |
| `integration-tests/terminal-capture/scenarios/*.ts` | Scenario configuration files |
- `integration-tests/terminal-capture/terminal-capture.ts`
Low-level PTY, xterm.js, and Playwright engine.
- `integration-tests/terminal-capture/scenario-runner.ts`
Scenario executor for config, interactions, and screenshots.
- `integration-tests/terminal-capture/run.ts`
CLI entry point for batch scenario runs.
- `integration-tests/terminal-capture/scenarios/*.ts`
Scenario configuration files.
## Quick Start
@ -43,7 +52,8 @@ import type { ScenarioConfig } from '../scenario-runner.js';
export default {
name: '/about',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' }, // Relative to this config file's location
// cwd is relative to this config file's location.
terminal: { title: 'qwen-code', cwd: '../../..' },
flow: [
{ type: 'Hi, can you help me understand this codebase?' },
{ type: '/about' },
@ -55,15 +65,18 @@ export default {
```bash
# Single scenario
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
npx tsx integration-tests/terminal-capture/run.ts \
integration-tests/terminal-capture/scenarios/about.ts
# Batch (entire directory)
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/
npx tsx integration-tests/terminal-capture/run.ts \
integration-tests/terminal-capture/scenarios/
```
### 3. Output
Screenshots are saved to `integration-tests/terminal-capture/scenarios/screenshots/{name}/`:
Screenshots are saved to
`integration-tests/terminal-capture/scenarios/screenshots/{name}/`:
| File | Description |
| --------------- | ---------------------------------- |
@ -79,7 +92,8 @@ Each flow step can contain the following fields:
### `type: string` — Input Text
Automatic behavior: Input text → Screenshot (01) → Press Enter → Wait for output to stabilize → Screenshot (02).
Automatic behavior:
Input text → Screenshot (01) → Enter → stable output → Screenshot (02).
```typescript
{
@ -90,13 +104,17 @@ Automatic behavior: Input text → Screenshot (01) → Press Enter → Wait for
} // Slash command (auto-completion handled automatically)
```
**Special rule**: If the next step is `key`, do not auto-press Enter (hand over control to the key sequence).
**Special rule**: If the next step is `key`, do not auto-press Enter (hand over
control to the key sequence).
### `key: string | string[]` — Send Key Press
Used for menu selection, Tab completion, and other interactions. Does not auto-press Enter or auto-screenshot.
Used for menu selection, Tab completion, and other interactions. Does not
auto-press Enter or auto-screenshot.
Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, `Tab`, `Escape`, `Backspace`, `Space`, `Home`, `End`, `PageUp`, `PageDown`, `Delete`
Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`,
`Tab`, `Escape`, `Backspace`, `Space`, `Home`, `End`, `PageUp`, `PageDown`,
`Delete`
```typescript
{
@ -107,11 +125,13 @@ Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`,
} // Multiple keys
```
Auto-screenshot is triggered after the key sequence ends (when the next step is not a `key`).
Auto-screenshot is triggered after the key sequence ends (when the next step is
not a `key`).
### `streaming` — Capture During Execution
Capture multiple screenshots at intervals during long-running output (e.g., progress bars). Optionally generates an animated GIF.
Capture multiple screenshots at intervals during long-running output (e.g.,
progress bars). Optionally generates an animated GIF.
```typescript
{
@ -125,11 +145,15 @@ Capture multiple screenshots at intervals during long-running output (e.g., prog
}
```
- `delayMs` (optional): Milliseconds to wait after pressing Enter before starting captures. Useful for skipping model thinking/approval time.
- Captures stop early if terminal output is unchanged for 3 consecutive intervals.
- `delayMs` (optional): Milliseconds to wait after pressing Enter before
starting captures. Useful for skipping model thinking/approval time.
- Captures stop early if terminal output is unchanged for 3 consecutive
intervals.
- Duplicate frames (no output change) are automatically skipped.
**GIF prerequisite**: If the scenario uses `streaming` with GIF enabled (default), check if `ffmpeg` is installed before running. If not, ask the user whether they'd like to install it:
**GIF prerequisite**: If the scenario uses `streaming` with GIF enabled
(default), check if `ffmpeg` is installed before running. If not, ask the user
whether they'd like to install it:
```bash
# Check
@ -139,7 +163,8 @@ which ffmpeg
brew install ffmpeg
```
If the user declines, the scenario still runs — GIF generation is skipped with a warning.
If the user declines, the scenario still runs. GIF generation is skipped with a
warning.
### `capture` / `captureFull` — Explicit Screenshot
@ -200,12 +225,18 @@ This tool is commonly used for visual verification during PR reviews.
## Troubleshooting
| Issue | Cause | Solution |
| ------------------------------------ | ------------------------------------- | ---------------------------------------------------- |
| Playwright error `browser not found` | Browser not installed | `npx playwright install chromium` |
| Blank screenshot | Process starts slowly or build failed | Ensure `npm run build` succeeds, check spawn command |
| PTY-related errors | node-pty native module not compiled | `npm rebuild node-pty` |
| Unstable screenshot output | Terminal output not fully rendered | Check if the scenario needs additional wait time |
- Playwright error `browser not found`
Cause: browser not installed.
Solution: `npx playwright install chromium`.
- Blank screenshot
Cause: process starts slowly or build failed.
Solution: check build success and the spawn command.
- PTY-related errors
Cause: node-pty native module not compiled.
Solution: `npm rebuild node-pty`.
- Unstable screenshot output
Cause: terminal output not fully rendered.
Solution: add scenario wait time.
## Full ScenarioConfig Type

118
AGENTS.md
View file

@ -1,6 +1,7 @@
# AGENTS.md
This file provides guidance to Qwen Code when working with code in this repository.
This file provides guidance to Qwen Code when working with code in this
repository.
## Common Commands
@ -10,14 +11,27 @@ This file provides guidance to Qwen Code when working with code in this reposito
npm install # Install all dependencies
npm run build # Build all packages (TypeScript compilation + asset copying)
npm run build:all # Build everything including sandbox container
npm run bundle # Bundle dist/ into a single dist/cli.js via esbuild (requires build first)
npm run bundle # Bundle dist/ into a single dist/cli.js via esbuild
# (requires build first)
```
`npm run build` compiles TS into each package's `dist/`. `npm run bundle` takes that output and produces a single `dist/cli.js` via esbuild. Bundle requires build to have run first.
`npm run build` compiles TS into each package's `dist/`. `npm run bundle`
takes that output and produces a single `dist/cli.js` via esbuild. Bundle
requires build to have run first.
### Development
```bash
npm run dev # Run CLI directly from TypeScript source (no build needed)
```
Runs the CLI via `tsx` with `DEV=true`. Changes to `packages/core` or
`packages/cli` are reflected immediately without rebuilding.
### Unit Testing
Tests must be run from within the specific package directory, not the project root.
Tests must be run from within the specific package directory, not the project
root.
**Run individual test files** (always preferred):
@ -35,12 +49,14 @@ cd packages/cli && npx vitest run src/path/to/file.test.ts --update
**Avoid:**
- `npm run test -- --filter=...` — does NOT filter; runs the entire suite
- `npx vitest` from the project root — fails due to package-specific vitest configs
- `npx vitest` from the project root — fails due to package-specific vitest
configs
- Running the whole test suite unless necessary (e.g., final PR verification)
**Test gotchas:**
- In CLI tests, use `vi.hoisted()` for mocks consumed by `vi.mock()` — the mock factory runs at module load time, before test execution.
- In CLI tests, use `vi.hoisted()` for mocks consumed by `vi.mock()` — the
mock factory runs at module load time, before test execution.
### Integration Testing
@ -56,10 +72,12 @@ npm run test:integration:interactive:sandbox:none
Or combined in one command:
```bash
cd integration-tests && cross-env QWEN_SANDBOX=false npx vitest run cli interactive
cd integration-tests && \
cross-env QWEN_SANDBOX=false npx vitest run cli interactive
```
**Gotcha:** In interactive tests, always call `session.idle()` between sends — ANSI output streams asynchronously.
**Gotcha:** In interactive tests, always call `session.idle()` between sends —
ANSI output streams asynchronously.
### Linting & Formatting
@ -68,25 +86,91 @@ npm run lint # ESLint check
npm run lint:fix # Auto-fix lint issues
npm run format # Prettier formatting
npm run typecheck # TypeScript type checking
npm run preflight # Full check: clean → install → format → lint → build → typecheck → test
npm run preflight # Full check: clean → install → format → lint → build
# → typecheck → test
```
## Code Conventions
- **Module system**: ESM throughout (`"type": "module"` in all packages)
- **TypeScript**: Strict mode with `noImplicitAny`, `strictNullChecks`, `noUnusedLocals`, `verbatimModuleSyntax`
- **Formatting**: Prettier — single quotes, semicolons, trailing commas, 2-space indent, 80-char width
- **Linting**: No `any` types, consistent type imports, no relative imports between packages
- **Tests**: Collocated with source (`file.test.ts` next to `file.ts`), vitest framework
- **TypeScript**: Strict mode with `noImplicitAny`, `strictNullChecks`,
`noUnusedLocals`, `verbatimModuleSyntax`
- **Formatting**: Prettier — single quotes, semicolons, trailing commas,
2-space indent, 80-char width
- **Linting**: No `any` types, consistent type imports, no relative imports
between packages
- **Tests**: Collocated with source (`file.test.ts` next to `file.ts`),
vitest framework
- **Commits**: Conventional Commits (e.g., `feat(cli): Add --json flag`)
- **Node.js**: Development requires `~20.19.0`; production requires `>=20`
## Development Guidelines
### General workflow
1. **Design doc for non-trivial work** — write one in `.qwen/design/` if the
change touches multiple files or involves design decisions. Skip for small
bugfixes.
2. **Test plan for behavioral changes** — write an E2E test plan in
`.qwen/e2e-tests/` when the change affects user-observable behavior. Dry-run
against the global `qwen` CLI first to confirm the baseline.
3. **Build + typecheck before declaring done**:
`npm run build && npm run typecheck`.
4. **Code review** — run `/review` when available. Triage each comment:
valid / false positive / overthinking.
### Feature development
Use the `/feat-dev` skill for the full workflow: investigate, design, test plan,
dry-run, implement, verify, code review, and iterate.
### Bugfix
Use the `/bugfix` skill for the reproduce-first workflow: reproduce, fix,
verify, test, and code review.
## GitHub Operations
Use the `gh` CLI for all GitHub-related operations — issues, pull requests, comments, CI checks, releases, and API calls. Prefer `gh issue view`, `gh pr view`, `gh pr checks`, `gh run view`, `gh api`, etc. over web fetches or manual REST calls.
Use the `gh` CLI for all GitHub-related operations — issues, pull requests,
comments, CI checks, releases, and API calls. Prefer `gh issue view`,
`gh pr view`, `gh pr checks`, `gh run view`, `gh api`, etc. over web fetches
or manual REST calls.
## Testing, Debugging, and Bug Fixes
- **Bug reproduction & verification**: spawn the `test-engineer` agent. It reads code and docs to understand the bug, then reproduces it via E2E testing (or a test-script fallback). It also handles post-fix verification. It cannot edit source code — only observe and report.
- **Hard bugs**: use the `structured-debugging` skill when debugging requires more than a quick glance — especially when the first attempt at a fix didn't work or the behavior seems impossible.
- **E2E testing**: the `e2e-testing` skill covers headless mode, interactive (tmux) mode, MCP server testing, and API traffic inspection. The `test-engineer` agent invokes this skill internally — you typically don't need to use it directly.
- **Bug reproduction & verification**: spawn the `test-engineer` agent. It
reads code and docs to understand the bug, then reproduces it via E2E testing
(or a test-script fallback). It also handles post-fix verification. It cannot
edit source code — only observe and report.
- **Hard bugs**: use the `structured-debugging` skill when debugging requires
more than a quick glance — especially when the first attempt at a fix didn't
work or the behavior seems impossible.
- **E2E testing**: the `e2e-testing` skill covers headless mode, interactive
(tmux) mode, MCP server testing, and API traffic inspection. The
`test-engineer` agent invokes this skill internally — you typically don't
need to use it directly.
## Submitting PRs
When creating a PR, follow the template at `.github/pull_request_template.md`.
After the PR is submitted, post a separate comment with the E2E test report if
applicable.
- **PR description**: explain the motivation and changes in prose. Avoid
referencing file names or function names.
- **Reviewer Test Plan**: describe behaviors a reviewer should verify and what
to expect, not scripted test commands.
## Project Directories
Project artifacts live under `.qwen/`:
| Directory | Purpose |
| ----------------------- | ------------------------------------ |
| `.qwen/design/` | Design docs for planned features |
| `.qwen/e2e-tests/` | E2E test plans and results |
| `.qwen/issues/` | Issue drafts before filing on GitHub |
| `.qwen/pr-drafts/` | PR drafts before submitting |
| `.qwen/pr-reviews/` | PR review notes |
| `.qwen/investigations/` | Structured debugging journals |
| `.qwen/scripts/` | Utility scripts |

View file

@ -424,7 +424,7 @@ As an open-source terminal agent, you can use Qwen Code in four primary ways:
1. Interactive mode (terminal UI)
2. Headless mode (scripts, CI)
3. IDE integration (VS Code, Zed)
4. TypeScript SDK
4. SDKs (TypeScript, Python, Java)
#### Interactive mode
@ -452,11 +452,38 @@ Use Qwen Code inside your editor (VS Code, Zed, and JetBrains IDEs):
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
- [Use in JetBrains IDEs](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/)
#### TypeScript SDK
#### SDKs
Build on top of Qwen Code with the TypeScript SDK:
Build on top of Qwen Code with the available SDKs:
- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
- TypeScript: [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
- Python: [Use the Python SDK](./packages/sdk-python/README.md)
- Java: [Use the Java SDK](./packages/sdk-java/qwencode/README.md)
Python SDK example:
```python
import asyncio
from qwen_code_sdk import is_sdk_result_message, query
async def main() -> None:
result = query(
"Summarize the repository layout.",
{
"cwd": "/path/to/project",
"path_to_qwen_executable": "qwen",
},
)
async for message in result:
if is_sdk_result_message(message):
print(message["result"])
asyncio.run(main())
```
## Commands & Shortcuts

View file

@ -0,0 +1,376 @@
# Session Title Design
> A 3-7 word sentence-case session title generated by the fast model after
> the first assistant turn. Persisted in the session JSONL with a
> `titleSource: 'auto' | 'manual'` tag, surfaced in the session picker,
> and regeneratable on demand via `/rename --auto`.
## Overview
`/rename` (#3093) lets a user label a session so they can find it again in
the picker later, but until they run it the picker shows the first user
prompt — often truncated mid-sentence, or describing a framing question
rather than what the session actually became about. Manual renaming is
optional friction most users never do.
The goal is to make session names _useful by default_:
- **Descriptive** of what the session actually accomplished, not just the
opening line. 3-7 words, sentence case, git-commit-subject style.
- **Best-effort**: fires in the background after the first reply; if it
fails the user never sees an error.
- **Deferential to the user**: never clobber a `/rename` title the user
chose deliberately, even across CLI tabs on the same session.
- **Explicitly regeneratable** via `/rename --auto` for the "auto title
became stale / I want a fresh one" case.
## Triggers
| Trigger | Conditions | Implementation |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| **Auto** | After `recordAssistantTurn` fires. Skipped if an existing title is set, another attempt is in-flight, cap reached, non-interactive, env disabled, or no fast model. | `ChatRecordingService.maybeTriggerAutoTitle` — fire-and-forget |
| **Manual** | User runs `/rename --auto` | `renameCommand.ts` via `tryGenerateSessionTitle` |
Both paths funnel into a single function — `tryGenerateSessionTitle(config,
signal)` — to guarantee identical prompt, schema, model selection, and
sanitization. The auto trigger is a best-effort background call; the
manual `/rename --auto` is a blocking user action that surfaces a
reason-specific error on failure.
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ packages/core/src/services/ │
│ │
│ ┌──────────────────────────┐ │
│ │ chatRecordingService.ts │ │
│ │ │ │
│ │ recordAssistantTurn() │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ maybeTriggerAutoTitle() │── 6 guards ──→ IIFE(autoTitleController) │
│ │ │ │ │ │
│ │ └── resume hydrate │ ↓ │
│ │ via │ tryGenerateSessionTitle │
│ │ getSessionTitle- │ (sessionTitle.ts) │
│ │ Info │ │ │
│ │ │ ↓ │
│ └──────────────────────────┘ BaseLlmClient.generateJson │
│ (fastModel + JSON schema) │
│ │ │
│ ┌──────────────────────────┐ ↓ │
│ │ sessionService.ts │ sanitizeTitle + sanity checks │
│ │ │ │ │
│ │ getSessionTitleInfo() │◀── cross-process ↓ │
│ │ uses │ re-read recordCustomTitle │
│ │ readLastJsonString- │ before write (…, 'auto') │
│ │ FieldsSync │ │
│ │ (sessionStorageUtils) │ │
│ └──────────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ utils/terminalSafe │ │
│ │ stripTerminalCtrl- │ │
│ │ Sequences │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ packages/cli/src/ui/ │
│ │
│ commands/renameCommand.ts ─── /rename <name> → manual │
│ ─── /rename → kebab │
│ ─── /rename --auto → auto │
│ ─── /rename -- --literal → manual │
│ ─── /rename --unknown-flag → error │
│ │
│ components/SessionPicker.tsx ── dims rows where │
│ session.titleSource === 'auto' │
└─────────────────────────────────────────────────────────────────────────┘
```
### Files
| File | Responsibility |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `packages/core/src/services/sessionTitle.ts` | One-shot LLM call + history filter + sanitize. Exports `tryGenerateSessionTitle`. |
| `packages/core/src/services/chatRecordingService.ts` | `maybeTriggerAutoTitle` trigger, guards, cross-process re-read, abort-on-finalize. |
| `packages/core/src/services/sessionService.ts` | `getSessionTitleInfo` public accessor; `renameSession` accepts `titleSource`. |
| `packages/core/src/utils/sessionStorageUtils.ts` | `extractLastJsonStringFields` + `readLastJsonStringFieldsSync` atomic pair reader. |
| `packages/core/src/utils/terminalSafe.ts` | `stripTerminalControlSequences` shared by sentence-case and kebab paths. |
| `packages/cli/src/ui/commands/renameCommand.ts` | `/rename --auto`, sentinel parser, failure-reason message map. |
| `packages/cli/src/ui/components/SessionPicker.tsx` | Dim styling for `titleSource === 'auto'`. |
## Prompt Design
### System Prompt
Replaces the main agent's system prompt for this single call so the model
only tries to label the session, not behave as a coding assistant.
Bullets below correspond 1:1 with `TITLE_SYSTEM_PROMPT`:
- 3-7 words, sentence case (only first word and proper nouns capitalized).
- No trailing punctuation, no markdown, no quotes.
- Match the dominant language of the conversation; for Chinese, budget
roughly 12-20 characters.
- Be specific about the user's actual goal — name the feature, bug, or
subject area. Avoid vague catch-alls like "Code changes" or "Help
request".
- Four good examples (three English + one Chinese) and four bad examples
(too vague / too long / wrong case / trailing punctuation).
- Return only a JSON object with a single `title` key.
### Structured Output (JSON schema)
Instead of wrapping output in tags (as session-recap does), we use
`BaseLlmClient.generateJson` with a function-calling schema:
```ts
const TITLE_SCHEMA = {
type: 'object',
properties: {
title: {
type: 'string',
description:
'A concise sentence-case session title, 3-7 words, no trailing punctuation.',
},
},
required: ['title'],
};
```
Why function calling rather than free text + tag extraction:
1. Cross-provider reliability — OpenAI-compatible endpoints, Gemini, and
Qwen's native tool-calling all implement function calling; tag parsing
would rely on every model respecting a text convention.
2. No reasoning-preamble leakage — the function call arguments come back
structured, so a "thinking" paragraph before the answer can't bleed
into the title.
3. Simpler post-processing — a single `typeof result.title === 'string'`
check plus `sanitizeTitle` covers every realistic model drift.
The model may still return something the schema allows but the UX
rejects (empty string, whitespace-only, 500 chars, markdown fencing,
control chars). `sanitizeTitle` handles all of these and returns `''`
service returns `{ok: false, reason: 'empty_result'}`.
### Call Parameters
| Parameter | Value | Reason |
| ----------------- | ------------------------------ | ----------------------------------------------------------------------------------------------- |
| `model` | `getFastModel()` — no fallback | Auto-titling on main-model tokens is too expensive to be silent. |
| `schema` | `TITLE_SCHEMA` | Forces `{title: string}`; filters shape drift at the transport layer. |
| `maxOutputTokens` | `100` | More than enough for 7 words plus schema overhead. |
| `temperature` | `0.2` | Mostly deterministic — session titles benefit from stability across regeneration. |
| `maxAttempts` | `1` | Titles are best-effort cosmetic metadata; retries would queue behind user-visible main traffic. |
Contrast with session-recap, which falls back to the main model. Title
generation is triggered automatically and often; silently spending
main-model tokens without a user opt-in is a real bill surprise. Manual
`/rename --auto` explicitly fails with `no_fast_model` rather than
fallback — forcing the user to make the fast-model choice consciously.
## History Filtering
`geminiClient.getChat().getHistory()` returns `Content[]` that includes
tool calls, tool responses (often 10K+ tokens of file content), and model
thought parts. Feeding that raw into the title LLM would bias the label
toward implementation noise like "Called grep on auth module".
`filterToDialog` keeps only `user` / `model` entries with non-empty text
and no `thought` / `thoughtSignature` parts. `takeRecentDialog` slices to
the last 20 messages and refuses to start on a dangling model/tool
response. `flattenToTail` converts to "Role: text" lines and slices the
last 1000 characters.
### The 1000-character tail slice
A session that starts with `help me debug X` but pivots to refactoring Y
should be titled about Y. Titling by the head locks in the opening
framing; titling by the tail captures what the session became.
### UTF-16 surrogate handling
`.slice(-1000)` on a UTF-16 code-unit boundary can orphan a high or low
surrogate if a CJK supplementary char or emoji gets cut. Some providers
respond to the resulting invalid UTF-16 with a 400 — which, without
handling, would burn an attempt for no reason. `flattenToTail` drops a
leading orphaned low surrogate; `sanitizeTitle` scrubs any orphaned
surrogate after the max-length trim on the output path too.
## Persistence
### Record shape
`CustomTitleRecordPayload` grows an optional `titleSource: 'auto' |
'manual'` field:
```jsonc
{
"type": "system",
"subtype": "custom_title",
"systemPayload": {
"customTitle": "Debug login button on mobile",
"titleSource": "auto",
},
}
```
The field is optional, and absent-in-legacy records are treated as
`undefined`. `SessionPicker` dims rows only on a strict `=== 'auto'`
match — a pre-change user `/rename` title is never silently reclassified
as a model guess.
### Resume hydration
On resume, `ChatRecordingService` constructor calls
`sessionService.getSessionTitleInfo(sessionId)` to read **both** the
title and its source. Without hydrating the source, `finalize()`'s
re-append (which runs on every session lifecycle event) would rewrite
auto as manual on every resume cycle — silently stripping the dim
affordance.
### Atomic pair read
`extractLastJsonStringFields` returns `customTitle` and `titleSource`
from the **same matching line** in a single scan. Two separate
`readLastJsonStringFieldSync` calls could land on different records if
an older line has only the primary field, yielding a mismatched pair.
The extractor also requires a proper closing quote on the primary value,
so a crash-truncated trailing record can't win the latest-match race.
### Full-file scan cap
Phase-2 (when the tail-window fast path misses) streams the whole file
in 64KB chunks. Capped at `MAX_FULL_SCAN_BYTES = 64 MB` so a corrupt
multi-GB JSONL can't freeze the session picker on the main event loop.
The picker's latency envelope survives corruption.
### Symlink defense
Session reads open with `O_NOFOLLOW` (falls back to plain read-only on
Windows, where the constant is not exposed). Defense in depth so a
symlink planted in `~/.qwen/projects/<proj>/chats/` can't redirect a
metadata read to an unrelated file.
## Concurrency and Edge Cases
### Trigger guard order
`maybeTriggerAutoTitle` checks six conditions in this exact order — each
short-circuits the rest so the cheap ones run first:
1. `currentCustomTitle` set → skip. Never overwrite manual / prior auto.
2. `autoTitleController !== undefined` → skip. One attempt at a time.
3. `autoTitleAttempts >= 3` → skip. Cap bounds total waste.
4. `!config.isInteractive()` → skip. Headless `qwen -p` / CI never spends
fast-model tokens on a one-shot session.
5. `autoTitleDisabledByEnv()` → skip. `QWEN_DISABLE_AUTO_TITLE=1`
explicit opt-out.
6. `!config.getFastModel()` → skip. No fast-model → no-op.
### Why the cap is 3, not 1
The first assistant turn can be a pure tool-call with no user-visible
text (e.g. the model opens with a `grep`). `tryGenerateSessionTitle`
returns `{ok: false, reason: 'empty_history'}` in that case. Without a
retry window, an entire session's chance at a title would be burned on
turn 1 before the user said anything interesting. Cap of 3 covers the
common "first turn is noise" case while still bounding runaway retry on
a persistently failing fast model.
### Cross-process manual-rename race
Two CLI tabs on the same session file can diverge in memory. Tab A runs
`/rename foo` and writes `titleSource: manual`. Tab B's
`ChatRecordingService` has its own `currentCustomTitle = undefined` and
would naively overwrite with an auto title.
After the LLM call resolves, the IIFE re-reads the JSONL via
`sessionService.getSessionTitleInfo`. If the file shows
`source: 'manual'`, the IIFE bails AND syncs its in-memory state so
subsequent turns respect the rename too. Cost: one 64KB tail read per
successful generation; negligible.
### Abort propagation on `finalize()`
`autoTitleController` doubles as the in-flight flag. `finalize()` (run
on session switch and process shutdown) calls
`autoTitleController.abort()` before re-appending the title record. The
LLM socket is cancelled promptly; session switch doesn't wait on a slow
fast-model call. The IIFE's `finally` block clears
`autoTitleController` only if it's still the active one, so a finalize
mid-flight doesn't race a concurrent `recordAssistantTurn`.
### Manual `/rename` lands mid-flight
Between the IIFE's `await` completing and the `recordCustomTitle('auto')`
call, the user could `/rename foo`. The IIFE re-checks
`this.currentTitleSource === 'manual'` and bails. The in-process check
AND the cross-process re-read both run; manual wins at both layers.
## Configuration
### User-facing knobs
| Setting / env var | Default | Effect |
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------- |
| `fastModel` | unset | Required for auto-titling. Unset → no-op (no main-model fallback). |
| `QWEN_DISABLE_AUTO_TITLE=1` | unset | Opt out of the auto trigger without unsetting `fastModel`. `/rename --auto` still works on request. |
No `settings.json` toggle — the env var is the only user-visible
off-switch. Rationale: the feature is cosmetic and cheap; a settings
toggle would add a UI surface for something that can live as a one-time
env export for the few users who want to disable it.
### Why auto doesn't fall back to the main model
Auto-titling is triggered unconditionally after every assistant turn.
If a user without a fast model were silently charged main-model tokens
for every new session's title, the cost delta is invisible until the
monthly bill arrives. Failing quietly (no-op, no title, no cost) is the
safer default. `/rename --auto` surfaces `no_fast_model` as an
actionable error so the user can set one if they want to.
## Observability
`createDebugLogger('SESSION_TITLE')` emits `debugLogger.warn` from the
generator's catch block. Failures are fully transparent to the user —
auto-title is an auxiliary feature and never throws into the UI.
Developers can grep for the `[SESSION_TITLE]` tag in the debug log
(`~/.qwen/debug/<sessionId>.txt`; `latest.txt` symlinks to the current
session). A working end-to-end call produces no log output; a failing
one gets one WARN line with the underlying error message.
## Security Hardening
The title value is rendered verbatim in the terminal (session picker)
AND persisted in a user-readable JSONL file. Both surfaces are attack
reachable if a compromised or prompt-injected fast model returns
hostile text.
| Concern | Guard |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| ANSI / OSC-8 / CSI injection | `stripTerminalControlSequences` before both JSONL write and picker render. |
| Clickable-link smuggle via OSC-8 | Same — OSC sequences stripped as whole units, not just the ESC byte. |
| Invalid UTF-16 surrogates | Scrubbed in `flattenToTail` (LLM input) and `sanitizeTitle` (LLM output after max-length trim). |
| Subtype-line spoof via user message content | `lineContains: '"subtype":"custom_title"'` — user text that happens to contain the literal phrase can't shadow a real record. |
| Symlink redirect on session reads | `O_NOFOLLOW` (no-op on Windows where the constant is missing). |
| Truncated trailing JSONL record | `extractLastJsonStringFields` requires a closing quote before a record wins the latest-match race. |
| Pathological file size freezing the picker | `MAX_FULL_SCAN_BYTES = 64 MB` cap on Phase-2 full-file scan. |
| Paired CJK bracket decorators (`【Draft】`) | Stripped as a unit so a lone closing bracket doesn't dangle. |
## Out of Scope
| Item | Why not |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Auto-regenerate when the title goes stale | `/rename --auto` is the explicit user-triggered path. Silent mid-session title swaps would confuse users scrolling back through the picker. |
| WebUI / VSCode dim-styling parity | Those surfaces read `customTitle` already and will show auto titles as if manual. A follow-up can wire the `titleSource` through. |
| Settings-dialog toggle for auto generation | Env var is the single knob. Full settings UI is easy to add later if user demand surfaces. |
| i18n locale catalog entries for new strings | Consistent with existing `/rename` strings, which fall through to English. A repo-wide i18n pass is out of scope. |
| Migration to re-classify legacy records | Back-compat by design: absent `titleSource` is treated as manual. Rewriting old records would risk losing user intent. |
| Non-interactive auto-titling | `qwen -p` / CI scripts throw the session away; fast-model tokens for a title no one will ever resume is pure waste. |

View file

@ -11,7 +11,8 @@ export default {
type: 'separator',
},
'sdk-typescript': 'Typescript SDK',
'sdk-java': 'Java SDK(alpha)',
'sdk-python': 'Python SDK (alpha)',
'sdk-java': 'Java SDK (alpha)',
'Dive Into Qwen Code': {
title: 'Dive Into Qwen Code',
type: 'separator',

View file

@ -7,8 +7,7 @@ Learn how to enable and setup OpenTelemetry for Qwen Code.
- [OpenTelemetry Integration](#opentelemetry-integration)
- [Configuration](#configuration)
- [Aliyun Telemetry](#aliyun-telemetry)
- [Prerequisites](#prerequisites)
- [Direct Export (Recommended)](#direct-export-recommended)
- [Manual OTLP Export](#manual-otlp-export)
- [Local Telemetry](#local-telemetry)
- [File-based Output (Recommended)](#file-based-output-recommended)
- [Collector-Based Export (Advanced)](#collector-based-export-advanced)
@ -44,6 +43,11 @@ observability framework — Qwen Code's observability system provides:
instrumentation
[OpenTelemetry]: https://opentelemetry.io/
[aliyun-opentelemetry-overview]: https://www.alibabacloud.com/help/en/arms/tracing-analysis/product-overview/what-is-tracing-analysis
[aliyun-opentelemetry-get-started]: https://www.alibabacloud.com/help/en/arms/tracing-analysis/before-you-begin
[aliyun-opentelemetry-console-cn]: https://trace.console.aliyun.com
[aliyun-opentelemetry-console-cn-legacy]: https://tracing.console.aliyun.com
[aliyun-opentelemetry-console-intl]: https://arms.console.alibabacloud.com
## Configuration
@ -54,15 +58,15 @@ observability framework — Qwen Code's observability system provides:
All telemetry behavior is controlled through your `.qwen/settings.json` file.
These settings can be overridden by environment variables or CLI flags.
| Setting | Environment Variable | CLI Flag | Description | Values | Default |
| -------------- | ------------------------------ | -------------------------------------------------------- | ------------------------------------------------- | ------------------ | ----------------------- |
| `enabled` | `QWEN_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` |
| `target` | `QWEN_TELEMETRY_TARGET` | `--telemetry-target <local\|qwen>` | Where to send telemetry data | `"qwen"`/`"local"` | `"local"` |
| `otlpEndpoint` | `QWEN_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint <URL>` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `QWEN_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol <grpc\|http>` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `outfile` | `QWEN_TELEMETRY_OUTFILE` | `--telemetry-outfile <path>` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `QWEN_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `QWEN_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` |
| Setting | Environment Variable | CLI Flag | Description | Values | Default |
| -------------- | ------------------------------ | -------------------------------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- |
| `enabled` | `QWEN_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` |
| `target` | `QWEN_TELEMETRY_TARGET` | `--telemetry-target <local\|gcp>` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `QWEN_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint <URL>` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `QWEN_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol <grpc\|http>` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `outfile` | `QWEN_TELEMETRY_OUTFILE` | `--telemetry-outfile <path>` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `QWEN_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `QWEN_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` |
**Note on boolean environment variables:** For the boolean settings (`enabled`,
`logPrompts`, `useCollector`), setting the corresponding environment variable to
@ -73,21 +77,55 @@ For detailed information about all configuration options, see the
## Aliyun Telemetry
### Direct Export (Recommended)
### Manual OTLP Export
Sends telemetry directly to Aliyun services. No collector needed.
To view Qwen Code telemetry in Alibaba Cloud Managed Service for
OpenTelemetry, configure Qwen Code to export to the OTLP endpoint
provided by ARMS.
1. Enable telemetry in your `.qwen/settings.json`:
Setting `"target": "gcp"` alone does not configure the export
destination. If `otlpEndpoint` is not set, Qwen Code still defaults to
`http://localhost:4317`. If `outfile` is set, it overrides
`otlpEndpoint` and telemetry is written to the file instead of being
sent to Alibaba Cloud.
1. Enable telemetry in your `.qwen/settings.json` and set the OTLP
endpoint:
```json
{
"telemetry": {
"enabled": true,
"target": "qwen"
"target": "gcp",
"otlpEndpoint": "https://<your-otlp-endpoint>",
"otlpProtocol": "grpc"
}
}
```
2. Run Qwen Code and send prompts.
3. View logs and metrics in the Aliyun Console.
2. If your Alibaba Cloud endpoint requires authentication, provide OTLP
headers through standard OpenTelemetry environment variables such as
`OTEL_EXPORTER_OTLP_HEADERS` (or the signal-specific variants). Qwen
Code does not currently expose OTLP auth headers directly in
`.qwen/settings.json`.
3. Run Qwen Code and send prompts.
4. View telemetry in Managed Service for OpenTelemetry:
- Product overview:
[What is Managed Service for OpenTelemetry?][aliyun-opentelemetry-overview]
- Getting started:
[Get started with Managed Service for OpenTelemetry][aliyun-opentelemetry-get-started]
- Console entry points:
- China mainland:
[trace.console.aliyun.com][aliyun-opentelemetry-console-cn]
(legacy console:
[tracing.console.aliyun.com][aliyun-opentelemetry-console-cn-legacy])
- International:
[arms.console.alibabacloud.com][aliyun-opentelemetry-console-intl]
- In the console, use `Applications` to inspect traces and service
topology.
- To locate the OTLP endpoint and access information:
- **New console** (`trace.console.aliyun.com` or international):
navigate to `Integration Center`.
- **Legacy console** (`tracing.console.aliyun.com`): navigate to
`Cluster Configurations``Access point information`.
## Local Telemetry

View file

@ -0,0 +1,168 @@
# Python SDK
## `qwen-code-sdk`
`qwen-code-sdk` is an experimental Python SDK for Qwen Code. v1 targets the
existing `stream-json` CLI protocol and keeps the transport surface small and
testable.
## Scope
- Package name: `qwen-code-sdk`
- Import path: `qwen_code_sdk`
- Runtime requirement: Python `>=3.10`
- CLI dependency: external `qwen` executable is required in v1
- Transport scope: process transport only
- Not included in v1: ACP transport, SDK-embedded MCP servers
## Install
```bash
pip install qwen-code-sdk
```
If `qwen` is not on `PATH`, pass `path_to_qwen_executable` explicitly.
## Quick Start
```python
import asyncio
from qwen_code_sdk import is_sdk_result_message, query
async def main() -> None:
result = query(
"Explain the repository structure.",
{
"cwd": "/path/to/project",
"path_to_qwen_executable": "qwen",
},
)
async for message in result:
if is_sdk_result_message(message):
print(message["result"])
asyncio.run(main())
```
## API Surface
### Top-level entry points
- `query(prompt, options=None) -> Query`
- `query_sync(prompt, options=None) -> SyncQuery`
`prompt` supports either:
- `str` for single-turn requests
- `AsyncIterable[SDKUserMessage]` for multi-turn streams
### `Query`
- Async iterable over SDK messages
- `close()`
- `interrupt()`
- `set_model(model)`
- `set_permission_mode(mode)`
- `supported_commands()`
- `mcp_server_status()`
- `get_session_id()`
- `is_closed()`
### `QueryOptions`
Supported options in v1:
- `cwd`
- `model`
- `path_to_qwen_executable`
- `permission_mode`
- `can_use_tool`
- `env`
- `system_prompt`
- `append_system_prompt`
- `debug`
- `max_session_turns`
- `core_tools`
- `exclude_tools`
- `allowed_tools`
- `auth_type`
- `include_partial_messages`
- `resume`
- `continue_session`
- `session_id`
- `timeout`
- `mcp_servers`
- `stderr`
Session argument priority is fixed as:
1. `resume`
2. `continue_session`
3. `session_id`
## Permission Handling
When the CLI emits a `can_use_tool` control request, the SDK routes it through
`can_use_tool(tool_name, tool_input, context)`.
- Default behavior: deny
- Default timeout: 60 seconds
- Timeout fallback: deny
- Callback exceptions: converted to deny with an error message
- Callback context: `cancel_event`, `suggestions`, and `blocked_path`
- Callback contract: `can_use_tool` must be async with 3 positional arguments;
`stderr` must accept 1 positional string argument
## Error Model
- `ValidationError`: invalid options, invalid UUIDs, unsupported combinations
- `ControlRequestTimeoutError`: initialize, interrupt, or other control request
timed out
- `ProcessExitError`: CLI exited non-zero
- `AbortError`: control request or session was cancelled
## Troubleshooting
If the SDK cannot start the CLI:
- Verify `qwen --version` works in the target environment
- Pass `path_to_qwen_executable` if your shell uses `nvm`, `pyenv`, or other
non-standard PATH setup
- Use `debug=True` or `stderr=print` to surface CLI stderr while debugging
If session control calls time out:
- Check that the target `qwen` version supports `--input-format stream-json`
- Increase `timeout.control_request`
- Verify that no wrapper script is swallowing stdout/stderr
## Repository Integration
Repository-level helper commands:
- `npm run test:sdk:python`
- `npm run lint:sdk:python`
- `npm run typecheck:sdk:python`
- `npm run smoke:sdk:python -- --qwen qwen`
## Real E2E Smoke
For a real runtime check (actual `qwen` process + real model call), run from
the repository root. The npm helper uses `python3`, so ensure it resolves to a
Python `>=3.10` interpreter:
```bash
npm run smoke:sdk:python -- --qwen qwen
```
This script runs:
- async single-turn query
- async control flow (`supported_commands`, permission mode updates)
- sync `query_sync` query
It prints JSON and returns non-zero on failure.

View file

@ -46,7 +46,6 @@ Qwen Code's built-in tools can be broadly categorized as follows:
- **[File System Tools](./file-system.md):** For interacting with files and directories (reading, writing, listing, searching, etc.).
- **[Shell Tool](./shell.md) (`run_shell_command`):** For executing shell commands.
- **[Web Fetch Tool](./web-fetch.md) (`web_fetch`):** For retrieving content from URLs.
- **[Web Search Tool](./web-search.md) (`web_search`):** For searching the web.
- **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** A specialized tool for reading content from multiple files or directories, often used by the `@` command.
- **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling information across sessions.
- **[Todo Write Tool](./todo-write.md) (`todo_write`):** For creating and managing structured task lists during coding sessions.
@ -58,5 +57,6 @@ Additionally, these tools incorporate:
- **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the model and your local environment or other services like APIs.
- **[MCP Quick Start Guide](../mcp-quick-start.md)**: Get started with MCP in 5 minutes with practical examples
- **[MCP Example Configurations](../mcp-example-configs.md)**: Ready-to-use configurations for common scenarios
- **[Web Search via MCP](./web-search.md)**: Connect to web search services (Bailian, Tavily, GLM) through MCP
- **[MCP Testing & Validation](../mcp-testing-validation.md)**: Test and validate your MCP server setups
- **[Sandboxing](../sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk.

View file

@ -1,185 +1,215 @@
# Web Search Tool (`web_search`)
# Web Search
This document describes the `web_search` tool for performing web searches using multiple providers.
Qwen Code supports web search capabilities through **MCP (Model Context Protocol)** integrations. Rather than a built-in search tool, web search is provided by connecting to external MCP servers, giving you full flexibility to choose the search service that best fits your needs.
## Description
## ⚠️ Breaking Change: Built-in `web_search` Tool Removed
Use `web_search` to perform a web search and get information from the internet. The tool supports multiple search providers and returns a concise answer with source citations when available.
> **Affected versions:** `V0.0.7+` through the last release with built-in web search support.
### Supported Providers
The built-in `web_search` tool and all its associated configuration have been **removed**. If you were using any of the following, you should migrate to the MCP-based approach described in this document:
1. **DashScope** (Official) - Available when explicitly configured in settings (Qwen OAuth free tier auto-injection discontinued 2026-04-15)
2. **Tavily** - High-quality search API with built-in answer generation
3. **Google Custom Search** - Google's Custom Search JSON API
| Removed | What to do |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `webSearch` block in `settings.json` | Configure an MCP server in `mcpServers` instead (see below) |
| `advanced.tavilyApiKey` in `settings.json` | Use the [Tavily MCP server](#tavily-websearch) |
| `TAVILY_API_KEY` environment variable | Use the [Tavily MCP server](#tavily-websearch) |
| `DASHSCOPE_API_KEY` for web search | Use the [Alibaba Cloud Bailian WebSearch MCP](#alibaba-cloud-bailian-websearch-recommended) |
| `GLM_API_KEY` for web search | Use the [GLM WebSearch Prime MCP](#glm-websearch-prime-zhipuai) |
| `--tavily-api-key` / `--glm-api-key` / `--dashscope-api-key` CLI flags | Configure via `mcpServers` in `settings.json` |
### Arguments
### Migration Examples
`web_search` takes two arguments:
- `query` (string, required): The search query
- `provider` (string, optional): Specific provider to use ("dashscope", "tavily", "google")
- If not specified, uses the default provider from configuration
## Configuration
### Method 1: Settings File (Recommended)
Add to your `settings.json`:
**Before (Tavily via built-in tool):**
```json
{
"webSearch": {
"provider": [
{ "type": "dashscope" },
{ "type": "tavily", "apiKey": "tvly-xxxxx" },
{
"type": "google",
"apiKey": "your-google-api-key",
"searchEngineId": "your-search-engine-id"
}
],
"provider": [{ "type": "tavily", "apiKey": "tvly-xxx" }],
"default": "tavily"
}
}
```
**After (Tavily via MCP):**
```json
{
"mcpServers": {
"tavily": {
"httpUrl": "https://mcp.tavily.com/mcp/?tavilyApiKey=tvly-xxx"
}
}
}
```
---
**Before (DashScope via built-in tool):**
```json
{
"webSearch": {
"provider": [{ "type": "dashscope", "apiKey": "sk-xxx" }],
"default": "dashscope"
}
}
```
**Notes:**
- DashScope doesn't require an API key (official, free service)
- **Qwen OAuth users:** DashScope is automatically added to your provider list, even if not explicitly configured
- Configure additional providers (Tavily, Google) if you want to use them alongside DashScope
- Set `default` to specify which provider to use by default (if not set, priority order: Tavily > Google > DashScope)
### Method 2: Environment Variables
Set environment variables in your shell or `.env` file:
```bash
# Tavily
export TAVILY_API_KEY="tvly-xxxxx"
# Google
export GOOGLE_API_KEY="your-api-key"
export GOOGLE_SEARCH_ENGINE_ID="your-engine-id"
```
### Method 3: Command Line Arguments
Pass API keys when running Qwen Code:
```bash
# Tavily
qwen --tavily-api-key tvly-xxxxx
# Google
qwen --google-api-key your-key --google-search-engine-id your-id
# Specify default provider
qwen --web-search-default tavily
```
### Backward Compatibility (Deprecated)
⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated:
**After (Alibaba Cloud Bailian WebSearch via MCP):**
```json
{
"advanced": {
"tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated
"mcpServers": {
"WebSearch": {
"httpUrl": "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp",
"headers": {
"Authorization": "Bearer sk-xxx"
}
}
}
}
```
**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration.
---
## Disabling Web Search
## Supported MCP Web Search Services
If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`:
### Alibaba Cloud Bailian WebSearch (Recommended)
The official web search MCP service provided by Alibaba Cloud Bailian platform, powered by DashScope.
- **MCP Marketplace:** https://bailian.console.aliyun.com/cn-beijing?tab=mcp#/mcp-market/detail/WebSearch
- **Cost:** Paid (billed via Alibaba Cloud DashScope)
- **Get API Key:** https://help.aliyun.com/zh/model-studio/get-api-key
- **Best for:** Chinese-language queries, access to Chinese web content, integration with the Alibaba Cloud ecosystem
#### Setup
**Method 1: CLI command**
```bash
qwen mcp add WebSearch \
-t http \
"https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp" \
-H "Authorization: Bearer ${DASHSCOPE_API_KEY}"
```
**Method 2: `settings.json`**
```json
{
"tools": {
"exclude": ["web_search"]
"mcpServers": {
"WebSearch": {
"httpUrl": "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp",
"headers": {
"Authorization": "Bearer ${DASHSCOPE_API_KEY}"
}
}
}
}
```
**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured.
Replace `${DASHSCOPE_API_KEY}` with your actual API key, or set it as an environment variable so Qwen Code picks it up automatically.
## Usage Examples
---
### Basic search (using default provider)
### Tavily WebSearch
```
web_search(query="latest advancements in AI")
A production-ready MCP server providing real-time web search, extract, map, and crawl capabilities.
- **Repository:** https://github.com/tavily-ai/tavily-mcp
- **Cost:** Paid (free tier available)
- **Get API Key:** https://app.tavily.com/home
- **Best for:** General-purpose web search with high-quality AI-generated answers
#### Available Tools
- `tavily_search` — Real-time web search
- `tavily_extract` — Intelligent data extraction from web pages
- `tavily_map` — Create a structured map of a website
- `tavily_crawl` — Systematically explore websites
#### Setup
**Method 1: CLI command (Remote MCP)**
```bash
qwen mcp add tavily \
-t http \
"https://mcp.tavily.com/mcp/?tavilyApiKey=${TAVILY_API_KEY}"
```
### Search with specific provider
**Method 2: `settings.json` (Remote MCP)**
```
web_search(query="latest advancements in AI", provider="tavily")
```json
{
"mcpServers": {
"tavily": {
"httpUrl": "https://mcp.tavily.com/mcp/?tavilyApiKey=${TAVILY_API_KEY}"
}
}
}
```
### Real-world examples
Replace `${TAVILY_API_KEY}` with your actual API key, or set it as an environment variable.
```
web_search(query="weather in San Francisco today")
web_search(query="latest Node.js LTS version", provider="google")
web_search(query="best practices for React 19", provider="dashscope")
**Method 3: `settings.json` (Local NPX)**
```json
{
"mcpServers": {
"tavily-mcp": {
"command": "npx",
"args": ["-y", "tavily-mcp@latest"],
"env": {
"TAVILY_API_KEY": "your-api-key-here"
}
}
}
}
```
## Provider Details
---
### DashScope (Official)
### GLM WebSearch Prime (ZhipuAI)
- **Cost:** Free (requires Qwen OAuth credentials)
- **Authentication:** Requires Qwen OAuth credentials
- **Configuration:** Must be explicitly configured in `settings.json` web search providers (auto-injection for Qwen OAuth users was removed when the free tier was discontinued on 2026-04-15)
- **Quota:** 200 requests/minute, 100 requests/day
- **Best for:** General queries when you have Qwen OAuth credentials
The official web search Remote MCP service provided by ZhipuAI (智谱AI), designed for GLM Coding Plan users. Provides real-time web search including news, stock prices, weather, and more.
### Tavily
- **Documentation:** https://docs.bigmodel.cn/cn/coding-plan/mcp/search-mcp-server
- **Cost:** Included in GLM Coding Plan subscription (Lite: 100 calls/month, Pro: 1,000/month, Max: 4,000/month)
- **Get API Key:** https://open.bigmodel.cn/apikey/platform
- **Best for:** Chinese-language queries, real-time information retrieval
- **Cost:** Requires API key (paid service with free tier)
- **Sign up:** https://tavily.com
- **Features:** High-quality results with AI-generated answers
- **Best for:** Research, comprehensive answers with citations
#### Available Tools
### Google Custom Search
- `webSearchPrime` — Web search returning page title, URL, summary, site name, and favicon
- **Cost:** Free tier available (100 queries/day)
- **Setup:**
1. Enable Custom Search API in Google Cloud Console
2. Create a Custom Search Engine at https://programmablesearchengine.google.com
- **Features:** Google's search quality
- **Best for:** Specific, factual queries
#### Setup
## Important Notes
**Method 1: CLI command**
- **Response format:** Returns a concise answer with numbered source citations
- **Citations:** Source links are appended as a numbered list: [1], [2], etc.
- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter
- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed
- **Default provider selection:** The system automatically selects a default provider based on availability:
1. Your explicit `default` configuration (highest priority)
2. CLI argument `--web-search-default`
3. First available provider by priority: Tavily > Google > DashScope
```bash
qwen mcp add web-search-prime \
-t http \
"https://open.bigmodel.cn/api/mcp/web_search_prime/mcp" \
-H "Authorization: Bearer ${GLM_API_KEY}"
```
## Troubleshooting
**Method 2: `settings.json`**
**Tool not available?**
```json
{
"mcpServers": {
"web-search-prime": {
"httpUrl": "https://open.bigmodel.cn/api/mcp/web_search_prime/mcp",
"headers": {
"Authorization": "Bearer ${GLM_API_KEY}"
}
}
}
}
```
- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed
- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured
- For Tavily/Google: Verify your API keys are correct
Replace `${GLM_API_KEY}` with your actual ZhipuAI API key, or set it as an environment variable.
**Provider-specific errors?**
- Use the `provider` parameter to try a different search provider
- Check your API quotas and rate limits
- Verify API keys are properly set in configuration
**Need help?**
- Check your configuration: Run `qwen` and use the settings dialog
- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows)
---

View file

@ -223,7 +223,6 @@ The `extra_body` field allows you to add custom parameters to the request body s
| `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore files when searching. | `true` |
| `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` |
| `context.fileFiltering.enableFuzzySearch` | boolean | When `true`, enables fuzzy search capabilities when searching for files. Set to `false` to improve performance on projects with a large number of files. | `true` |
| `context.clearContextOnIdle.thinkingThresholdMinutes` | number | Minutes of inactivity before clearing old thinking blocks to free context tokens. Aligns with typical provider prompt-cache TTL. Use `-1` to disable. | `5` |
| `context.clearContextOnIdle.toolResultsThresholdMinutes` | number | Minutes of inactivity before clearing old tool result content. Use `-1` to disable. | `60` |
| `context.clearContextOnIdle.toolResultsNumToKeep` | number | Number of most-recent compactable tool results to preserve when clearing. Floor at 1. | `5` |
@ -430,11 +429,6 @@ LSP server configuration is done through `.lsp.json` files in your project root
| `advanced.dnsResolutionOrder` | string | The DNS resolution order. | `undefined` |
| `advanced.excludedEnvVars` | array of strings | Environment variables to exclude from project context. Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded. | `["DEBUG","DEBUG_MODE"]` |
| `advanced.bugCommand` | object | Configuration for the bug report command. Overrides the default URL for the `/bug` command. Properties: `urlTemplate` (string): A URL that can contain `{title}` and `{info}` placeholders. Example: `"bugCommand": { "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" }` | `undefined` |
| `advanced.tavilyApiKey` | string | API key for Tavily web search service. Used to enable the `web_search` tool functionality. | `undefined` |
> [!note]
>
> **Note about advanced.tavilyApiKey:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
#### mcpServers
@ -571,7 +565,6 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe
| `CLI_TITLE` | Set to a string to customize the title of the CLI. | |
| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. |
| `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code uses an adaptive strategy: starts with 8K tokens and automatically retries with 64K if the response is truncated. Set this to a specific value (e.g., `16000`) to use a fixed limit instead. | Takes precedence over the capped default (8K) but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` |
| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` |
| `QWEN_CODE_UNATTENDED_RETRY` | Set to `true` or `1` to enable persistent retry mode. When enabled, transient API capacity errors (HTTP 429 Rate Limit and 529 Overloaded) are retried indefinitely with exponential backoff (capped at 5 minutes per retry) and heartbeat keepalives every 30 seconds on stderr. | Designed for CI/CD pipelines and background automation where long-running tasks should survive temporary API outages. Must be set explicitly — `CI=true` alone does **not** activate this mode. See [Headless Mode](../features/headless#persistent-retry-mode) for details. Example: `export QWEN_CODE_UNATTENDED_RETRY=1` |
| `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process. Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` |
@ -620,7 +613,6 @@ For sandbox image selection, precedence is:
| `--version` | | Displays the version of the CLI. | | |
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
## Context Files (Hierarchical Instructional Context)

View file

@ -333,7 +333,6 @@ tools:
- read_file
- write_file
- read_many_files
- web_search
---
You are a technical documentation specialist.

View file

@ -1,126 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe('web_search', () => {
it('should be able to search the web', async () => {
// Check if any web search provider is available
const hasTavilyKey = !!process.env['TAVILY_API_KEY'];
const hasGoogleKey =
!!process.env['GOOGLE_API_KEY'] &&
!!process.env['GOOGLE_SEARCH_ENGINE_ID'];
// Skip if no provider is configured
// Note: DashScope provider is automatically available for Qwen OAuth users,
// but we can't easily detect that in tests without actual OAuth credentials
if (!hasTavilyKey && !hasGoogleKey) {
console.warn(
'Skipping web search test: No web search provider configured. ' +
'Set TAVILY_API_KEY or GOOGLE_API_KEY+GOOGLE_SEARCH_ENGINE_ID environment variables.',
);
return;
}
const rig = new TestRig();
// Configure web search in settings if provider keys are available
const webSearchSettings: Record<string, unknown> = {};
const providers: Array<{
type: string;
apiKey?: string;
searchEngineId?: string;
}> = [];
if (hasTavilyKey) {
providers.push({ type: 'tavily', apiKey: process.env['TAVILY_API_KEY'] });
}
if (hasGoogleKey) {
providers.push({
type: 'google',
apiKey: process.env['GOOGLE_API_KEY'],
searchEngineId: process.env['GOOGLE_SEARCH_ENGINE_ID'],
});
}
if (providers.length > 0) {
webSearchSettings.webSearch = {
provider: providers,
default: providers[0]?.type,
};
}
await rig.setup('should be able to search the web', {
settings: webSearchSettings,
});
let result;
try {
result = await rig.run(`what is the weather in London`);
} catch (error) {
// Network errors can occur in CI environments
if (
error instanceof Error &&
(error.message.includes('network') || error.message.includes('timeout'))
) {
console.warn(
'Skipping test due to network error:',
(error as Error).message,
);
return; // Skip the test
}
throw error; // Re-throw if not a network error
}
const foundToolCall = await rig.waitForToolCall('web_search');
// Add debugging information
if (!foundToolCall) {
const allTools = printDebugInfo(rig, result);
// Check if the tool call failed due to network issues
const failedSearchCalls = allTools.filter(
(t) => t.toolRequest.name === 'web_search' && !t.toolRequest.success,
);
if (failedSearchCalls.length > 0) {
console.warn(
'web_search tool was called but failed, possibly due to network issues',
);
console.warn(
'Failed calls:',
failedSearchCalls.map((t) => t.toolRequest.args),
);
return; // Skip the test if network issues
}
}
expect(foundToolCall, 'Expected to find a call to web_search').toBeTruthy();
// Validate model output - will throw if no output, warn if missing expected content
const hasExpectedContent = validateModelOutput(
result,
['weather', 'london'],
'Web search test',
);
// If content was missing, log the search queries used
if (!hasExpectedContent) {
const searchCalls = rig
.readToolLogs()
.filter((t) => t.toolRequest.name === 'web_search');
if (searchCalls.length > 0) {
console.warn(
'Search queries used:',
searchCalls.map((t) => t.toolRequest.args),
);
}
}
});
});

View file

@ -530,7 +530,6 @@ const TOOL_DISPLAY_NAME_BY_NAME = {
skill: 'Skill',
exit_plan_mode: 'ExitPlanMode',
web_fetch: 'WebFetch',
web_search: 'WebSearch',
list_directory: 'ListFiles',
};
@ -546,7 +545,6 @@ const TOOL_KIND_BY_NAME = {
rename: 'move',
grep_search: 'search',
glob: 'search',
web_search: 'search',
list_directory: 'search',
run_shell_command: 'execute',
bash: 'execute',

View file

@ -905,7 +905,6 @@ describe('Permission Control (E2E)', () => {
'grep_search',
'glob',
'list_directory',
'web_search',
'web_fetch',
];

25
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.15.0",
"version": "0.15.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.15.0",
"version": "0.15.2",
"workspaces": [
"packages/*",
"packages/channels/base",
@ -12842,7 +12842,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@ -16860,7 +16859,7 @@
},
"packages/channels/base": {
"name": "@qwen-code/channel-base",
"version": "0.15.0",
"version": "0.15.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1"
},
@ -16870,7 +16869,7 @@
},
"packages/channels/dingtalk": {
"name": "@qwen-code/channel-dingtalk",
"version": "0.15.0",
"version": "0.15.2",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"dingtalk-stream-sdk-nodejs": "^2.0.4"
@ -16881,7 +16880,7 @@
},
"packages/channels/plugin-example": {
"name": "@qwen-code/channel-plugin-example",
"version": "0.15.0",
"version": "0.15.2",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"ws": "^8.18.0"
@ -16895,7 +16894,7 @@
},
"packages/channels/telegram": {
"name": "@qwen-code/channel-telegram",
"version": "0.15.0",
"version": "0.15.2",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"grammy": "^1.41.1",
@ -16908,7 +16907,7 @@
},
"packages/channels/weixin": {
"name": "@qwen-code/channel-weixin",
"version": "0.15.0",
"version": "0.15.2",
"dependencies": {
"@qwen-code/channel-base": "file:../base"
},
@ -16918,7 +16917,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.15.0",
"version": "0.15.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@google/genai": "1.30.0",
@ -17577,7 +17576,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.15.0",
"version": "0.15.2",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@ -21021,7 +21020,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.15.0",
"version": "0.15.2",
"license": "LICENSE",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
@ -21268,7 +21267,7 @@
},
"packages/web-templates": {
"name": "@qwen-code/web-templates",
"version": "0.15.0",
"version": "0.15.2",
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
@ -21796,7 +21795,7 @@
},
"packages/webui": {
"name": "@qwen-code/webui",
"version": "0.15.0",
"version": "0.15.2",
"license": "MIT",
"dependencies": {
"markdown-it": "^14.1.0"

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.15.0",
"version": "0.15.2",
"engines": {
"node": ">=20.0.0"
},
@ -18,7 +18,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.2"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@ -43,6 +43,7 @@
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests cli",
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests cli",
"test:integration:interactive:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests interactive",
@ -53,9 +54,12 @@
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
"lint:sdk:python": "python3 -m ruff check --config packages/sdk-python/pyproject.toml packages/sdk-python",
"lint:all": "node scripts/lint.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:sdk:python": "python3 -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src",
"smoke:sdk:python": "python3 packages/sdk-python/scripts/smoke_real.py",
"check-i18n": "npm run check-i18n --workspace=packages/cli",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run build && npm run bundle",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-base",
"version": "0.15.0",
"version": "0.15.2",
"description": "Base channel infrastructure for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-dingtalk",
"version": "0.15.0",
"version": "0.15.2",
"description": "DingTalk channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-plugin-example",
"version": "0.15.0",
"version": "0.15.2",
"private": true,
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-telegram",
"version": "0.15.0",
"version": "0.15.2",
"description": "Telegram channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-weixin",
"version": "0.15.0",
"version": "0.15.2",
"description": "WeChat (Weixin) channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.15.0",
"version": "0.15.2",
"description": "Qwen Code",
"repository": {
"type": "git",
@ -16,6 +16,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./export": {
"types": "./dist/src/export/index.d.ts",
"import": "./dist/src/export/index.js"
}
},
"scripts": {
@ -33,7 +37,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.2"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",

View file

@ -45,7 +45,22 @@ vi.mock('@agentclientprotocol/sdk', () => ({
},
})),
ndJsonStream: vi.fn().mockReturnValue({}),
RequestError: class RequestError extends Error {},
RequestError: class RequestError extends Error {
static authRequired = vi
.fn()
.mockImplementation((data: unknown, msg: string) => {
const err = new Error(msg);
Object.assign(err, data);
return err;
});
static invalidParams = vi
.fn()
.mockImplementation((data: unknown, msg: string) => {
const err = new Error(msg);
Object.assign(err, data);
return err;
});
},
PROTOCOL_VERSION: '1.0.0',
}));
@ -73,7 +88,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
clearCachedCredentialFile: vi.fn(),
QwenOAuth2Event: {},
qwenOAuth2Events: { on: vi.fn(), off: vi.fn() },
MCPServerConfig: {},
MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({
_args: args,
})),
SessionService: vi.fn(),
tokenLimit: vi.fn(),
SessionStartSource: {
@ -90,18 +107,31 @@ vi.mock('./authMethods.js', () => ({ buildAuthMethods: vi.fn() }));
vi.mock('./service/filesystem.js', () => ({
AcpFileSystemService: vi.fn(),
}));
vi.mock('../config/settings.js', () => ({ SettingScope: {} }));
vi.mock('../config/settings.js', () => ({
SettingScope: {},
loadSettings: vi.fn(),
}));
vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn() }));
vi.mock('./session/Session.js', () => ({ Session: vi.fn() }));
vi.mock('../utils/acpModelUtils.js', () => ({
formatAcpModelId: vi.fn(),
}));
import { runAcpAgent } from './acpAgent.js';
import {
runAcpAgent,
toStdioServer,
toSseServer,
toHttpServer,
} from './acpAgent.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
import type { CliArgs } from '../config/config.js';
import { SessionEndReason } from '@qwen-code/qwen-code-core';
import { SessionEndReason, MCPServerConfig } from '@qwen-code/qwen-code-core';
import type { McpServer } from '@agentclientprotocol/sdk';
import { AgentSideConnection } from '@agentclientprotocol/sdk';
import { loadSettings } from '../config/settings.js';
import { loadCliConfig } from '../config/config.js';
import { Session } from './session/Session.js';
describe('runAcpAgent shutdown cleanup', () => {
let processExitSpy: MockInstance<typeof process.exit>;
@ -480,3 +510,387 @@ describe('runAcpAgent SessionEnd hooks', () => {
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// Unit tests for toStdioServer / toSseServer / toHttpServer helpers
// ---------------------------------------------------------------------------
describe('toStdioServer', () => {
const stdioServer = {
name: 'my-stdio',
command: 'node',
args: ['server.js'],
env: [],
} as unknown as McpServer;
const sseServer = {
type: 'sse',
name: 'my-sse',
url: 'http://localhost:3000/sse',
headers: [],
} as unknown as McpServer;
it('returns the server when it is a stdio server', () => {
expect(toStdioServer(stdioServer)).toBe(stdioServer);
});
it('returns undefined for SSE server', () => {
expect(toStdioServer(sseServer)).toBeUndefined();
});
it('returns undefined for HTTP server', () => {
const httpServer = {
type: 'http',
name: 'my-http',
url: 'http://localhost:3000/mcp',
headers: [],
} as unknown as McpServer;
expect(toStdioServer(httpServer)).toBeUndefined();
});
});
describe('toSseServer', () => {
it('returns the server when type is sse', () => {
const sseServer = {
type: 'sse',
name: 'my-sse',
url: 'http://localhost:3000/sse',
headers: [],
} as unknown as McpServer;
const result = toSseServer(sseServer);
expect(result).toBe(sseServer);
expect(result?.type).toBe('sse');
});
it('returns undefined for stdio server', () => {
const stdioServer = {
name: 'my-stdio',
command: 'node',
args: [],
env: [],
} as unknown as McpServer;
expect(toSseServer(stdioServer)).toBeUndefined();
});
it('returns undefined for http server', () => {
const httpServer = {
type: 'http',
name: 'my-http',
url: 'http://localhost:3000/mcp',
headers: [],
} as unknown as McpServer;
expect(toSseServer(httpServer)).toBeUndefined();
});
});
describe('toHttpServer', () => {
it('returns the server when type is http', () => {
const httpServer = {
type: 'http',
name: 'my-http',
url: 'http://localhost:3000/mcp',
headers: [],
} as unknown as McpServer;
const result = toHttpServer(httpServer);
expect(result).toBe(httpServer);
expect(result?.type).toBe('http');
});
it('returns undefined for stdio server', () => {
const stdioServer = {
name: 'my-stdio',
command: 'node',
args: [],
env: [],
} as unknown as McpServer;
expect(toHttpServer(stdioServer)).toBeUndefined();
});
it('returns undefined for sse server', () => {
const sseServer = {
type: 'sse',
name: 'my-sse',
url: 'http://localhost:3000/sse',
headers: [],
} as unknown as McpServer;
expect(toHttpServer(sseServer)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Tests for QwenAgent.initialize() mcpCapabilities + newSession SSE/HTTP
// ---------------------------------------------------------------------------
describe('QwenAgent MCP SSE/HTTP support', () => {
// We need to capture the agent factory from AgentSideConnection constructor
let capturedAgentFactory:
| ((conn: AgentSideConnectionLike) => AgentLike)
| undefined;
type AgentSideConnectionLike = { closed: Promise<void> };
type AgentLike = {
initialize: (args: Record<string, unknown>) => Promise<unknown>;
newSession: (args: Record<string, unknown>) => Promise<unknown>;
};
let mockConfig: Config;
let processExitSpy: MockInstance<typeof process.exit>;
let stdinDestroySpy: MockInstance<typeof process.stdin.destroy>;
let stdoutDestroySpy: MockInstance<typeof process.stdout.destroy>;
const mockArgv = {} as CliArgs;
beforeEach(() => {
vi.clearAllMocks();
mockConnectionState.reset();
capturedAgentFactory = undefined;
// Override AgentSideConnection mock to capture factory
vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => {
capturedAgentFactory = factory as typeof capturedAgentFactory;
return {
get closed() {
return mockConnectionState.promise;
},
} as unknown as InstanceType<typeof AgentSideConnection>;
});
mockConfig = {
initialize: vi.fn().mockResolvedValue(undefined),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDisableAllHooks: vi.fn().mockReturnValue(false),
hasHooksForEvent: vi.fn().mockReturnValue(false),
getModel: vi.fn().mockReturnValue('test-model'),
getModelsConfig: vi.fn().mockReturnValue({
getCurrentAuthType: vi.fn().mockReturnValue('api-key'),
}),
refreshAuth: vi.fn().mockResolvedValue(undefined),
} as unknown as Config;
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as unknown as typeof process.exit);
stdinDestroySpy = vi
.spyOn(process.stdin, 'destroy')
.mockImplementation(() => process.stdin);
stdoutDestroySpy = vi
.spyOn(process.stdout, 'destroy')
.mockImplementation(() => process.stdout);
});
afterEach(() => {
processExitSpy.mockRestore();
stdinDestroySpy.mockRestore();
stdoutDestroySpy.mockRestore();
});
it('initialize response includes mcpCapabilities with sse and http', async () => {
const mockSettings = {
merged: { mcpServers: {} },
} as unknown as LoadedSettings;
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const fakeConn = {
get closed() {
return mockConnectionState.promise;
},
} as AgentSideConnectionLike;
const agent = capturedAgentFactory!(fakeConn) as AgentLike;
const response = await agent.initialize({ clientCapabilities: {} });
expect(response).toMatchObject({
agentCapabilities: {
mcpCapabilities: {
sse: true,
http: true,
},
},
});
mockConnectionState.resolve();
await agentPromise;
});
function makeInnerConfig() {
return {
initialize: vi.fn().mockResolvedValue(undefined),
getModelsConfig: vi.fn().mockReturnValue({
getCurrentAuthType: vi.fn().mockReturnValue('api-key'),
}),
refreshAuth: vi.fn().mockResolvedValue(undefined),
getModel: vi.fn().mockReturnValue('m'),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getAvailableModels: vi.fn().mockReturnValue([]),
getModes: vi.fn().mockReturnValue([]),
getApprovalMode: vi.fn().mockReturnValue('default'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getAuthType: vi.fn().mockReturnValue('api-key'),
getAllConfiguredModels: vi.fn().mockReturnValue([]),
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(true),
initialize: vi.fn().mockResolvedValue(undefined),
}),
getFileSystemService: vi.fn().mockReturnValue(undefined),
setFileSystemService: vi.fn(),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDisableAllHooks: vi.fn().mockReturnValue(true),
hasHooksForEvent: vi.fn().mockReturnValue(false),
};
}
function makeSessionSettings() {
return {
merged: { mcpServers: {} },
getUserHooks: vi.fn().mockReturnValue({}),
getProjectHooks: vi.fn().mockReturnValue({}),
} as unknown as LoadedSettings;
}
async function setupSessionMocks(sessionId: string) {
const innerConfig = makeInnerConfig();
vi.mocked(loadSettings).mockReturnValue(makeSessionSettings());
vi.mocked(loadCliConfig).mockResolvedValue(
innerConfig as unknown as Config,
);
vi.mocked(Session).mockImplementation(
() =>
({
getId: vi.fn().mockReturnValue(sessionId),
getConfig: vi.fn().mockReturnValue(innerConfig),
sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
replayHistory: vi.fn().mockResolvedValue(undefined),
installRewriter: vi.fn(),
}) as unknown as InstanceType<typeof Session>,
);
return innerConfig;
}
it('newSession with SSE MCP server creates MCPServerConfig with url', async () => {
await setupSessionMocks('session-sse');
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await agent.newSession({
cwd: '/tmp',
mcpServers: [
{
type: 'sse',
name: 'my-sse-server',
url: 'http://localhost:3001/sse',
headers: [{ name: 'Authorization', value: 'Bearer token123' }],
},
],
});
expect(MCPServerConfig).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
undefined,
'http://localhost:3001/sse',
undefined,
{ Authorization: 'Bearer token123' },
);
mockConnectionState.resolve();
await agentPromise;
});
it('newSession with HTTP MCP server creates MCPServerConfig with httpUrl', async () => {
await setupSessionMocks('session-http');
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await agent.newSession({
cwd: '/tmp',
mcpServers: [
{
type: 'http',
name: 'my-http-server',
url: 'http://localhost:3002/mcp',
headers: [],
},
],
});
expect(MCPServerConfig).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
undefined,
undefined,
'http://localhost:3002/mcp',
undefined,
);
mockConnectionState.resolve();
await agentPromise;
});
it('newSession with SSE MCP server and empty headers passes undefined for headers', async () => {
await setupSessionMocks('session-sse-noheaders');
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await agent.newSession({
cwd: '/tmp',
mcpServers: [
{
type: 'sse',
name: 'no-header-sse',
url: 'http://localhost:3003/sse',
headers: [],
},
],
});
expect(MCPServerConfig).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
undefined,
'http://localhost:3003/sse',
undefined,
undefined,
);
mockConnectionState.resolve();
await agentPromise;
});
});

View file

@ -42,6 +42,8 @@ import type {
LoadSessionRequest,
LoadSessionResponse,
McpServer,
McpServerHttp,
McpServerSse,
McpServerStdio,
NewSessionRequest,
NewSessionResponse,
@ -162,13 +164,31 @@ export async function runAcpAgent(
process.off('SIGINT', shutdownHandler);
}
function toStdioServer(server: McpServer): McpServerStdio | undefined {
export function toStdioServer(server: McpServer): McpServerStdio | undefined {
if ('command' in server && 'args' in server && 'env' in server) {
return server as McpServerStdio;
}
return undefined;
}
export function toSseServer(
server: McpServer,
): (McpServerSse & { type: 'sse' }) | undefined {
if ('type' in server && server.type === 'sse') {
return server as McpServerSse & { type: 'sse' };
}
return undefined;
}
export function toHttpServer(
server: McpServer,
): (McpServerHttp & { type: 'http' }) | undefined {
if ('type' in server && server.type === 'http') {
return server as McpServerHttp & { type: 'http' };
}
return undefined;
}
class QwenAgent implements Agent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: ClientCapabilities | undefined;
@ -204,6 +224,10 @@ class QwenAgent implements Agent {
list: {},
resume: {},
},
mcpCapabilities: {
sse: true,
http: true,
},
},
};
}
@ -499,18 +523,55 @@ class QwenAgent implements Agent {
for (const server of mcpServers) {
const stdioServer = toStdioServer(server);
if (!stdioServer) continue;
const env: Record<string, string> = {};
for (const { name: envName, value } of stdioServer.env) {
env[envName] = value;
if (stdioServer) {
const env: Record<string, string> = {};
for (const { name: envName, value } of stdioServer.env) {
env[envName] = value;
}
mergedMcpServers[stdioServer.name] = new MCPServerConfig(
stdioServer.command,
stdioServer.args,
env,
cwd,
);
continue;
}
const sseServer = toSseServer(server);
if (sseServer) {
const headers: Record<string, string> = {};
for (const { name: headerName, value } of sseServer.headers) {
headers[headerName] = value;
}
mergedMcpServers[sseServer.name] = new MCPServerConfig(
undefined,
undefined,
undefined,
undefined,
sseServer.url,
undefined,
Object.keys(headers).length > 0 ? headers : undefined,
);
continue;
}
const httpServer = toHttpServer(server);
if (httpServer) {
const headers: Record<string, string> = {};
for (const { name: headerName, value } of httpServer.headers) {
headers[headerName] = value;
}
mergedMcpServers[httpServer.name] = new MCPServerConfig(
undefined,
undefined,
undefined,
undefined,
undefined,
httpServer.url,
Object.keys(headers).length > 0 ? headers : undefined,
);
continue;
}
mergedMcpServers[stdioServer.name] = new MCPServerConfig(
stdioServer.command,
stdioServer.args,
env,
cwd,
);
}
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };

View file

@ -246,6 +246,43 @@ describe('Session', () => {
});
});
it('attaches available skills to available_commands_update metadata', async () => {
getAvailableCommandsSpy.mockResolvedValueOnce([
{
name: 'init',
description: 'Initialize project context',
},
]);
mockConfig.getSkillManager = vi.fn().mockReturnValue({
listSkills: vi
.fn()
.mockResolvedValue([
{ name: 'code-review-expert' },
{ name: 'verification-pack' },
]),
});
await session.sendAvailableCommandsUpdate();
expect(mockClient.sessionUpdate).toHaveBeenCalledTimes(1);
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{
name: 'init',
description: 'Initialize project context',
input: null,
},
],
_meta: {
availableSkills: ['code-review-expert', 'verification-pack'],
},
},
});
});
it('swallows errors and does not throw', async () => {
getAvailableCommandsSpy.mockRejectedValueOnce(
new Error('Command discovery failed'),
@ -1082,6 +1119,125 @@ describe('Session', () => {
});
});
describe('tool call concurrency', () => {
it('runs multiple Agent tool calls concurrently (issue #2516)', async () => {
// Each Agent call has two controllable async boundaries:
// - `called` — resolves *when* the test code reaches `execute()`
// - `result` — the promise `execute()` returns, resolved by the
// test after observing both `called` signals.
//
// Under the old sequential for-loop, call-b's `execute()` would
// only run after call-a's `execute()` promise resolved — so the
// `await Promise.all([called-a, called-b])` below deadlocks and
// the test hits vitest's default per-test timeout. Under the
// concurrent implementation both `called` signals fire before
// either `result` is resolved.
type Deferred<T> = {
promise: Promise<T>;
resolve: (v: T) => void;
};
const makeDeferred = <T>(): Deferred<T> => {
let resolve!: (v: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
};
const called: Record<string, Deferred<void>> = {
'call-a': makeDeferred<void>(),
'call-b': makeDeferred<void>(),
};
const result: Record<string, Deferred<core.ToolResult>> = {
'call-a': makeDeferred<core.ToolResult>(),
'call-b': makeDeferred<core.ToolResult>(),
};
const agentTool = {
name: core.ToolNames.AGENT,
kind: core.Kind.Think,
build: vi.fn().mockImplementation((args: Record<string, unknown>) => {
const id = args['_test_id'] as string;
return {
params: args,
eventEmitter: undefined,
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue(`agent ${id}`),
toolLocations: vi.fn().mockReturnValue([]),
execute: vi.fn().mockImplementation(() => {
called[id].resolve();
return result[id].promise;
}),
};
}),
};
mockToolRegistry.getTool.mockImplementation((name: string) =>
name === core.ToolNames.AGENT ? agentTool : undefined,
);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
// Model returns two Agent calls, then an empty stream once results
// are fed back (to terminate the prompt loop).
const sendMessageStream = vi
.fn()
.mockResolvedValueOnce(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-a',
name: core.ToolNames.AGENT,
args: { _test_id: 'call-a', subagent_type: 'explore' },
},
{
id: 'call-b',
name: core.ToolNames.AGENT,
args: { _test_id: 'call-b', subagent_type: 'explore' },
},
],
},
},
]),
)
.mockResolvedValueOnce(createEmptyStream());
mockChat.sendMessageStream = sendMessageStream;
const promptPromise = session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'spawn two agents' }],
});
// Wait until both `execute()` bodies have been entered. Sequential
// behaviour deadlocks here → vitest times out the test → failure.
await Promise.all([called['call-a'].promise, called['call-b'].promise]);
// Resolve out of order to also verify that final part ordering
// follows the original functionCalls order, not resolution order.
result['call-b'].resolve({ llmContent: 'B-done', returnDisplay: 'B' });
result['call-a'].resolve({ llmContent: 'A-done', returnDisplay: 'A' });
await promptPromise;
// The second sendMessageStream invocation carries the tool responses
// that will be fed back to the model — assert their order matches
// the original function-call order (A before B).
expect(sendMessageStream).toHaveBeenCalledTimes(2);
const followUp = sendMessageStream.mock.calls[1][1] as {
message: Array<{ functionResponse?: { id?: string } }>;
};
const ids = followUp.message
.filter((p) => p.functionResponse)
.map((p) => p.functionResponse?.id);
expect(ids).toEqual(['call-a', 'call-b']);
});
});
describe('system reminders', () => {
// Captures the `message` parts fed into chat.sendMessageStream on the
// first turn so individual tests can assert what the model saw.

View file

@ -522,17 +522,11 @@ export class Session implements SessionContext {
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const response = await this.runTool(
pendingSend.signal,
promptId,
fc,
);
toolResponseParts.push(...response);
}
const toolResponseParts = await this.runToolCalls(
pendingSend.signal,
promptId,
functionCalls,
);
nextMessage = { role: 'user', parts: toolResponseParts };
}
}
@ -763,17 +757,11 @@ export class Session implements SessionContext {
// Process tool calls from the follow-up message
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const toolResponse = await this.runTool(
pendingSend.signal,
promptId,
fc,
);
toolResponseParts.push(...toolResponse);
}
const toolResponseParts = await this.runToolCalls(
pendingSend.signal,
promptId,
functionCalls,
);
nextMessage = { role: 'user', parts: toolResponseParts };
}
}
@ -956,11 +944,11 @@ export class Session implements SessionContext {
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const response = await this.runTool(ac.signal, promptId, fc);
toolResponseParts.push(...response);
}
const toolResponseParts = await this.runToolCalls(
ac.signal,
promptId,
functionCalls,
);
nextMessage = { role: 'user', parts: toolResponseParts };
}
}
@ -997,9 +985,27 @@ export class Session implements SessionContext {
}),
);
let availableSkills: string[] | undefined;
try {
const skillManager = this.config.getSkillManager();
if (skillManager) {
const skills = await skillManager.listSkills();
availableSkills = skills.map((skill) => skill.name);
}
} catch (error) {
debugLogger.error('Error loading available skills:', error);
}
const update: SessionUpdate = {
sessionUpdate: 'available_commands_update',
availableCommands,
...(availableSkills
? {
_meta: {
availableSkills,
},
}
: {}),
};
await this.sendUpdate(update);
@ -1103,6 +1109,79 @@ export class Session implements SessionContext {
await this.sendUpdate(update);
}
/**
* Execute a batch of model-returned tool calls, running Agent calls
* concurrently while keeping other tools sequential.
*
* Mirrors the partition logic in `coreToolScheduler.partitionToolCalls`:
* consecutive Agent calls form a parallel batch (they spawn independent
* sub-agents with no shared mutable state); any other tool forms its own
* sequential batch to preserve the implicit ordering the model may rely
* on. Response-part ordering matches the original `functionCalls` order.
*/
private async runToolCalls(
abortSignal: AbortSignal,
promptId: string,
functionCalls: FunctionCall[],
): Promise<Part[]> {
type Batch = { concurrent: boolean; calls: FunctionCall[] };
const batches: Batch[] = [];
for (const fc of functionCalls) {
const isAgent = fc.name === ToolNames.AGENT;
const last = batches[batches.length - 1];
if (isAgent && last?.concurrent) {
last.calls.push(fc);
} else {
batches.push({ concurrent: isAgent, calls: [fc] });
}
}
// Bounded-concurrency runner: matches core's `runConcurrently`
// behaviour (`coreToolScheduler.ts:1506`), capped by
// `QWEN_CODE_MAX_TOOL_CONCURRENCY` (default 10). Results are returned
// in input order regardless of resolution order.
const runBounded = async (calls: FunctionCall[]): Promise<Part[][]> => {
const parsed = parseInt(
process.env['QWEN_CODE_MAX_TOOL_CONCURRENCY'] || '',
10,
);
const maxConcurrency =
Number.isFinite(parsed) && parsed >= 1 ? parsed : 10;
const results: Part[][] = new Array(calls.length);
const executing = new Set<Promise<void>>();
for (let i = 0; i < calls.length; i++) {
const idx = i;
const p = this.runTool(abortSignal, promptId, calls[idx])
.then((r) => {
results[idx] = r;
})
.finally(() => {
executing.delete(p);
});
executing.add(p);
if (executing.size >= maxConcurrency) {
await Promise.race(executing);
}
}
await Promise.all(executing);
return results;
};
const parts: Part[] = [];
for (const batch of batches) {
if (batch.concurrent && batch.calls.length > 1) {
const results = await runBounded(batch.calls);
for (const r of results) parts.push(...r);
} else {
for (const fc of batch.calls) {
const r = await this.runTool(abortSignal, promptId, fc);
parts.push(...r);
}
}
}
return parts;
}
/**
* Assemble the per-turn system reminders the model needs to see at the
* start of a user query or cron fire. Mirrors the subagent/plan/arena
@ -1247,14 +1326,19 @@ export class Session implements SessionContext {
try {
const invocation = tool.build(args);
if (isAgentTool && 'eventEmitter' in invocation) {
// Access eventEmitter from AgentTool invocation
const taskEventEmitter = (
invocation as {
eventEmitter: AgentEventEmitter;
}
).eventEmitter;
// Production AgentTool always initializes `eventEmitter` on its
// invocation (`agent.ts:392`). Be defensive about the `undefined`
// case too so an incomplete/custom AgentTool invocation degrades
// gracefully (no sub-agent event forwarding) instead of throwing
// inside SubAgentTracker.setup — the `'eventEmitter' in invocation`
// key-presence check passed for `{ eventEmitter: undefined }` and
// the ensuing `eventEmitter.on(...)` blew up.
const taskEventEmitter = (
invocation as {
eventEmitter?: AgentEventEmitter;
}
).eventEmitter;
if (isAgentTool && taskEventEmitter) {
// Extract subagent metadata from AgentTool call
const parentToolCallId = callId;
const subagentType = (args['subagent_type'] as string) ?? '';

View file

@ -91,10 +91,6 @@ export async function handleQwenAuth(
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
tavilyApiKey: undefined,
googleApiKey: undefined,
googleSearchEngineId: undefined,
webSearchDefault: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,

View file

@ -11,7 +11,6 @@ tools:
- NotebookRead
- WebFetch
- TodoWrite
- WebSearch
modelConfig:
model: qwen3-coder-plus
---

View file

@ -1734,7 +1734,6 @@ describe('loadCliConfig with includeDirectories', () => {
expect(config.getToolDiscoveryCommand()).toBeUndefined();
expect(config.getToolCallCommand()).toBeUndefined();
expect(config.getMcpServers()).toEqual({});
expect(config.getWebSearchConfig()).toBeUndefined();
expect(config.isLspEnabled()).toBe(false);
});

View file

@ -67,7 +67,6 @@ export function isValidSessionId(value: string): boolean {
}
import { isWorkspaceTrusted } from './trustedFolders.js';
import { buildWebSearchConfig } from './webSearch.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
const debugLogger = createDebugLogger('CONFIG');
@ -138,10 +137,6 @@ export interface CliArgs {
openaiLoggingDir: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
googleApiKey: string | undefined;
googleSearchEngineId: string | undefined;
webSearchDefault: string | undefined;
screenReader: boolean | undefined;
inputFormat?: string | undefined;
outputFormat: string | undefined;
@ -431,23 +426,6 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search',
})
.option('google-api-key', {
type: 'string',
description: 'Google Custom Search API key',
})
.option('google-search-engine-id', {
type: 'string',
description: 'Google Custom Search Engine ID',
})
.option('web-search-default', {
type: 'string',
description:
'Default web search provider (dashscope, tavily, google)',
})
.option('screen-reader', {
type: 'boolean',
description: 'Enable screen reader mode for accessibility.',
@ -1206,9 +1184,6 @@ export async function loadCliConfig(
? []
: (settings.security?.allowedHttpHookUrls ?? []),
cliVersion: await getCliVersion(),
webSearch: bareMode
? undefined
: buildWebSearchConfig(argv, settings, selectedAuthType),
ideMode,
chatCompression: settings.model?.chatCompression,
folderTrust,

View file

@ -28,7 +28,6 @@ describe('SettingsSchema', () => {
'mcp',
'security',
'advanced',
'webSearch',
];
expectedSettings.forEach((setting) => {

View file

@ -1012,16 +1012,6 @@ const SETTINGS_SCHEMA = {
'Settings for clearing stale context after idle periods. Use -1 to disable a threshold.',
showInDialog: false,
properties: {
thinkingThresholdMinutes: {
type: 'number',
label: 'Thinking Idle Threshold (minutes)',
category: 'Context',
requiresRestart: false,
default: 5 as number,
description:
'Minutes of inactivity before clearing old thinking blocks. Use -1 to disable.',
showInDialog: false,
},
toolResultsThresholdMinutes: {
type: 'number',
label: 'Tool Results Idle Threshold (minutes)',
@ -1590,37 +1580,9 @@ const SETTINGS_SCHEMA = {
'Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.',
showInDialog: false,
},
tavilyApiKey: {
type: 'string',
label: 'Tavily API Key (Deprecated)',
category: 'Advanced',
requiresRestart: false,
default: undefined as string | undefined,
description:
'⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.',
showInDialog: false,
},
},
},
webSearch: {
type: 'object',
label: 'Web Search',
category: 'Advanced',
requiresRestart: true,
default: undefined as
| {
provider: Array<{
type: 'tavily' | 'google' | 'dashscope';
apiKey?: string;
searchEngineId?: string;
}>;
default: string;
}
| undefined,
description: 'Configuration for web search providers.',
showInDialog: false,
},
agents: {
type: 'object',
label: 'Agents',

View file

@ -1,114 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
/**
* CLI arguments related to web search configuration
*/
export interface WebSearchCliArgs {
tavilyApiKey?: string;
googleApiKey?: string;
googleSearchEngineId?: string;
webSearchDefault?: string;
}
/**
* Web search configuration structure
*/
export interface WebSearchConfig {
provider: WebSearchProviderConfig[];
default: string;
}
/**
* Build webSearch configuration from multiple sources with priority:
* 1. settings.json (new format) - highest priority
* 2. Command line args + environment variables
* 3. Legacy tavilyApiKey (backward compatibility)
*
* @param argv - Command line arguments
* @param settings - User settings from settings.json
* @param authType - Authentication type (e.g., 'qwen-oauth')
* @returns WebSearch configuration or undefined if no providers available
*/
export function buildWebSearchConfig(
argv: WebSearchCliArgs,
settings: Settings,
_authType?: string,
): WebSearchConfig | undefined {
// Step 1: Collect providers from settings or command line/env
let providers: WebSearchProviderConfig[] = [];
let userDefault: string | undefined;
if (settings.webSearch) {
// Use providers from settings.json
providers = [...settings.webSearch.provider];
userDefault = settings.webSearch.default;
} else {
// Build providers from command line args and environment variables
const tavilyKey =
argv.tavilyApiKey ||
settings.advanced?.tavilyApiKey ||
process.env['TAVILY_API_KEY'];
if (tavilyKey) {
providers.push({
type: 'tavily',
apiKey: tavilyKey,
} as WebSearchProviderConfig);
}
const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY'];
const googleEngineId =
argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID'];
if (googleKey && googleEngineId) {
providers.push({
type: 'google',
apiKey: googleKey,
searchEngineId: googleEngineId,
} as WebSearchProviderConfig);
}
}
// Step 2: DashScope auto-injection for qwen-oauth was removed when the
// free tier was discontinued on 2026-04-15. Users who explicitly configure
// a dashscope provider in settings.json still get it (handled in Step 1).
// Step 3: If no providers available, return undefined
if (providers.length === 0) {
return undefined;
}
// Step 4: Determine default provider
// Priority: user explicit config > CLI arg > first available provider (tavily > google > dashscope)
const providerPriority: Array<'tavily' | 'google' | 'dashscope'> = [
'tavily',
'google',
'dashscope',
];
// Determine default provider based on availability
let defaultProvider = userDefault || argv.webSearchDefault;
if (!defaultProvider) {
// Find first available provider by priority order
for (const providerType of providerPriority) {
if (providers.some((p) => p.type === providerType)) {
defaultProvider = providerType;
break;
}
}
// Fallback to first available provider if none found in priority list
if (!defaultProvider) {
defaultProvider = providers[0]?.type || 'dashscope';
}
}
return {
provider: providers,
default: defaultProvider,
};
}

View file

@ -0,0 +1,17 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export {
collectSessionData,
generateExportFilename,
normalizeSessionData,
toHtml,
toJson,
toJsonl,
toMarkdown,
type ExportMessage,
type ExportSessionData,
} from '../ui/utils/export/index.js';

View file

@ -595,10 +595,6 @@ describe('gemini.tsx main function kitty protocol', () => {
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
tavilyApiKey: undefined,
googleApiKey: undefined,
googleSearchEngineId: undefined,
webSearchDefault: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,
@ -675,6 +671,7 @@ describe('startInteractiveUI', () => {
vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({
detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)),
disableKittyProtocol: vi.fn(),
}));
vi.mock('./ui/utils/updateCheck.js', () => ({

View file

@ -47,7 +47,10 @@ import { AgentViewProvider } from './ui/contexts/AgentViewContext.js';
import { BackgroundAgentViewProvider } from './ui/contexts/BackgroundAgentViewContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { themeManager, AUTO_THEME_NAME } from './ui/themes/theme-manager.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
import {
detectAndEnableKittyProtocol,
disableKittyProtocol,
} from './ui/utils/kittyProtocolDetector.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import {
cleanupCheckpoints,
@ -83,6 +86,7 @@ import { DualOutputContext } from './dualOutput/DualOutputContext.js';
import { RemoteInputWatcher } from './remoteInput/RemoteInputWatcher.js';
import { RemoteInputContext } from './remoteInput/RemoteInputContext.js';
import { installTerminalRedrawOptimizer } from './ui/utils/terminalRedrawOptimizer.js';
import { installSynchronizedOutput } from './ui/utils/synchronizedOutput.js';
const debugLogger = createDebugLogger('STARTUP');
@ -172,6 +176,10 @@ export async function startInteractiveUI(
process.stdout.isTTY && !config.getScreenReader()
? installTerminalRedrawOptimizer(process.stdout)
: () => {};
const restoreSynchronizedOutput =
process.stdout.isTTY && !config.getScreenReader()
? installSynchronizedOutput(process.stdout)
: () => {};
// Create dual output bridge if --json-fd or --json-file is specified.
// Errors are caught so a bad fd/path degrades gracefully instead of
@ -292,7 +300,12 @@ export async function startInteractiveUI(
registerCleanup(async () => {
remoteInputWatcher?.shutdown();
await dualOutputBridge?.shutdown();
// Explicitly disable the Kitty keyboard protocol before unmounting Ink so
// that the disable escape sequence is written while stdout is still fully
// operational, preventing garbled terminal output after the app exits.
disableKittyProtocol();
instance.unmount();
restoreSynchronizedOutput();
restoreTerminalRedrawOptimizer();
});
}

View file

@ -57,15 +57,18 @@ const getLocalePath = (
export function detectSystemLanguage(): SupportedLanguage {
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
if (envLang) {
// Normalize POSIX locales (e.g. zh_TW.UTF-8 → zh-tw) before matching
const normalized = envLang.replace(/_/g, '-').toLowerCase();
for (const lang of SUPPORTED_LANGUAGES) {
if (envLang.startsWith(lang.code)) return lang.code;
if (normalized.startsWith(lang.code.toLowerCase())) return lang.code;
}
}
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
const normalized = locale.replace(/_/g, '-').toLowerCase();
for (const lang of SUPPORTED_LANGUAGES) {
if (locale.startsWith(lang.code)) return lang.code;
if (normalized.startsWith(lang.code.toLowerCase())) return lang.code;
}
} catch {
// Fallback to default

View file

@ -7,6 +7,7 @@
export type SupportedLanguage =
| 'en'
| 'zh'
| 'zh-TW'
| 'ru'
| 'de'
| 'ja'
@ -32,6 +33,12 @@ export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [
fullName: 'English',
nativeName: 'English',
},
{
code: 'zh-TW',
id: 'zh-TW',
fullName: 'Traditional Chinese',
nativeName: '繁體中文',
},
{
code: 'zh',
id: 'zh-CN',
@ -75,7 +82,8 @@ export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [
* Used for LLM output language instructions.
*/
export function getLanguageNameFromLocale(locale: SupportedLanguage): string {
const lang = SUPPORTED_LANGUAGES.find((l) => l.code === locale);
const lower = locale.toLowerCase();
const lang = SUPPORTED_LANGUAGES.find((l) => l.code.toLowerCase() === lower);
return lang?.fullName || 'English';
}

View file

@ -575,6 +575,8 @@ export default {
'Updates all extensions or a named extension to the latest version.':
'Updates all extensions or a named extension to the latest version.',
'Update all extensions.': 'Update all extensions.',
'The name of the extension to update.':
'The name of the extension to update.',
'Either an extension name or --all must be provided':
'Either an extension name or --all must be provided',
'Lists installed extensions.': 'Lists installed extensions.',
@ -726,6 +728,7 @@ export default {
'User Settings': 'User Settings',
'System Settings': 'System Settings',
Extensions: 'Extensions',
'Session (temporary)': 'Session (temporary)',
// Hooks - Status
'✓ Enabled': '✓ Enabled',
'✗ Disabled': '✗ Disabled',
@ -1896,6 +1899,8 @@ export default {
// Coding Plan Authentication
// ============================================================================
'API key cannot be empty.': 'API key cannot be empty.',
'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.':
'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.',
'You can get your Coding Plan API key here':
'You can get your Coding Plan API key here',
'API key is stored in settings.env. You can migrate it to a .env file for better security.':
@ -1973,6 +1978,8 @@ export default {
'Show context window usage breakdown.',
'Run /context detail for per-item breakdown.':
'Run /context detail for per-item breakdown.',
'Show context window usage breakdown. Use "/context detail" for per-item breakdown.':
'Show context window usage breakdown. Use "/context detail" for per-item breakdown.',
'body loaded': 'body loaded',
memory: 'memory',
'{{region}} configuration updated successfully.':

File diff suppressed because it is too large Load diff

View file

@ -578,6 +578,7 @@ export default {
'(user)': '(用户)',
'[not set]': '[未设置]',
'[value stored in keychain]': '[值存储在钥匙串中]',
'Value:': '值:',
'Manage extension settings.': '管理扩展设置。',
'You need to specify a command (set or list).':
'您需要指定命令set 或 list。',
@ -1037,6 +1038,8 @@ export default {
'Command:': '命令:',
'Working Directory:': '工作目录:',
'Capabilities:': '功能:',
'No server selected': '未选择服务器',
prompts: '提示',
// MCP Tool List
'No tools available for this server.': '此服务器没有可用工具。',
@ -1049,7 +1052,9 @@ export default {
'{{current}}/{{total}}': '{{current}}/{{total}}',
// MCP Tool Detail
required: '必需',
Type: '类型',
Enum: '枚举',
Parameters: '参数',
'No tool selected': '未选择工具',
Annotations: '注解',

View file

@ -28,6 +28,8 @@ import { vi, type Mock, type MockInstance } from 'vitest';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
import { filterCommandsForMode } from './services/commandUtils.js';
import { _resetCleanupFunctionsForTest } from './utils/cleanup.js';
import { _resetExitLatchForTest } from './utils/errors.js';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
@ -79,6 +81,12 @@ describe('runNonInteractive', () => {
let mockGetDebugResponses: Mock;
beforeEach(async () => {
// Reset module-level state from any prior test in this file. Without
// these resets the once-set exit latch parks subsequent JSON-mode
// handleError tests in the never-resolving promise (5s vitest timeout).
_resetCleanupFunctionsForTest();
_resetExitLatchForTest();
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
mockGetCommandsForMode.mockImplementation((mode: ExecutionMode) =>
@ -275,6 +283,38 @@ describe('runNonInteractive', () => {
expect(mockShutdownTelemetry).toHaveBeenCalled();
});
it('on EPIPE, destroys stdout and returns normally instead of process.exit', async () => {
// Regression: process.exit(0) on EPIPE bypassed runExitCleanup → flush()
// and dropped queued JSONL writes for `qwen -p ... | head -1` patterns.
// process.exit is mocked to throw in beforeEach, so reaching the
// assertion also proves the bypass route is gone.
setupMetricsMock();
const stdoutDestroySpy = vi
.spyOn(process.stdout, 'destroy')
.mockReturnValue(process.stdout);
mockGeminiClient.sendMessageStream.mockImplementation(
async function* mockStream(): AsyncGenerator<ServerGeminiStreamEvent> {
process.stdout.emit(
'error',
Object.assign(new Error('EPIPE'), { code: 'EPIPE' }),
);
yield { type: GeminiEventType.Content, value: 'Hello' };
yield {
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata: { totalTokenCount: 0 },
},
};
},
);
await runNonInteractive(mockConfig, mockSettings, 'test', 'p1');
expect(stdoutDestroySpy).toHaveBeenCalled();
});
it('should handle a single tool call and respond', async () => {
setupMetricsMock();
const toolCallEvent: ServerGeminiStreamEvent = {

View file

@ -175,16 +175,22 @@ export async function runNonInteractive(
let totalApiDurationMs = 0;
const startTime = Date.now();
const stdoutErrorHandler = (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
process.stdout.removeListener('error', stdoutErrorHandler);
process.exit(0);
}
};
const geminiClient = config.getGeminiClient();
const abortController = options.abortController ?? new AbortController();
// EPIPE: don't process.exit here — that bypasses the caller's
// runExitCleanup → flush() and drops queued JSONL writes. Destroy
// stdout instead and let the natural return drive cleanup. (Aborting
// is also wrong: the abort path runs handleCancellationError → exit
// 130 and re-introduces the same bypass.)
let pipeBroken = false;
const stdoutErrorHandler = (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE' && !pipeBroken) {
pipeBroken = true;
process.stdout.destroy();
}
};
// Setup signal handlers for graceful shutdown
const shutdownHandler = () => {
debugLogger.debug('[runNonInteractive] Shutdown signal received');
@ -343,7 +349,7 @@ export async function runNonInteractive(
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
handleMaxTurnsExceededError(config);
await handleMaxTurnsExceededError(config);
}
const toolCallRequests: ToolCallRequestInfo[] = [];
@ -366,7 +372,7 @@ export async function runNonInteractive(
for await (const event of responseStream) {
if (abortController.signal.aborted) {
handleCancellationError(config);
await handleCancellationError(config);
}
// Use adapter for all event processing
adapter.processEvent(event);
@ -492,7 +498,7 @@ export async function runNonInteractive(
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
handleMaxTurnsExceededError(config);
await handleMaxTurnsExceededError(config);
}
const inputFormat =
@ -705,7 +711,7 @@ export async function runNonInteractive(
while (localQueue.length > 0) {
emitNotificationToSdk(localQueue.shift()!);
}
handleCancellationError(config);
await handleCancellationError(config);
}
await drainLocalQueue();
// Wait for every agent's terminal notification, not just the
@ -764,7 +770,7 @@ export async function runNonInteractive(
usage,
stats,
});
handleError(error, config);
await handleError(error, config);
} finally {
const reg = config.getBackgroundTaskRegistry();
reg.setNotificationCallback(undefined);

View file

@ -5,7 +5,10 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import {
getAvailableCommands,
handleSlashCommand,
} from './nonInteractiveCliCommands.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
@ -340,3 +343,43 @@ describe('handleSlashCommand', () => {
});
});
});
describe('getAvailableCommands', () => {
let mockConfig: Config;
beforeEach(() => {
mockCommandServiceCreate.mockResolvedValue({
getCommands: mockGetCommands,
getCommandsForMode: mockGetCommandsForMode,
});
mockConfig = {
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session'),
getFolderTrustFeature: vi.fn().mockReturnValue(false),
getFolderTrust: vi.fn().mockReturnValue(false),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
storage: {},
} as unknown as Config;
});
it('includes /export in the default non-interactive command list', async () => {
mockGetCommandsForMode.mockReturnValue([
{
name: 'export',
description: 'Export current session',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
},
]);
const commands = await getAvailableCommands(
mockConfig,
new AbortController().signal,
);
expect(commands.map((command) => command.name)).toContain('export');
});
});

View file

@ -45,6 +45,7 @@ import { recapCommand } from '../ui/commands/recapCommand.js';
import { renameCommand } from '../ui/commands/renameCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
@ -126,6 +127,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
renameCommand,
restoreCommand(this.config),
resumeCommand,
rewindCommand,
skillsCommand,
statsCommand,
summaryCommand,

View file

@ -57,6 +57,7 @@ describe('App', () => {
streamingState: StreamingState.Idle,
quittingMessages: null,
dialogsVisible: false,
stickyTodos: null,
mainControlsRef: { current: null },
historyManager: {
addItem: vi.fn(),

View file

@ -14,7 +14,8 @@ import {
type Mock,
} from 'vitest';
import { render, cleanup } from 'ink-testing-library';
import { AppContainer } from './AppContainer.js';
import { AppContainer, dedupeNewestFirst } from './AppContainer.js';
import ansiEscapes from 'ansi-escapes';
import {
type Config,
makeFakeConfig,
@ -28,7 +29,9 @@ import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
import { ToolCallStatus } from './types.js';
import { useContext } from 'react';
import { Box, measureElement } from 'ink';
// Mock useStdout to capture terminal title writes
let mockStdout: { write: ReturnType<typeof vi.fn> };
@ -48,7 +51,7 @@ let capturedUIActions: UIActions;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedUIActions = useContext(UIActionsContext)!;
return null;
return <Box ref={capturedUIState.mainControlsRef} />;
}
vi.mock('./App.js', () => ({
@ -120,7 +123,6 @@ import { useSessionStats } from './contexts/SessionContext.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { ShellExecutionService } from '@qwen-code/qwen-code-core';
@ -245,6 +247,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
mockedUseAutoAcceptIndicator.mockReturnValue(false);
mockedUseGitBranchName.mockReturnValue('main');
@ -426,6 +429,83 @@ describe('AppContainer State Management', () => {
}).not.toThrow();
});
it('refreshStatic clears the terminal before remounting history', () => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
capturedUIActions.refreshStatic();
expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal);
});
it('handleClearScreen avoids a second clearTerminal write', () => {
const clearSpy = vi.spyOn(console, 'clear').mockImplementation(() => {});
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
capturedUIActions.handleClearScreen();
expect(clearSpy).toHaveBeenCalledTimes(1);
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
clearSpy.mockRestore();
});
it('passes a remount-only refresh callback to slash commands', () => {
let slashRefreshStatic: (() => void) | undefined;
mockedUseSlashCommandProcessor.mockImplementation(
(
_config,
_settings,
_addItem,
_clearItems,
_loadHistory,
refreshStatic,
) => {
slashRefreshStatic = refreshStatic;
return {
handleSlashCommand: vi.fn(),
slashCommands: [],
pendingHistoryItems: [],
commandContext: {},
shellConfirmationRequest: null,
confirmationRequest: null,
};
},
);
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
slashRefreshStatic?.();
expect(slashRefreshStatic).toBeDefined();
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
});
it('provides ConfigContext with config object', () => {
expect(() => {
render(
@ -459,6 +539,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
render(
@ -476,6 +557,44 @@ describe('AppContainer State Management', () => {
expect(mockQueueMessage).not.toHaveBeenCalled();
});
it('submits slash commands immediately instead of queueing while idle', () => {
const mockSubmitQuery = vi.fn();
const mockQueueMessage = vi.fn();
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
capturedUIActions.handleFinalSubmit('/model');
expect(mockSubmitQuery).toHaveBeenCalledWith('/model');
expect(mockQueueMessage).not.toHaveBeenCalled();
});
it.each(['exit', 'quit', ':q', ':q!', ':wq', ':wq!'])(
'routes bare "%s" to /quit instead of sending as a message',
(command) => {
@ -497,6 +616,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
render(
@ -577,6 +697,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
render(
@ -605,6 +726,7 @@ describe('AppContainer State Management', () => {
it('moves queued follow-up messages into an empty buffer on cancel', async () => {
const mockSetText = vi.fn();
const mockPopAllMessages = vi.fn().mockReturnValue('queued follow-up');
const mockClearQueue = vi.fn();
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: mockSetText,
@ -626,10 +748,11 @@ describe('AppContainer State Management', () => {
mockedUseMessageQueue.mockReturnValue({
messageQueue: ['queued follow-up'],
addMessage: vi.fn(),
clearQueue: vi.fn(),
clearQueue: mockClearQueue,
getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'),
popAllMessages: mockPopAllMessages,
drainQueue: vi.fn().mockReturnValue(['queued follow-up']),
popNextSegment: vi.fn().mockReturnValue('queued follow-up'),
});
render(
@ -653,6 +776,75 @@ describe('AppContainer State Management', () => {
expect.stringContaining('the previous prompt'),
);
expect(mockPopAllMessages).toHaveBeenCalled();
// popAllForEdit drains the queue internally, so the cancel handler
// does not need to call clearQueue separately on this path.
expect(mockClearQueue).not.toHaveBeenCalled();
});
it('drops the queue when cancelling during tool execution', async () => {
// Simulates: user asks for a shell tool (e.g. sleep 30), queues
// `/model` and `hi` while the tool is running, then hits Ctrl+C.
// The cancel must clear BOTH the buffer and the queue so that
// `hi` does not auto-fire once the tool settles and the app
// returns to idle.
const mockSetText = vi.fn();
const mockClearQueue = vi.fn();
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: mockSetText,
});
installCancelCapture({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'run_shell_command',
description: 'sleep 30',
status: ToolCallStatus.Executing,
resultDisplay: undefined,
confirmationDetails: undefined,
renderOutputAsMarkdown: false,
},
],
},
],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
messageQueue: ['/model', 'hi'],
addMessage: vi.fn(),
clearQueue: mockClearQueue,
getQueuedMessagesText: vi.fn().mockReturnValue('/model\n\nhi'),
popAllMessages: vi.fn().mockReturnValue('/model'),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue('/model'),
});
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await Promise.resolve();
await Promise.resolve();
triggerCancel();
// Buffer cleared and queue dropped — same "abort and redirect"
// contract as the non-tool cancel path.
expect(mockSetText).toHaveBeenCalledWith('');
expect(mockClearQueue).toHaveBeenCalled();
});
it('preserves an in-progress draft when restoring queued messages on cancel', async () => {
@ -680,6 +872,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'),
popAllMessages: vi.fn().mockReturnValue('queued follow-up'),
drainQueue: vi.fn().mockReturnValue(['queued follow-up']),
popNextSegment: vi.fn().mockReturnValue('queued follow-up'),
});
render(
@ -1405,3 +1598,28 @@ describe('AppContainer State Management', () => {
});
});
});
describe('dedupeNewestFirst', () => {
it('returns empty array for empty input', () => {
expect(dedupeNewestFirst([])).toEqual([]);
});
it('preserves order when there are no duplicates', () => {
expect(dedupeNewestFirst(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('removes consecutive duplicates', () => {
expect(dedupeNewestFirst(['a', 'a', 'b'])).toEqual(['a', 'b']);
});
it('removes non-consecutive duplicates keeping the first (newest) occurrence', () => {
expect(
dedupeNewestFirst([
'first prompt',
'third prompt',
'second prompt',
'first prompt',
]),
).toEqual(['first prompt', 'third prompt', 'second prompt']);
});
});

View file

@ -58,6 +58,7 @@ import {
type WaitingToolCall,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { getStickyTodos } from './utils/todoSnapshot.js';
import { validateAuthMethod } from '../config/auth.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import process from 'node:process';
@ -74,6 +75,11 @@ import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useDeleteCommand } from './hooks/useDeleteCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useDoublePress } from './hooks/useDoublePress.js';
import {
computeApiTruncationIndex,
isRealUserTurn,
} from './utils/historyMapping.js';
import { useVimMode } from './contexts/VimModeContext.js';
import { CompactModeProvider } from './contexts/CompactModeContext.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
@ -88,7 +94,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useVim } from './hooks/vim.js';
import { isBtwCommand } from './utils/commandUtils.js';
import { isBtwCommand, isSlashCommand } from './utils/commandUtils.js';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { useFocus } from './hooks/useFocus.js';
@ -158,6 +164,20 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
});
}
// Exported for tests. Given a newest-first list of messages, return a list
// with duplicates removed, keeping the first (newest) occurrence of each.
export function dedupeNewestFirst(messages: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const msg of messages) {
if (!seen.has(msg)) {
seen.add(msg);
result.push(msg);
}
}
return result;
}
interface AppContainerProps {
config: Config;
settings: LoadedSettings;
@ -455,28 +475,27 @@ export const AppContainer = (props: AppContainerProps) => {
)
.map((item) => item.text)
.reverse();
// Current-session messages are already newest-first; combining with past
// messages gives a newest-first list. dedupeNewestFirst keeps the first
// (newest) occurrence so resubmitting an old prompt promotes it to
// "most recent" rather than leaving a stale copy at an older position.
const combinedMessages = [
...currentSessionUserMessages,
...pastMessagesRaw,
];
const deduplicatedMessages: string[] = [];
if (combinedMessages.length > 0) {
deduplicatedMessages.push(combinedMessages[0]);
for (let i = 1; i < combinedMessages.length; i++) {
if (combinedMessages[i] !== combinedMessages[i - 1]) {
deduplicatedMessages.push(combinedMessages[i]);
}
}
}
setUserMessages(deduplicatedMessages.reverse());
setUserMessages(dedupeNewestFirst(combinedMessages).reverse());
};
fetchUserMessages();
}, [historyManager.history, logger]);
const remountStaticHistory = useCallback(() => {
setHistoryRemountKey((prev) => prev + 1);
}, []);
const refreshStatic = useCallback(() => {
stdout.write(ansiEscapes.clearTerminal);
setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, stdout]);
remountStaticHistory();
}, [remountStaticHistory, stdout]);
const {
isThemeDialogOpen,
@ -627,6 +646,12 @@ export const AppContainer = (props: AppContainerProps) => {
const { isHooksDialogOpen, openHooksDialog, closeHooksDialog } =
useHooksDialog();
// Ref bridge: the guarded openRewindSelector callback is defined later
// (after useDoublePress), but slashCommandActions needs it now. The ref
// lets the useMemo capture a stable function pointer whose implementation
// is swapped in once the real callback exists.
const openRewindSelectorRef = useRef<() => void>(() => {});
const slashCommandActions = useMemo(
() => ({
openAuthDialog,
@ -655,6 +680,7 @@ export const AppContainer = (props: AppContainerProps) => {
openMcpDialog,
openHooksDialog,
openResumeDialog,
openRewindSelector: () => openRewindSelectorRef.current(),
handleResume,
openDeleteDialog,
}),
@ -699,7 +725,7 @@ export const AppContainer = (props: AppContainerProps) => {
historyManager.addItem,
historyManager.clearItems,
historyManager.loadHistory,
refreshStatic,
remountStaticHistory,
toggleVimEnabled,
isProcessing,
setIsProcessing,
@ -879,16 +905,18 @@ export const AppContainer = (props: AppContainerProps) => {
disabled: agentViewState.activeView !== 'main',
});
const { messageQueue, addMessage, popAllMessages, drainQueue } =
useMessageQueue({
isConfigInitialized,
streamingState,
submitQuery,
});
const {
messageQueue,
addMessage,
clearQueue,
popAllMessages,
drainQueue,
popNextSegment,
} = useMessageQueue();
// Bridge message queue to mid-turn drain via ref.
// drainQueue reads the synchronous queueRef inside the hook, so it
// stays consistent with popAllMessages even before React re-renders.
// stays consistent with popNextSegment even before React re-renders.
midTurnDrainRef.current = drainQueue;
// Connect remote input watcher to submitQuery for bidirectional sync.
@ -1164,6 +1192,14 @@ export const AppContainer = (props: AppContainerProps) => {
speculationRef.current = IDLE_SPECULATION;
}
if (
streamingState === StreamingState.Idle &&
isSlashCommand(submittedValue)
) {
void submitQuery(submittedValue);
return;
}
addMessage(submittedValue);
},
[
@ -1200,6 +1236,10 @@ export const AppContainer = (props: AppContainerProps) => {
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const stickyTodos = useMemo(
() => getStickyTodos(historyManager.history, pendingHistoryItems),
[historyManager.history, pendingHistoryItems],
);
// Terminal tab progress bar (OSC 9;4) for iTerm2/Ghostty
useTerminalProgress(streamingState, isToolExecuting(pendingHistoryItems));
@ -1210,28 +1250,22 @@ export const AppContainer = (props: AppContainerProps) => {
...pendingGeminiHistoryItems,
];
if (isToolExecuting(pendingHistoryItems)) {
buffer.setText(''); // Just clear the prompt
// Tool-cancel: drop both buffer and queue so nothing auto-fires later.
buffer.setText('');
clearQueue();
return;
}
// Move any queued follow-up messages back into the buffer so the user
// can edit or resubmit them. Otherwise leave the buffer alone — in
// particular, do NOT repopulate it with the previous prompt; the user
// can still recall it via history navigation (Up/Ctrl+P).
//
// popAllMessages is atomic via the queue's synchronous ref, matching
// the drain behavior used during tool completion.
// Restore queued input joined into the buffer for editing.
const popped = popAllMessages();
if (popped) {
const currentText = buffer.text;
// Preserve any in-progress draft the user typed since submitting (this
// is reachable via Ctrl+C cancel, which fires regardless of buffer
// content). Mirrors the popQueueIntoInput convention in InputPrompt.
buffer.setText(currentText ? `${popped}\n${currentText}` : popped);
}
}, [
buffer,
popAllMessages,
clearQueue,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
]);
@ -1239,8 +1273,8 @@ export const AppContainer = (props: AppContainerProps) => {
const handleClearScreen = useCallback(() => {
historyManager.clearItems();
clearScreen();
refreshStatic();
}, [historyManager, refreshStatic]);
remountStaticHistory();
}, [historyManager, remountStaticHistory]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
@ -1258,40 +1292,6 @@ export const AppContainer = (props: AppContainerProps) => {
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding);
const [controlsHeight, setControlsHeight] = useState(0);
useLayoutEffect(() => {
if (mainControlsRef.current) {
const fullFooterMeasurement = measureElement(mainControlsRef.current);
if (fullFooterMeasurement.height > 0) {
setControlsHeight(fullFooterMeasurement.height);
}
}
}, [buffer, terminalWidth, terminalHeight, btwItem]);
// agentViewState is declared earlier (before handleFinalSubmit) so it
// is available for input routing. Referenced here for layout computation.
// Compute available terminal height based on controls measurement.
// When in-process agents are present the AgentTabBar renders an extra
// row at the top of the layout; subtract it so downstream consumers
// (shell, transcript, etc.) don't overestimate available space.
const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0;
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight,
);
config.setShellExecutionConfig({
terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
terminalHeight: Math.max(
Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
1,
),
pager: settings.merged.tools?.shell?.pager,
showColor: settings.merged.tools?.shell?.showColor,
});
const isFocused = useFocus();
useBracketedPaste();
@ -1319,16 +1319,6 @@ export const AppContainer = (props: AppContainerProps) => {
const initialPrompt = useMemo(() => config.getQuestion(), [config]);
const initialPromptSubmitted = useRef(false);
useEffect(() => {
if (activePtyId) {
ShellExecutionService.resizePty(
activePtyId,
Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
Math.max(Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1),
);
}
}, [terminalWidth, availableTerminalHeight, activePtyId]);
useEffect(() => {
if (
initialPrompt &&
@ -1523,6 +1513,8 @@ export const AppContainer = (props: AppContainerProps) => {
const [escapePressedOnce, setEscapePressedOnce] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const dialogsVisibleRef = useRef(false);
const [isRewindSelectorOpen, setIsRewindSelectorOpen] = useState(false);
const [rewindEscPending, setRewindEscPending] = useState(false);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
@ -1536,8 +1528,107 @@ export const AppContainer = (props: AppContainerProps) => {
needsRestart: ideNeedsRestart,
restartReason: ideTrustRestartReason,
} = useIdeTrustListener();
const {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
settings,
streamingState,
history: historyManager.history,
sessionStats,
});
const dialogsVisible =
showWelcomeBackDialog ||
shouldShowIdePrompt ||
shouldShowCommandMigrationNudge ||
isFolderTrustDialogOpen ||
!!shellConfirmationRequest ||
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!codingPlanUpdateRequest ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
isMemoryDialogOpen ||
isModelDialogOpen ||
isTrustDialogOpen ||
activeArenaDialog !== null ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
isAuthenticating ||
isEditorDialogOpen ||
showIdeRestartPrompt ||
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isMcpDialogOpen ||
isHooksDialogOpen ||
isApprovalModeDialogOpen ||
isResumeDialogOpen ||
isDeleteDialogOpen ||
isExtensionsManagerDialogOpen ||
isRewindSelectorOpen ||
bgTasksDialogOpen;
dialogsVisibleRef.current = dialogsVisible;
const shouldShowStickyTodos =
stickyTodos !== null &&
!dialogsVisible &&
!isFeedbackDialogOpen &&
streamingState !== StreamingState.WaitingForConfirmation;
const [controlsHeight, setControlsHeight] = useState(0);
useLayoutEffect(() => {
if (!mainControlsRef.current) {
setControlsHeight(0);
return;
}
const fullFooterMeasurement = measureElement(mainControlsRef.current);
setControlsHeight(fullFooterMeasurement.height);
}, [
buffer,
terminalWidth,
terminalHeight,
btwItem,
dialogsVisible,
shouldShowStickyTodos,
stickyTodos,
]);
// agentViewState is declared earlier (before handleFinalSubmit) so it
// is available for input routing. Referenced here for layout computation.
const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0;
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight,
);
config.setShellExecutionConfig({
terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
terminalHeight: Math.max(
Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
1,
),
pager: settings.merged.tools?.shell?.pager,
showColor: settings.merged.tools?.shell?.showColor,
});
const isInitialMount = useRef(true);
useEffect(() => {
if (activePtyId) {
ShellExecutionService.resizePty(
activePtyId,
Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
Math.max(Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1),
);
}
}, [terminalWidth, availableTerminalHeight, activePtyId]);
useEffect(() => {
if (ideNeedsRestart) {
// IDE trust changed, force a restart.
@ -1570,6 +1661,98 @@ export const AppContainer = (props: AppContainerProps) => {
setShowEscapePrompt(showPrompt);
}, []);
// --- Rewind selector callbacks ---
const openRewindSelector = useCallback(() => {
if (streamingState !== StreamingState.Idle) return;
if (config.getIdeMode()) return;
if (dialogsVisibleRef.current) return;
const hasUserTurns = historyManager.history.some((h) => h.type === 'user');
if (!hasUserTurns) return;
setIsRewindSelectorOpen(true);
}, [streamingState, config, historyManager.history]);
openRewindSelectorRef.current = openRewindSelector;
const closeRewindSelector = useCallback(() => {
setIsRewindSelectorOpen(false);
}, []);
const handleRewindConfirm = useCallback(
(userItem: HistoryItem) => {
const geminiClient = config.getGeminiClient();
if (!geminiClient) return;
// 1. Compute values from current history BEFORE truncation
const originalHistory = historyManager.history;
const originalLength = originalHistory.length;
let targetTurnIndex = 0;
for (const h of originalHistory) {
if (h.id === userItem.id) break;
if (isRealUserTurn(h)) targetTurnIndex++;
}
// 2. Compute API truncation point
const apiHistory = geminiClient.getHistory();
const apiTruncateIndex = computeApiTruncationIndex(
originalHistory,
userItem.id,
apiHistory,
);
// Abort if the target turn is unreachable (e.g., absorbed by compression)
if (apiTruncateIndex < 0) {
historyManager.addItem(
{
type: 'error',
text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.',
},
Date.now(),
);
setIsRewindSelectorOpen(false);
return;
}
// 3. Truncate API history and strip stale thinking blocks
geminiClient.truncateHistory(apiTruncateIndex);
geminiClient.stripThoughtsFromHistory();
// 4. Truncate UI history (keep everything before the target item)
const truncatedUi = originalHistory.filter((h) => h.id < userItem.id);
historyManager.loadHistory(truncatedUi);
// 5. Re-render the terminal
refreshStatic();
// 6. Pre-populate input with the original user text
if (userItem.type === 'user' && userItem.text) {
buffer.setText(userItem.text);
}
// 7. Add info message
historyManager.addItem(
{
type: 'info',
text: 'Conversation rewound. Edit your prompt and press Enter to continue.',
},
Date.now(),
);
// 8. Record the rewind event — re-roots the parentUuid chain so
// rewound messages end up on a dead branch during resume.
config.getChatRecordingService()?.rewindRecording(targetTurnIndex, {
truncatedCount: originalLength - truncatedUi.length,
});
// 9. Close the selector
setIsRewindSelectorOpen(false);
},
[config, historyManager, refreshStatic, buffer],
);
const handleDoubleEscRewind = useDoublePress(openRewindSelector, (pending) =>
setRewindEscPending(pending),
);
const handleIdePromptComplete = useCallback(
(result: IdeIntegrationNudgeResult) => {
if (result.userSelection === 'yes') {
@ -1865,6 +2048,20 @@ export const AppContainer = (props: AppContainerProps) => {
return;
}
// Input is empty and idle — double-ESC opens rewind selector
if (
streamingState === StreamingState.Idle &&
!dialogsVisibleRef.current
) {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
setEscapePressedOnce(false);
handleDoubleEscRewind();
return;
}
// No action available, reset the flag
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
@ -1961,6 +2158,7 @@ export const AppContainer = (props: AppContainerProps) => {
compactMode,
setCompactMode,
refreshStatic,
handleDoubleEscRewind,
],
);
@ -2002,55 +2200,40 @@ export const AppContainer = (props: AppContainerProps) => {
stdout,
]);
const nightly = props.version.includes('nightly');
// Drain queued messages when idle. `queueDrainNonce` re-fires the effect
// after each submission settles so multi-step queues drain end-to-end.
const queueDrainingRef = useRef(false);
const [queueDrainNonce, setQueueDrainNonce] = useState(0);
useEffect(() => {
if (queueDrainingRef.current) return;
if (!isConfigInitialized) return;
if (streamingState !== StreamingState.Idle) return;
if (dialogsVisible) return;
if (messageQueue.length === 0) return;
const dialogsVisible =
showWelcomeBackDialog ||
shouldShowIdePrompt ||
shouldShowCommandMigrationNudge ||
isFolderTrustDialogOpen ||
!!shellConfirmationRequest ||
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!codingPlanUpdateRequest ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
isMemoryDialogOpen ||
isModelDialogOpen ||
isTrustDialogOpen ||
activeArenaDialog !== null ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
isAuthenticating ||
isEditorDialogOpen ||
showIdeRestartPrompt ||
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isMcpDialogOpen ||
isHooksDialogOpen ||
isApprovalModeDialogOpen ||
isResumeDialogOpen ||
isDeleteDialogOpen ||
isExtensionsManagerDialogOpen ||
bgTasksDialogOpen;
dialogsVisibleRef.current = dialogsVisible;
// Two-phase: batch plain prompts as one turn, else pop next slash command.
const plainPrompts = drainQueue();
const submission =
plainPrompts.length > 0 ? plainPrompts.join('\n\n') : popNextSegment();
if (submission === null) return;
const {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
settings,
queueDrainingRef.current = true;
Promise.resolve(submitQuery(submission)).finally(() => {
queueDrainingRef.current = false;
setQueueDrainNonce((n) => n + 1);
});
}, [
isConfigInitialized,
streamingState,
history: historyManager.history,
sessionStats,
});
dialogsVisible,
messageQueue,
drainQueue,
popNextSegment,
submitQuery,
queueDrainNonce,
]);
const nightly = props.version.includes('nightly');
const uiState: UIState = useMemo(
() => ({
@ -2126,6 +2309,7 @@ export const AppContainer = (props: AppContainerProps) => {
staticExtraHeight,
dialogsVisible,
pendingHistoryItems,
stickyTodos,
btwItem,
setBtwItem,
cancelBtw,
@ -2169,6 +2353,9 @@ export const AppContainer = (props: AppContainerProps) => {
// Prompt suggestion
promptSuggestion,
dismissPromptSuggestion,
// Rewind selector
isRewindSelectorOpen,
rewindEscPending,
}),
[
isThemeDialogOpen,
@ -2240,6 +2427,7 @@ export const AppContainer = (props: AppContainerProps) => {
staticExtraHeight,
dialogsVisible,
pendingHistoryItems,
stickyTodos,
btwItem,
setBtwItem,
cancelBtw,
@ -2285,6 +2473,9 @@ export const AppContainer = (props: AppContainerProps) => {
// Prompt suggestion
promptSuggestion,
dismissPromptSuggestion,
// Rewind selector
isRewindSelectorOpen,
rewindEscPending,
],
);
@ -2354,6 +2545,10 @@ export const AppContainer = (props: AppContainerProps) => {
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
// Rewind selector
openRewindSelector,
closeRewindSelector,
handleRewindConfirm,
}),
[
openThemeDialog,
@ -2418,6 +2613,10 @@ export const AppContainer = (props: AppContainerProps) => {
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
// Rewind selector
openRewindSelector,
closeRewindSelector,
handleRewindConfirm,
],
);

View file

@ -9,16 +9,31 @@ import { renameCommand } from './renameCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
const tryGenerateSessionTitleMock = vi.fn();
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
(await importOriginal()) as typeof import('@qwen-code/qwen-code-core');
return {
...original,
tryGenerateSessionTitle: (...args: unknown[]) =>
tryGenerateSessionTitleMock(...args),
};
});
describe('renameCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
tryGenerateSessionTitleMock.mockReset();
});
it('should have the correct name and description', () => {
expect(renameCommand.name).toBe('rename');
expect(renameCommand.description).toBe('Rename the current conversation');
expect(renameCommand.description).toBe(
'Rename the current conversation. --auto lets the fast model pick a title.',
);
});
it('should return error when config is not available', async () => {
@ -103,7 +118,7 @@ describe('renameCommand', () => {
const result = await renameCommand.action!(mockContext, 'my-feature');
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature');
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature', 'manual');
expect(result).toEqual({
type: 'message',
messageType: 'info',
@ -130,6 +145,7 @@ describe('renameCommand', () => {
expect(mockRenameSession).toHaveBeenCalledWith(
'test-session-id',
'my-feature',
'manual',
);
expect(result).toEqual({
type: 'message',
@ -159,4 +175,270 @@ describe('renameCommand', () => {
content: 'Failed to rename session.',
});
});
describe('bare /rename model selection', () => {
// Pins the kebab-case path's model choice: bare `/rename` (no args)
// prefers fastModel when one is configured, falls back to the main
// model otherwise. Previous tests mocked `getHistory: []` which bailed
// before the model selection ran, leaving this regression-prone.
function mockConfigForKebab(opts: { fastModel?: string; model?: string }): {
config: unknown;
generateContent: ReturnType<typeof vi.fn>;
} {
const generateContent = vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'fix-login-bug' }] } }],
});
const config = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn().mockReturnValue(true),
}),
getFastModel: vi.fn().mockReturnValue(opts.fastModel),
getModel: vi.fn().mockReturnValue(opts.model ?? 'main-model'),
getGeminiClient: vi.fn().mockReturnValue({
getHistory: vi.fn().mockReturnValue([
{ role: 'user', parts: [{ text: 'fix the login bug' }] },
{
role: 'model',
parts: [{ text: 'Looking at the handler now.' }],
},
]),
}),
getContentGenerator: vi.fn().mockReturnValue({ generateContent }),
};
return { config, generateContent };
}
it('uses fastModel when configured', async () => {
const { config, generateContent } = mockConfigForKebab({
fastModel: 'qwen-turbo',
model: 'main-model',
});
mockContext = createMockCommandContext({
services: { config: config as never },
});
await renameCommand.action!(mockContext, '');
expect(generateContent).toHaveBeenCalledOnce();
expect(generateContent.mock.calls[0][0].model).toBe('qwen-turbo');
});
it('falls back to main model when fastModel is unset', async () => {
const { config, generateContent } = mockConfigForKebab({
fastModel: undefined,
model: 'main-model',
});
mockContext = createMockCommandContext({
services: { config: config as never },
});
await renameCommand.action!(mockContext, '');
expect(generateContent).toHaveBeenCalledOnce();
expect(generateContent.mock.calls[0][0].model).toBe('main-model');
});
});
describe('--auto flag', () => {
it('refuses --auto when no fast model is configured', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue(undefined),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
});
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
});
it('refuses --auto combined with a positional name', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto my-name');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'/rename --auto does not take a name. Use `/rename <name>` to set a name yourself.',
});
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
});
it('writes an auto-sourced title on --auto success', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: true,
title: 'Fix login button on mobile',
modelUsed: 'qwen-turbo',
});
const mockRecordCustomTitle = vi.fn().mockReturnValue(true);
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: mockRecordCustomTitle,
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce();
expect(mockRecordCustomTitle).toHaveBeenCalledWith(
'Fix login button on mobile',
'auto',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Session renamed to "Fix login button on mobile"',
});
});
it('surfaces empty_history reason with actionable hint', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: false,
reason: 'empty_history',
});
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No conversation to title yet — send at least one message first.',
});
});
it('surfaces model_error reason distinctly', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: false,
reason: 'model_error',
});
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(result).toMatchObject({
messageType: 'error',
});
expect((result as { content: string }).content).toMatch(
/rate limit, auth, or network error/,
);
});
it('rejects unknown flag with sentinel hint', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(
mockContext,
'--my-label-with-dashes',
);
expect(result).toMatchObject({ messageType: 'error' });
const content = (result as { content: string }).content;
expect(content).toMatch(/Unknown flag "--my-label-with-dashes"/);
expect(content).toMatch(/\/rename -- --my-label-with-dashes/);
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
});
it('surfaces aborted reason when user cancels', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: false,
reason: 'aborted',
});
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Title generation was cancelled.',
});
});
it('falls back to SessionService.renameSession with auto source', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: true,
title: 'Audit auth middleware',
modelUsed: 'qwen-turbo',
});
const mockRenameSession = vi.fn().mockResolvedValue(true);
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue(undefined),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getSessionService: vi.fn().mockReturnValue({
renameSession: mockRenameSession,
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});
const result = await renameCommand.action!(mockContext, '--auto');
expect(mockRenameSession).toHaveBeenCalledWith(
'test-session-id',
'Audit auth middleware',
'auto',
);
expect(result).toMatchObject({ messageType: 'info' });
});
});
});

View file

@ -5,10 +5,13 @@
*/
import type { Content } from '@google/genai';
import type { Config } from '@qwen-code/qwen-code-core';
import {
getResponseText,
SESSION_TITLE_MAX_LENGTH,
stripTerminalControlSequences,
tryGenerateSessionTitle,
type Config,
type SessionTitleFailureReason,
} from '@qwen-code/qwen-code-core';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
@ -38,9 +41,11 @@ function extractConversationText(history: Content[]): string {
}
/**
* Calls the LLM to generate a short session title from conversation history.
* Calls the LLM to generate a short kebab-case session title from conversation
* history. Used when `/rename` is invoked with no arguments produces a
* filesystem-style name for sessions the user wants to keep long-term.
*/
async function generateSessionTitle(
async function generateKebabTitle(
config: Config,
signal?: AbortSignal,
): Promise<string | null> {
@ -51,9 +56,15 @@ async function generateSessionTitle(
return null;
}
// Prefer the fast model for title generation — it's much cheaper and
// faster than the main model, and title generation is a small bounded
// task that doesn't need main-model reasoning. Falls back to the main
// model when no fast model is configured so this path never fails to
// start.
const model = config.getFastModel() ?? config.getModel();
const response = await config.getContentGenerator().generateContent(
{
model: config.getModel(),
model,
contents: [
{
role: 'user',
@ -79,8 +90,13 @@ async function generateSessionTitle(
if (!text) {
return null;
}
// Clean up: take first line, remove quotes/backticks
const cleaned = text.split('\n')[0].replace(/["`']/g, '').trim();
// Clean up: strip ANSI / control sequences via the shared helper
// (same security concern as the sentence-case path — the title renders
// directly in the picker), then take the first line and drop quotes.
const cleaned = stripTerminalControlSequences(text)
.split('\n')[0]
.replace(/["`']/g, '')
.trim();
return cleaned.length > 0 && cleaned.length <= MAX_TITLE_LENGTH
? cleaned
: null;
@ -89,12 +105,88 @@ async function generateSessionTitle(
}
}
/**
* Translate a title-generation failure reason into a human-actionable
* message. Exists so `/rename --auto` doesn't collapse to a generic "could
* not generate" that leaves the user guessing about the cause.
*/
function autoFailureMessage(reason: SessionTitleFailureReason): string {
switch (reason) {
case 'no_fast_model':
return t(
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
);
case 'empty_history':
return t(
'No conversation to title yet — send at least one message first.',
);
case 'empty_result':
return t(
'The fast model returned no usable title. Try `/rename <name>` to set one yourself.',
);
case 'aborted':
return t('Title generation was cancelled.');
case 'model_error':
return t(
'The fast model could not generate a title (rate limit, auth, or network error). Check debug log or try again.',
);
case 'no_client':
return t('Session is still initializing — try again in a moment.');
default:
return t('Could not generate a title.');
}
}
/**
* Parse `--auto` out of the args. Kept simple rather than bringing in an
* argv parser we only have one flag.
*
* Rules:
* - `--auto` (case-insensitive) sets auto=true.
* - `--` terminates flag parsing; everything after is positional, so users
* can legitimately name sessions starting with `--` via `/rename -- --foo`.
* - Any other `--xxx` before `--` bubbles up as `unknownFlag` for a clean
* error, rather than silently becoming part of the title (`--Auto` typo,
* `--help` expectation, etc.).
*/
function parseArgs(raw: string): {
auto: boolean;
positional: string;
unknownFlag?: string;
} {
const trimmed = raw.trim().replace(/[\r\n]+/g, ' ');
if (!trimmed) return { auto: false, positional: '' };
const parts = trimmed.split(/\s+/);
let auto = false;
let unknownFlag: string | undefined;
let flagsDone = false;
const rest: string[] = [];
for (const p of parts) {
if (!flagsDone && p === '--') {
flagsDone = true;
continue;
}
if (!flagsDone && p.startsWith('--')) {
if (p.toLowerCase() === '--auto') {
auto = true;
continue;
}
if (!unknownFlag) unknownFlag = p;
continue;
}
rest.push(p);
}
return { auto, positional: rest.join(' '), unknownFlag };
}
export const renameCommand: SlashCommand = {
name: 'rename',
altNames: ['tag'],
kind: CommandKind.BUILT_IN,
get description() {
return t('Rename the current conversation');
return t(
'Rename the current conversation. --auto lets the fast model pick a title.',
);
},
action: async (context, args): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
@ -107,10 +199,87 @@ export const renameCommand: SlashCommand = {
};
}
let name = args.trim().replace(/[\r\n]+/g, ' ');
const { auto, positional, unknownFlag } = parseArgs(args);
if (unknownFlag) {
return {
type: 'message',
messageType: 'error',
content: t(
'Unknown flag "{{flag}}". Supported: --auto. To use this as a literal name, run `/rename -- {{flag}}`.',
{ flag: unknownFlag },
),
};
}
let name = positional;
// Track where the title came from so the session picker can dim
// auto-generated titles; explicit user text stays 'manual'.
let titleSource: 'auto' | 'manual' = 'manual';
// If no name provided, auto-generate one from conversation history
if (!name) {
if (auto) {
// Explicit user-triggered auto-title. This overwrites whatever title
// is currently set (manual or auto) because the user asked for it.
// Requires a configured fast model — we don't silently fall back to
// the main model here because `--auto` is a deliberate opt-in to the
// sentence-case fast-model flow, and surprising a user with a main-
// model call would defeat the purpose.
if (!config.getFastModel()) {
return {
type: 'message',
messageType: 'error',
content: t(
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
),
};
}
if (positional) {
return {
type: 'message',
messageType: 'error',
content: t(
'/rename --auto does not take a name. Use `/rename <name>` to set a name yourself.',
),
};
}
const dots = ['.', '..', '...'];
let dotIndex = 0;
const baseText = t('Regenerating session title');
context.ui.setPendingItem({
type: 'info',
text: baseText + dots[dotIndex],
});
const timer = setInterval(() => {
dotIndex = (dotIndex + 1) % dots.length;
context.ui.setPendingItem({
type: 'info',
text: baseText + dots[dotIndex],
});
}, 500);
// try/finally ensures the spinner stops even if tryGenerateSessionTitle
// ever throws (it currently swallows internally, but defensively so
// future regressions don't leak an interval timer).
let outcome: Awaited<ReturnType<typeof tryGenerateSessionTitle>>;
try {
outcome = await tryGenerateSessionTitle(
config,
context.abortSignal ?? new AbortController().signal,
);
} finally {
clearInterval(timer);
context.ui.setPendingItem(null);
}
if (!outcome.ok) {
return {
type: 'message',
messageType: 'error',
content: autoFailureMessage(outcome.reason),
};
}
name = outcome.title;
titleSource = 'auto';
} else if (!name) {
// Legacy no-arg behavior: kebab-case, generated via the main content
// generator with fallback to fastModel. Preserved as-is for users who
// prefer filesystem-style names.
const dots = ['.', '..', '...'];
let dotIndex = 0;
const baseText = t('Generating session name');
@ -125,9 +294,13 @@ export const renameCommand: SlashCommand = {
text: baseText + dots[dotIndex],
});
}, 500);
const generated = await generateSessionTitle(config, context.abortSignal);
clearInterval(timer);
context.ui.setPendingItem(null);
let generated: string | null;
try {
generated = await generateKebabTitle(config, context.abortSignal);
} finally {
clearInterval(timer);
context.ui.setPendingItem(null);
}
if (!generated) {
return {
type: 'message',
@ -151,7 +324,7 @@ export const renameCommand: SlashCommand = {
// Record the custom title in the current session's JSONL file
const chatRecordingService = config.getChatRecordingService();
if (chatRecordingService) {
const ok = chatRecordingService.recordCustomTitle(name);
const ok = chatRecordingService.recordCustomTitle(name, titleSource);
if (!ok) {
return {
type: 'message',
@ -163,7 +336,11 @@ export const renameCommand: SlashCommand = {
// Fallback: write via SessionService for non-recording sessions
const sessionId = config.getSessionId();
const sessionService = config.getSessionService();
const success = await sessionService.renameSession(sessionId, name);
const success = await sessionService.renameSession(
sessionId,
name,
titleSource,
);
if (!success) {
return {
type: 'message',

View file

@ -0,0 +1,22 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const rewindCommand: SlashCommand = {
name: 'rewind',
altNames: ['rollback'],
get description() {
return t('Rewind conversation to a previous turn');
},
kind: CommandKind.BUILT_IN,
action: async (): Promise<SlashCommandActionReturn> => ({
type: 'dialog',
dialog: 'rewind',
}),
};

View file

@ -24,7 +24,7 @@ export const skillsCommand: SlashCommand = {
return t('List available skills.');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
supportedModes: ['interactive', 'acp'] as const,
action: async (context: CommandContext, args?: string) => {
const rawArgs = args?.trim() ?? '';
const [skillName = ''] = rawArgs.split(/\s+/);

View file

@ -182,7 +182,8 @@ export interface OpenDialogActionReturn {
| 'delete'
| 'extensions_manage'
| 'hooks'
| 'mcp';
| 'mcp'
| 'rewind';
}
/**

View file

@ -16,7 +16,6 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
import { t } from '../../i18n/index.js';
@ -104,8 +103,6 @@ export const Composer = () => {
/>
)}
{!uiState.isConfigInitialized && <ConfigInitDisplay />}
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}

View file

@ -43,6 +43,7 @@ import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
import { HooksManagementDialog } from './hooks/HooksManagementDialog.js';
import { SessionPicker } from './SessionPicker.js';
import { RewindSelector } from './RewindSelector.js';
import { MemoryDialog } from './MemoryDialog.js';
import { BackgroundTasksDialog } from './background-view/BackgroundTasksDialog.js';
import { useBackgroundAgentViewState } from '../contexts/BackgroundAgentViewContext.js';
@ -384,6 +385,7 @@ export const DialogManager = ({
onSelect={uiActions.handleResume}
onCancel={uiActions.closeResumeDialog}
initialSessions={uiState.resumeMatchedSessions}
enablePreview
/>
);
}
@ -400,6 +402,16 @@ export const DialogManager = ({
);
}
if (uiState.isRewindSelectorOpen) {
return (
<RewindSelector
history={uiState.history}
onRewind={uiActions.handleRewindConfirm}
onCancel={uiActions.closeRewindSelector}
/>
);
}
// Background tasks dialog — lowest priority so other dialogs
// (permissions, trust prompts, auth, etc.) always take precedence. The
// dialog is part of the shared dialogsVisible machinery (see

View file

@ -79,6 +79,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
contextFileNames: [],
showToolDescriptions: false,
ideContextState: undefined,
isConfigInitialized: true,
...overrides,
}) as UIState;
@ -152,6 +153,43 @@ describe('<Footer />', () => {
});
});
describe('config init message', () => {
it('shows init status in place of the hint while config is initializing', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({ isConfigInitialized: false }),
);
const frame = lastFrame()!;
expect(frame).toContain('Initializing...');
expect(frame).not.toContain('? for shortcuts');
});
it('falls back to the hint once config is initialized', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({ isConfigInitialized: true }),
);
const frame = lastFrame()!;
expect(frame).not.toContain('Initializing...');
expect(frame).toContain('? for shortcuts');
});
// Init progress is more useful than zero layout shift: we show it even
// when a custom status line is active, accepting that the row shrinks
// by one line once init completes. Still strictly better than the
// original bug (a 2-row residual above the input in the default case).
it('shows init status even when a custom status line is active', () => {
useStatusLineMock.mockReturnValue({ lines: ['model-name ctx:34%'] });
const { lastFrame } = renderWithWidth(
120,
createMockUIState({ isConfigInitialized: false }),
);
const frame = lastFrame()!;
expect(frame).toContain('model-name ctx:34%');
expect(frame).toContain('Initializing...');
});
});
describe('footer rendering (golden snapshots)', () => {
it('renders complete footer on wide terminal', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());

View file

@ -16,10 +16,12 @@ import { BackgroundTasksPill } from './background-view/BackgroundTasksPill.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useStatusLine } from '../hooks/useStatusLine.js';
import { useConfigInitMessage } from '../hooks/useConfigInitMessage.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { t } from '../../i18n/index.js';
/**
@ -53,6 +55,7 @@ export const Footer: React.FC = () => {
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const { lines: statusLineLines } = useStatusLine();
const configInitMessage = useConfigInitMessage(uiState.isConfigInitialized);
const dreamRunning = useDreamRunning(config.getProjectRoot());
const { promptTokenCount, showAutoAcceptIndicator } = {
@ -83,17 +86,33 @@ export const Footer: React.FC = () => {
// occupies the footer, so the hint is redundant). Matches upstream behavior.
const suppressHint = statusLineLines.length > 0;
// Left bottom row: high-priority messages > approval mode > hint.
// MCP init progress lives in this row (not a standalone component above the
// input) so the live area's height is constant in the default case, avoiding
// the residual-blank-line artifact left behind when a separate block unmounts.
// When a custom status line is active, the row shrinks by 1 on transition to
// ready — a one-time, small regression preferred over hiding init progress.
//
// `configInitMessage` is placed ahead of `showAutoAcceptIndicator` so users
// launched with YOLO / auto-accept-edits still see the ~1s startup progress;
// the approval-mode indicator takes over as soon as init finishes.
const leftBottomContent = uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>{t('Press Ctrl+C again to exit.')}</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>{t('Press Ctrl+D again to exit.')}</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>{t('Press Esc again to clear.')}</Text>
) : uiState.rewindEscPending ? (
<Text color={theme.text.secondary}>
{t('Press Esc again to rewind conversation.')}
</Text>
) : vimEnabled && vimMode === 'INSERT' ? (
<Text color={theme.text.secondary}>-- INSERT --</Text>
) : uiState.shellModeActive ? (
<ShellModeIndicator />
) : configInitMessage ? (
<Text color={theme.text.secondary}>
<GeminiSpinner /> {configInitMessage}
</Text>
) : showAutoAcceptIndicator !== undefined &&
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />

View file

@ -169,6 +169,7 @@ describe('InputPrompt', () => {
navigateUp: vi.fn(),
navigateDown: vi.fn(),
handleSubmit: vi.fn(),
resetHistoryNav: vi.fn(),
};
mockedUseInputHistory.mockReturnValue(mockInputHistory);
@ -741,6 +742,25 @@ describe('InputPrompt', () => {
unmount();
});
it('should reset history navigation after submitting on Enter', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
isPerfectMatch: false,
});
props.buffer.setText('a prompt from history');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
stdin.write('\r');
await wait();
expect(props.onSubmit).toHaveBeenCalledWith('a prompt from history');
expect(mockInputHistory.resetHistoryNav).toHaveBeenCalled();
unmount();
});
it('should submit directly on Enter when a complete leaf command is typed', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,

View file

@ -293,6 +293,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
[],
);
// Ref to inputHistory.resetHistoryNav, populated after useInputHistory runs.
// Needed because handleSubmitAndClear is passed into useInputHistory as
// onSubmit, so we can't reference inputHistory directly here without a cycle.
const resetHistoryNavRef = useRef<() => void>(() => {});
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
// Expand any large paste placeholders to their full content before submitting
@ -330,6 +335,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.setText('');
onSubmit(finalValue);
// Reset history navigation so the next Up-arrow starts from the newest
// entry rather than advancing from whatever index the user picked.
resetHistoryNavRef.current();
// Dismiss follow-up suggestion after submit
followup.dismiss();
@ -373,6 +382,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onChange: customSetTextAndResetCompletionSignal,
});
resetHistoryNavRef.current = inputHistory.resetHistoryNav;
// When an arena session starts (agents appear), reset history position so
// that pressing down-arrow immediately focuses the agent tab bar instead
// of cycling through input history.

View file

@ -0,0 +1,328 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo, useCallback } from 'react';
import { Box, Text } from 'ink';
import type { HistoryItem } from '../types.js';
import { theme } from '../semantic-colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { truncateText } from '../utils/sessionPickerUtils.js';
import { isRealUserTurn } from '../utils/historyMapping.js';
import { t } from '../../i18n/index.js';
export interface RewindSelectorProps {
history: HistoryItem[];
onRewind: (userItem: HistoryItem) => void;
onCancel: () => void;
}
const MAX_VISIBLE_ITEMS = 7;
/**
* Extract user-type items from UI history for the rewind pick list.
*/
function getUserTurns(history: HistoryItem[]): HistoryItem[] {
return history.filter(isRealUserTurn);
}
interface TurnItemViewProps {
item: HistoryItem;
isSelected: boolean;
isFirst: boolean;
isLast: boolean;
showScrollUp: boolean;
showScrollDown: boolean;
maxPromptWidth: number;
turnNumber: number;
}
function TurnItemView({
item,
isSelected,
isFirst,
isLast,
showScrollUp,
showScrollDown,
maxPromptWidth,
turnNumber,
}: TurnItemViewProps): React.JSX.Element {
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
const prefix = isSelected
? ' '
: showUpIndicator
? '↑ '
: showDownIndicator
? '↓ '
: ' ';
const promptText = item.text || '(empty prompt)';
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
return (
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
bold={isSelected}
>
{prefix}
</Text>
<Text color={theme.text.secondary}>{`#${turnNumber} `}</Text>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
bold={isSelected}
>
{truncatedPrompt}
</Text>
</Box>
</Box>
);
}
/**
* Two-phase rewind selector:
* 1. Pick list choose which user turn to rewind to
* 2. Confirm confirm the rewind action
*/
export function RewindSelector({
history,
onRewind,
onCancel,
}: RewindSelectorProps) {
const { columns: width, rows: height } = useTerminalSize();
const userTurns = useMemo(() => getUserTurns(history), [history]);
const [selectedIndex, setSelectedIndex] = useState(userTurns.length - 1);
const [confirmItem, setConfirmItem] = useState<HistoryItem | null>(null);
const boxWidth = width - 4;
const maxVisibleItems = Math.min(MAX_VISIBLE_ITEMS, userTurns.length);
// Centered scroll offset
const scrollOffset = useMemo(() => {
if (userTurns.length <= maxVisibleItems) return 0;
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(userTurns.length - maxVisibleItems, offset);
return offset;
}, [userTurns.length, maxVisibleItems, selectedIndex]);
const visibleTurns = useMemo(
() => userTurns.slice(scrollOffset, scrollOffset + maxVisibleItems),
[userTurns, scrollOffset, maxVisibleItems],
);
const showScrollUp = scrollOffset > 0;
const showScrollDown = scrollOffset + maxVisibleItems < userTurns.length;
const handleConfirmSelect = useCallback(
(confirmed: boolean) => {
if (confirmed && confirmItem) {
onRewind(confirmItem);
} else {
setConfirmItem(null);
}
},
[confirmItem, onRewind],
);
// Pick-list key handler
useKeypress(
(key) => {
const { name, ctrl } = key;
if (name === 'escape' || (ctrl && name === 'c')) {
onCancel();
return;
}
if (name === 'return') {
const selected = userTurns[selectedIndex];
if (selected) {
setConfirmItem(selected);
}
return;
}
if (name === 'up' || name === 'k') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
return;
}
if (name === 'down' || name === 'j') {
setSelectedIndex((prev) => Math.min(userTurns.length - 1, prev + 1));
return;
}
},
{ isActive: confirmItem === null },
);
// Confirm key handler
useKeypress(
(key) => {
const { name, ctrl, sequence } = key;
if (name === 'escape' || (ctrl && name === 'c')) {
setConfirmItem(null);
return;
}
if (name === 'return' || sequence === 'y' || sequence === 'Y') {
handleConfirmSelect(true);
return;
}
if (sequence === 'n' || sequence === 'N') {
handleConfirmSelect(false);
return;
}
},
{ isActive: confirmItem !== null },
);
if (userTurns.length === 0) {
return (
<Box flexDirection="column" width={boxWidth}>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={boxWidth}
>
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{t('No user turns to rewind to.')}
</Text>
</Box>
</Box>
</Box>
);
}
// Confirm phase
if (confirmItem) {
const promptPreview = truncateText(
confirmItem.text || '(empty)',
boxWidth - 10,
);
return (
<Box flexDirection="column" width={boxWidth}>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={boxWidth}
>
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
{t('Rewind Conversation')}
</Text>
</Box>
<Box>
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
</Box>
<Box paddingX={1} flexDirection="column">
<Box marginBottom={1}>
<Text color={theme.text.primary}>{t('Rewind to: ')}</Text>
<Text color={theme.text.accent} bold>
{promptPreview}
</Text>
</Box>
<Text color={theme.status.warning}>
{t(
'This will remove all conversation after this turn. The prompt will be pre-populated in the input for editing.',
)}
</Text>
</Box>
<Box>
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
</Box>
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{t('Enter/Y to confirm · Esc/N to go back')}
</Text>
</Box>
</Box>
</Box>
);
}
// Pick-list phase
return (
<Box
flexDirection="column"
width={boxWidth}
height={height - 1}
overflow="hidden"
>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={boxWidth}
height={height - 1}
overflow="hidden"
>
{/* Header */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
{t('Rewind Conversation')}
</Text>
<Text color={theme.text.secondary}>
{' '}
{t('({{count}} turns)', { count: String(userTurns.length) })}
</Text>
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
</Box>
{/* Turn list */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{visibleTurns.map((item, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
return (
<TurnItemView
key={item.id}
item={item}
isSelected={actualIndex === selectedIndex}
isFirst={visibleIndex === 0}
isLast={visibleIndex === visibleTurns.length - 1}
showScrollUp={showScrollUp}
showScrollDown={showScrollDown}
maxPromptWidth={boxWidth - 10}
turnNumber={actualIndex + 1}
/>
);
})}
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
</Box>
{/* Footer */}
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{t('↑↓ to navigate · Enter to select · Esc to cancel')}
</Text>
</Box>
</Box>
</Box>
);
}

View file

@ -18,6 +18,7 @@ import {
} from '../utils/sessionPickerUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { t } from '../../i18n/index.js';
import { SessionPreview } from './SessionPreview.js';
export interface SessionPickerProps {
sessionService: SessionService | null;
@ -41,6 +42,14 @@ export interface SessionPickerProps {
* When provided, skips initial load and disables pagination.
*/
initialSessions?: SessionData[];
/**
* Enable Space-to-preview. Off by default preview's Enter shortcut
* forwards to `onSelect`, which for resume flows is "resume", but for
* destructive flows (e.g. delete) would commit the action. Only opt in
* for non-destructive selection flows.
*/
enablePreview?: boolean;
}
const PREFIX_CHARS = {
@ -94,6 +103,11 @@ function SessionListItemView({
const promptText = session.customTitle || session.prompt || '(empty prompt)';
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
// Dim auto-generated titles so users can distinguish a model guess from
// a title they chose themselves with `/rename`. Selected row keeps the
// accent color — legibility of the focused row wins over source hinting.
const isAutoTitle =
session.titleSource === 'auto' && Boolean(session.customTitle);
return (
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
@ -111,7 +125,13 @@ function SessionListItemView({
{prefix}
</Text>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
color={
isSelected
? theme.text.accent
: isAutoTitle
? theme.text.secondary
: theme.text.primary
}
bold={isSelected}
>
{truncatedPrompt}
@ -136,6 +156,7 @@ export function SessionPicker(props: SessionPickerProps) {
title,
centerSelection = true,
initialSessions,
enablePreview = false,
} = props;
const { columns: width, rows: height } = useTerminalSize();
@ -161,8 +182,32 @@ export function SessionPicker(props: SessionPickerProps) {
centerSelection,
initialSessions,
isActive: true,
enablePreview,
});
if (
enablePreview &&
picker.viewMode === 'preview' &&
picker.previewSessionId &&
sessionService
) {
const previewed = picker.filteredSessions.find(
(s) => s.sessionId === picker.previewSessionId,
);
return (
<SessionPreview
sessionService={sessionService}
sessionId={picker.previewSessionId}
sessionTitle={previewed?.customTitle ?? previewed?.prompt ?? undefined}
messageCount={previewed?.messageCount}
mtime={previewed?.mtime}
gitBranch={previewed?.gitBranch}
onExit={picker.exitPreview}
onResume={onSelect}
/>
);
}
return (
<Box
flexDirection="column"
@ -251,7 +296,12 @@ export function SessionPicker(props: SessionPickerProps) {
>
B
</Text>
{t(' to toggle branch')} ·
{t(' to toggle branch · ')}
</Text>
)}
{enablePreview && (
<Text color={theme.text.secondary}>
{t('Space to preview · ')}
</Text>
)}
<Text color={theme.text.secondary}>

View file

@ -0,0 +1,176 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SessionPreview } from './SessionPreview.js';
beforeEach(() => {
Object.defineProperty(process.stdout, 'columns', {
value: 80,
configurable: true,
});
Object.defineProperty(process.stdout, 'rows', {
value: 24,
configurable: true,
});
});
afterEach(() => vi.clearAllMocks());
const wait = (ms = 50) => new Promise((r) => setTimeout(r, ms));
function mockService(resolved: unknown) {
return {
loadSession: vi
.fn()
.mockReturnValue(
resolved instanceof Promise ? resolved : Promise.resolve(resolved),
),
listSessions: vi.fn(),
loadLastSession: vi.fn(),
} as never;
}
function fakeResumedData() {
return {
conversation: {
sessionId: 's1',
projectHash: 'h',
startTime: '2026-01-01T00:00:00.000Z',
lastUpdated: '2026-01-01T00:00:00.000Z',
messages: [
{
uuid: 'u1',
parentUuid: null,
sessionId: 's1',
timestamp: '2026-01-01T00:00:00.000Z',
type: 'user',
cwd: '/tmp',
version: 'test',
message: {
role: 'user',
parts: [{ text: 'Hello world PREVIEW-MARKER' }],
},
},
{
uuid: 'u2',
parentUuid: 'u1',
sessionId: 's1',
timestamp: '2026-01-01T00:00:01.000Z',
type: 'assistant',
cwd: '/tmp',
version: 'test',
message: {
role: 'model',
parts: [{ text: 'Hi from assistant REPLY-MARKER' }],
},
},
],
},
filePath: '/tmp/s1.jsonl',
lastCompletedUuid: 'u2',
};
}
describe('SessionPreview', () => {
it('shows loading state before data arrives', () => {
const svc = mockService(new Promise(() => {})); // never resolves
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
onExit={vi.fn()}
onResume={vi.fn()}
/>
</KeypressProvider>,
);
expect(lastFrame()).toContain('Loading session preview');
});
it('renders all messages after load', async () => {
const svc = mockService(fakeResumedData());
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
onExit={vi.fn()}
onResume={vi.fn()}
/>
</KeypressProvider>,
);
await wait(100);
const frame = lastFrame() ?? '';
expect(frame).toContain('PREVIEW-MARKER');
expect(frame).toContain('REPLY-MARKER');
});
it('renders footer metadata (messageCount · time · branch)', async () => {
const svc = mockService(fakeResumedData());
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
messageCount={42}
mtime={Date.now() - 60_000}
gitBranch="feat/preview"
onExit={vi.fn()}
onResume={vi.fn()}
/>
</KeypressProvider>,
);
await wait(100);
const frame = lastFrame() ?? '';
expect(frame).toMatch(/42\s*messages/);
expect(frame).toContain('feat/preview');
});
it('calls onExit when Escape is pressed', async () => {
const onExit = vi.fn();
const svc = mockService(fakeResumedData());
const { stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
onExit={onExit}
onResume={vi.fn()}
/>
</KeypressProvider>,
);
await wait(100);
stdin.write('\u001B'); // ESC
await wait(50);
expect(onExit).toHaveBeenCalledTimes(1);
});
it('calls onResume(sessionId) when Enter is pressed', async () => {
const onResume = vi.fn();
const svc = mockService(fakeResumedData());
const { stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPreview
sessionService={svc}
sessionId="s1"
sessionTitle="My session"
onExit={vi.fn()}
onResume={onResume}
/>
</KeypressProvider>,
);
await wait(100);
stdin.write('\r'); // Enter
await wait(50);
expect(onResume).toHaveBeenCalledWith('s1');
});
});

View file

@ -0,0 +1,164 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useEffect, useMemo, useState } from 'react';
import type {
ResumedSessionData,
SessionService,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import { formatRelativeTime } from '../utils/formatters.js';
import { formatMessageCount } from '../utils/sessionPickerUtils.js';
import { t } from '../../i18n/index.js';
export interface SessionPreviewProps {
sessionService: SessionService;
sessionId: string;
sessionTitle?: string;
/** Message count from the session list entry, for the footer. */
messageCount?: number;
/** Last-modified time (ms epoch) from the session list entry, for the footer. */
mtime?: number;
/** Git branch from the session list entry, for the footer. */
gitBranch?: string;
onExit: () => void;
onResume: (sessionId: string) => void;
}
export function SessionPreview(props: SessionPreviewProps) {
const {
sessionService,
sessionId,
sessionTitle,
messageCount,
mtime,
gitBranch,
onExit,
onResume,
} = props;
const { columns } = useTerminalSize();
const [data, setData] = useState<ResumedSessionData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setData(null);
setError(null);
sessionService
.loadSession(sessionId)
.then((d: ResumedSessionData | undefined) => {
if (cancelled) return;
if (!d) {
setError('Session not found');
return;
}
setData(d);
})
.catch((e: unknown) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, [sessionService, sessionId]);
// Preview passes `null` config: tool_group entries degrade to name-only
// (no description). Users can press Enter to resume for full fidelity.
const items = useMemo(() => {
if (!data) return [];
return buildResumedHistoryItems(data, null);
}, [data]);
useKeypress(
(key) => {
const { name, ctrl } = key;
if (name === 'escape' || (ctrl && name === 'c')) {
onExit();
return;
}
if (name === 'return') {
onResume(sessionId);
}
},
{ isActive: true },
);
// Clamp to a safe minimum: `'─'.repeat(boxWidth - 2)` would throw RangeError
// in very narrow terminals (tmux splits, small panes) if boxWidth < 2.
const boxWidth = Math.max(10, columns - 4);
const separatorWidth = Math.max(0, boxWidth - 2);
const metaParts: string[] = [];
if (typeof messageCount === 'number') {
metaParts.push(formatMessageCount(messageCount));
}
if (typeof mtime === 'number') {
metaParts.push(formatRelativeTime(mtime));
}
if (gitBranch) {
metaParts.push(gitBranch);
}
const metaLine = metaParts.join(' · ');
return (
<Box flexDirection="column" width={boxWidth}>
{/* Header */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
{sessionTitle ?? t('Session Preview')}
</Text>
</Box>
<Box>
<Text color={theme.border.default}>{'─'.repeat(separatorWidth)}</Text>
</Box>
{/* Body: render all items, let the terminal's scrollback own overflow. */}
{error ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.status.error}>{error}</Text>
</Box>
) : !data ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{t('Loading session preview...')}
</Text>
</Box>
) : (
<Box flexDirection="column">
{items.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
terminalWidth={boxWidth}
isPending={false}
/>
))}
</Box>
)}
{/* Footer */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(separatorWidth)}</Text>
</Box>
{metaLine && (
<Box paddingX={1}>
<Text color={theme.text.secondary}>{metaLine}</Text>
</Box>
)}
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{t('Enter to resume · Esc to back')}
</Text>
</Box>
</Box>
);
}

View file

@ -963,11 +963,25 @@ describe('SettingsDialog', () => {
</KeypressProvider>,
);
// Trigger a restart-required setting change: navigate to "Language: UI" (2nd item) and toggle it.
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
await waitFor(() => {
expect(lastFrame()).toContain('Tool Approval Mode');
});
const languageIndex = getDialogSettingKeys().indexOf('general.language');
expect(languageIndex).toBeGreaterThanOrEqual(0);
const press = async (key: string) => {
act(() => {
stdin.write(key);
});
await wait();
};
// Trigger a restart-required setting change by toggling the UI language setting.
for (let i = 0; i < languageIndex; i++) {
await press(TerminalKeys.DOWN_ARROW as string);
}
await press(TerminalKeys.ENTER as string);
await waitFor(() => {
expect(lastFrame()).toContain(
@ -976,10 +990,8 @@ describe('SettingsDialog', () => {
});
// Switch scopes; restart prompt should remain visible.
stdin.write(TerminalKeys.TAB as string);
await wait();
stdin.write('2');
await wait();
await press(TerminalKeys.TAB as string);
await press('2');
await waitFor(() => {
expect(lastFrame()).toContain(

View file

@ -4,11 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ReactNode } from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { SessionPicker } from './SessionPicker.js';
import type { LoadedSettings } from '../../config/settings.js';
import type {
Config,
SessionListItem,
ListSessionsResult,
} from '@qwen-code/qwen-code-core';
@ -621,4 +626,281 @@ describe('SessionPicker', () => {
unmount();
});
});
describe('Preview Mode', () => {
// Mirror `StandaloneSessionPicker`'s runtime wrapping so the preview
// render tree (ToolGroupMessage, ToolMessage) can safely call
// `useConfig()` / `useSettings()` in tests. Without these, any test
// whose previewed session contains tool calls would crash.
const PREVIEW_CONFIG_STUB = {
getShouldUseNodePtyShell: () => false,
getIdeMode: () => false,
isTrustedFolder: () => false,
getToolRegistry: () => ({ getTool: () => undefined }),
getContentGenerator: () => ({ useSummarizedThinking: () => false }),
} as unknown as Config;
const PREVIEW_SETTINGS_STUB = {
merged: { ui: {} },
} as unknown as LoadedSettings;
function renderPicker(children: ReactNode) {
return render(
<KeypressProvider kittyProtocolEnabled={false}>
<ConfigContext.Provider value={PREVIEW_CONFIG_STUB}>
<SettingsContext.Provider value={PREVIEW_SETTINGS_STUB}>
{children}
</SettingsContext.Provider>
</ConfigContext.Provider>
</KeypressProvider>,
);
}
function fakeResumedData(sessionId: string) {
return {
conversation: {
sessionId,
projectHash: 'h',
startTime: '2026-01-01T00:00:00.000Z',
lastUpdated: '2026-01-01T00:00:00.000Z',
messages: [
{
uuid: 'u1',
parentUuid: null,
sessionId,
timestamp: '2026-01-01T00:00:00.000Z',
type: 'user',
cwd: '/tmp',
version: 'test',
message: {
role: 'user',
parts: [{ text: 'USER-ASKED-THIS' }],
},
},
{
uuid: 'u2',
parentUuid: 'u1',
sessionId,
timestamp: '2026-01-01T00:00:01.000Z',
type: 'assistant',
cwd: '/tmp',
version: 'test',
message: {
role: 'model',
parts: [{ text: 'ASSISTANT-REPLIED' }],
},
},
],
},
filePath: `/tmp/${sessionId}.jsonl`,
lastCompletedUuid: 'u2',
};
}
it('opens preview on Space and closes on Esc', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'First session',
messageCount: 2,
}),
];
const service = createMockSessionService(sessions);
service.loadSession.mockResolvedValue(fakeResumedData('s1'));
const { stdin, lastFrame } = renderPicker(
<SessionPicker
sessionService={service as never}
onSelect={vi.fn()}
onCancel={vi.fn()}
enablePreview
/>,
);
await wait(100);
expect(lastFrame()).toContain('First session');
stdin.write(' '); // Space
await wait(150);
const previewFrame = lastFrame() ?? '';
expect(previewFrame).toContain('USER-ASKED-THIS');
expect(previewFrame).toContain('ASSISTANT-REPLIED');
stdin.write('\u001B'); // Esc
await wait(50);
const afterExitFrame = lastFrame() ?? '';
expect(afterExitFrame).toContain('First session');
expect(afterExitFrame).not.toContain('USER-ASKED-THIS');
});
it('renders tool_group items without crashing (stub Providers mounted)', async () => {
// The previewed session contains a function call + tool_result, which
// produces a `tool_group` HistoryItem that exercises ToolGroupMessage
// and ToolMessage — the places that throw without stub Providers.
const toolSession = {
conversation: {
sessionId: 's1',
projectHash: 'h',
startTime: '2026-01-01T00:00:00.000Z',
lastUpdated: '2026-01-01T00:00:00.000Z',
messages: [
{
uuid: 'u1',
parentUuid: null,
sessionId: 's1',
timestamp: '2026-01-01T00:00:00.000Z',
type: 'user',
cwd: '/tmp',
version: 'test',
message: { role: 'user', parts: [{ text: 'list files' }] },
},
{
uuid: 'u2',
parentUuid: 'u1',
sessionId: 's1',
timestamp: '2026-01-01T00:00:01.000Z',
type: 'assistant',
cwd: '/tmp',
version: 'test',
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'call-1',
name: 'BashTool',
args: { command: 'ls' },
},
},
],
},
},
{
uuid: 'u3',
parentUuid: 'u2',
sessionId: 's1',
timestamp: '2026-01-01T00:00:02.000Z',
type: 'tool_result',
cwd: '/tmp',
version: 'test',
toolCallResult: {
callId: 'call-1',
resultDisplay: 'a.txt\nb.txt',
status: 'success',
},
},
],
},
filePath: '/tmp/s1.jsonl',
lastCompletedUuid: 'u3',
};
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'list files',
messageCount: 3,
}),
];
const service = createMockSessionService(sessions);
service.loadSession.mockResolvedValue(toolSession);
const { stdin, lastFrame } = renderPicker(
<SessionPicker
sessionService={service as never}
onSelect={vi.fn()}
onCancel={vi.fn()}
enablePreview
/>,
);
await wait(100);
stdin.write(' '); // Space → preview
await wait(150);
const frame = lastFrame() ?? '';
// Tool group renders with raw function name fallback (no registry).
expect(frame).toContain('BashTool');
});
it('Enter inside preview fires onSelect with previewed sessionId', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'First',
messageCount: 2,
}),
createMockSession({
sessionId: 's2',
prompt: 'Second',
messageCount: 2,
}),
];
const service = createMockSessionService(sessions);
service.loadSession.mockResolvedValue(fakeResumedData('s1'));
const onSelect = vi.fn();
const { stdin } = renderPicker(
<SessionPicker
sessionService={service as never}
onSelect={onSelect}
onCancel={vi.fn()}
enablePreview
/>,
);
await wait(100);
stdin.write(' '); // open preview on s1
await wait(150);
stdin.write('\r'); // Enter
await wait(50);
expect(onSelect).toHaveBeenCalledWith('s1');
});
it('without enablePreview, Space is a no-op and footer omits the hint', async () => {
// Regression: SessionPicker is also reused by the delete-session
// dialog, where `onSelect = handleDelete`. If preview were on by
// default, Space → preview → Enter would silently delete the session
// while the preview UI still says "Enter to resume". The default must
// stay opt-in.
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'Deletable session',
messageCount: 2,
}),
];
const service = createMockSessionService(sessions);
service.loadSession.mockResolvedValue(fakeResumedData('s1'));
const onSelect = vi.fn();
const { stdin, lastFrame } = renderPicker(
<SessionPicker
sessionService={service as never}
onSelect={onSelect}
onCancel={vi.fn()}
// intentionally NO enablePreview — emulates the delete dialog
/>,
);
await wait(100);
const beforeFrame = lastFrame() ?? '';
expect(beforeFrame).toContain('Deletable session');
// Hint must not appear, otherwise we are training users to press
// Space in destructive flows.
expect(beforeFrame).not.toContain('Space to preview');
stdin.write(' '); // Space
await wait(150);
const afterFrame = lastFrame() ?? '';
// No preview body, still on the list.
expect(afterFrame).not.toContain('USER-ASKED-THIS');
expect(afterFrame).toContain('Deletable session');
// Enter must still call onSelect on the highlighted row (delete path
// unchanged), not be eaten by a phantom preview.
stdin.write('\r');
await wait(50);
expect(onSelect).toHaveBeenCalledWith('s1');
expect(service.loadSession).not.toHaveBeenCalled();
});
});
});

View file

@ -9,12 +9,41 @@ import { render, Box, useApp } from 'ink';
import {
getGitBranch,
SessionService,
type Config,
type SessionListItem,
} from '@qwen-code/qwen-code-core';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { SessionPicker } from './SessionPicker.js';
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
/**
* `--resume` runs this picker BEFORE `loadCliConfig`, so no real Config /
* LoadedSettings exist yet. But the preview render tree (HistoryItemDisplay
* ToolGroupMessage ToolMessage) calls `useConfig()` / `useSettings()`,
* which throw without a Provider mounted.
*
* These stubs satisfy the Context consumers. Every downstream access of
* Config/Settings in the preview path is either optional-chained or gated
* on states (Confirming / Executing) that never occur in resumed session
* data, so the stubbed methods are only read, never invoked for real work.
* Tool descriptions fall back to the raw function-call name (see
* `buildResumedHistoryItems` handling when the registry returns undefined).
*/
const PREVIEW_CONFIG_STUB = {
getShouldUseNodePtyShell: () => false,
getIdeMode: () => false,
isTrustedFolder: () => false,
getToolRegistry: () => ({ getTool: () => undefined }),
getContentGenerator: () => ({ useSummarizedThinking: () => false }),
} as unknown as Config;
const PREVIEW_SETTINGS_STUB = {
merged: { ui: {} },
} as unknown as LoadedSettings;
interface StandalonePickerScreenProps {
sessionService: SessionService;
onSelect: (sessionId: string) => void;
@ -43,20 +72,25 @@ function StandalonePickerScreen({
}
return (
<SessionPicker
sessionService={sessionService}
onSelect={(id) => {
onSelect(id);
handleExit();
}}
onCancel={() => {
onCancel();
handleExit();
}}
currentBranch={currentBranch}
centerSelection={true}
initialSessions={initialSessions}
/>
<ConfigContext.Provider value={PREVIEW_CONFIG_STUB}>
<SettingsContext.Provider value={PREVIEW_SETTINGS_STUB}>
<SessionPicker
sessionService={sessionService}
onSelect={(id) => {
onSelect(id);
handleExit();
}}
onCancel={() => {
onCancel();
handleExit();
}}
currentBranch={currentBranch}
centerSelection={true}
initialSessions={initialSessions}
enablePreview
/>
</SettingsContext.Provider>
</ConfigContext.Provider>
);
}

View file

@ -0,0 +1,57 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, expect, it } from 'vitest';
import { StickyTodoList } from './StickyTodoList.js';
import type { TodoItem } from './TodoDisplay.js';
describe('StickyTodoList', () => {
it('keeps each task number attached to the original task after sorting', () => {
const todos: TodoItem[] = [
{
id: 'done',
content: 'Summarize results',
status: 'completed',
},
{
id: 'pending',
content: 'Run cli tests',
status: 'pending',
},
{
id: 'active',
content: 'Run core tests',
status: 'in_progress',
},
];
const { lastFrame } = render(<StickyTodoList todos={todos} width={60} />);
const output = lastFrame() ?? '';
const lines = output
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
expect(output).toContain('Current tasks');
expect(output).toContain('╭');
expect(
lines.find((line) => line.includes('Run core tests')) ?? '',
).toContain('3.');
expect(
lines.find((line) => line.includes('Run cli tests')) ?? '',
).toContain('2.');
expect(
lines.find((line) => line.includes('Summarize results')) ?? '',
).toContain('1.');
expect(output.indexOf('Run core tests')).toBeLessThan(
output.indexOf('Run cli tests'),
);
expect(output.indexOf('Run cli tests')).toBeLessThan(
output.indexOf('Summarize results'),
);
});
});

View file

@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { t } from '../../i18n/index.js';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { getOrderedStickyTodos } from '../utils/todoSnapshot.js';
import type { TodoItem } from './TodoDisplay.js';
interface StickyTodoListProps {
todos: TodoItem[];
width: number;
}
const STATUS_ICONS = {
pending: '○',
in_progress: '◐',
completed: '●',
} as const;
export const StickyTodoList: React.FC<StickyTodoListProps> = ({
todos,
width,
}) => {
const orderedTodos = useMemo(() => getOrderedStickyTodos(todos), [todos]);
const todoNumberById = useMemo(
() =>
new Map(todos.map((todo, index) => [todo.id, `${index + 1}.`] as const)),
[todos],
);
if (todos.length === 0) {
return null;
}
const numberColumnWidth = String(orderedTodos.length).length + 2;
return (
<Box
marginX={2}
width={width}
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
>
<Text color={theme.text.secondary} bold>
{t('Current tasks')}
</Text>
{orderedTodos.map((todo, index) => {
const todoNumber = todoNumberById.get(todo.id) ?? `${index + 1}.`;
const itemColor =
todo.status === 'in_progress'
? Colors.AccentGreen
: Colors.Foreground;
return (
<Box key={todo.id} flexDirection="row" minHeight={1}>
<Box width={numberColumnWidth}>
<Text color={theme.text.secondary}>{todoNumber}</Text>
</Box>
<Box width={2}>
<Text color={itemColor}>{STATUS_ICONS[todo.status]}</Text>
</Box>
<Box flexGrow={1}>
<Text
color={itemColor}
strikethrough={todo.status === 'completed'}
wrap="wrap"
>
{todo.content}
</Text>
</Box>
</Box>
);
})}
</Box>
);
};

View file

@ -10,6 +10,7 @@ import {
AgentStatus,
ArenaSessionStatus,
type ArenaManager,
type ArenaAgentResult,
type Config,
} from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../../test-utils/render.js';
@ -17,72 +18,7 @@ import { ArenaSelectDialog } from './ArenaSelectDialog.js';
describe('ArenaSelectDialog', () => {
it('toggles quick preview and detailed diff for the highlighted agent', async () => {
const result = {
sessionId: 'arena-1',
task: 'Update auth',
status: ArenaSessionStatus.IDLE,
agents: [
{
agentId: 'model-1',
model: { modelId: 'model-1', authType: 'openai' },
status: AgentStatus.IDLE,
worktree: {
id: 'w1',
name: 'model-1',
path: '/tmp/model-1',
branch: 'arena/model-1',
isActive: true,
createdAt: 1,
},
stats: {
rounds: 1,
totalTokens: 1000,
inputTokens: 700,
outputTokens: 300,
durationMs: 2000,
toolCalls: 2,
successfulToolCalls: 2,
failedToolCalls: 0,
},
diff: `diff --git a/src/auth.ts b/src/auth.ts
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -1 +1 @@
-old
+new`,
diffSummary: {
files: [{ path: 'src/auth.ts', additions: 1, deletions: 1 }],
additions: 1,
deletions: 1,
},
modifiedFiles: ['src/auth.ts'],
approachSummary: 'Updated the auth implementation inline.',
startedAt: 1,
},
],
startedAt: 1,
wasRepoInitialized: false,
};
const manager = {
getResult: vi.fn(() => result),
getAgentStates: vi.fn(() => [
{
agentId: 'model-1',
model: { modelId: 'model-1', authType: 'openai' },
status: AgentStatus.IDLE,
stats: result.agents[0]!.stats,
},
]),
getAgentState: vi.fn(),
applyAgentResult: vi.fn(),
} as unknown as ArenaManager;
const config = {
getArenaManager: () => manager,
cleanupArenaRuntime: vi.fn(),
getChatRecordingService: () => undefined,
} as unknown as Config;
const { manager, config } = createDialogHarness();
const { lastFrame, stdin } = renderWithProviders(
<ArenaSelectDialog
@ -105,4 +41,196 @@ describe('ArenaSelectDialog', () => {
});
expect(lastFrame()).toContain('diff --git a/src/auth.ts b/src/auth.ts');
});
it('closes without applying or cleaning up when Escape is pressed', async () => {
const { manager, config, closeArenaDialog, applyAgentResult } =
createDialogHarness();
const cleanupArenaRuntime = config.cleanupArenaRuntime as ReturnType<
typeof vi.fn
>;
const { stdin } = renderWithProviders(
<ArenaSelectDialog
manager={manager}
config={config}
addItem={vi.fn()}
closeArenaDialog={closeArenaDialog}
/>,
);
stdin.write('\x1B');
await waitFor(() => {
expect(closeArenaDialog).toHaveBeenCalledTimes(1);
});
expect(applyAgentResult).not.toHaveBeenCalled();
expect(cleanupArenaRuntime).not.toHaveBeenCalled();
});
it('discards results without applying changes when x is pressed', async () => {
const { manager, config, closeArenaDialog, applyAgentResult } =
createDialogHarness();
const cleanupArenaRuntime = config.cleanupArenaRuntime as ReturnType<
typeof vi.fn
>;
const { stdin } = renderWithProviders(
<ArenaSelectDialog
manager={manager}
config={config}
addItem={vi.fn()}
closeArenaDialog={closeArenaDialog}
/>,
);
stdin.write('x');
await waitFor(() => {
expect(cleanupArenaRuntime).toHaveBeenCalledWith(true);
});
expect(closeArenaDialog).toHaveBeenCalledTimes(1);
expect(applyAgentResult).not.toHaveBeenCalled();
});
it('applies the highlighted successful agent when Enter is pressed', async () => {
const { manager, config, closeArenaDialog, applyAgentResult } =
createDialogHarness();
const cleanupArenaRuntime = config.cleanupArenaRuntime as ReturnType<
typeof vi.fn
>;
const { stdin } = renderWithProviders(
<ArenaSelectDialog
manager={manager}
config={config}
addItem={vi.fn()}
closeArenaDialog={closeArenaDialog}
/>,
);
stdin.write('\r');
await waitFor(() => {
expect(applyAgentResult).toHaveBeenCalledWith('model-1');
});
expect(closeArenaDialog).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(cleanupArenaRuntime).toHaveBeenCalledWith(true);
});
});
it('ignores Enter when the highlighted agent is not selectable', async () => {
const failedAgent = createAgentResult({
agentId: 'model-1',
status: AgentStatus.FAILED,
});
const { manager, config, closeArenaDialog, applyAgentResult } =
createDialogHarness([failedAgent]);
const cleanupArenaRuntime = config.cleanupArenaRuntime as ReturnType<
typeof vi.fn
>;
const { stdin } = renderWithProviders(
<ArenaSelectDialog
manager={manager}
config={config}
addItem={vi.fn()}
closeArenaDialog={closeArenaDialog}
/>,
);
stdin.write('\r');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(applyAgentResult).not.toHaveBeenCalled();
expect(closeArenaDialog).not.toHaveBeenCalled();
expect(cleanupArenaRuntime).not.toHaveBeenCalled();
});
});
function createDialogHarness(agents = [createAgentResult()]) {
const result = {
sessionId: 'arena-1',
task: 'Update auth',
status: ArenaSessionStatus.IDLE,
agents,
startedAt: 1,
wasRepoInitialized: false,
};
const applyAgentResult = vi.fn().mockResolvedValue({ success: true });
const manager = {
getResult: vi.fn(() => result),
getAgentStates: vi.fn(() =>
agents.map((agent) => ({
agentId: agent.agentId,
model: agent.model,
status: agent.status,
stats: agent.stats,
})),
),
getAgentState: vi.fn((agentId: string) =>
agents.find((agent) => agent.agentId === agentId),
),
applyAgentResult,
} as unknown as ArenaManager;
const config = {
getArenaManager: () => manager,
cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined),
getChatRecordingService: () => undefined,
} as unknown as Config;
return {
manager,
config,
closeArenaDialog: vi.fn(),
applyAgentResult,
};
}
function createAgentResult({
agentId = 'model-1',
status = AgentStatus.IDLE,
}: {
agentId?: string;
status?: AgentStatus;
} = {}): ArenaAgentResult {
return {
agentId,
model: { modelId: agentId, authType: 'openai' },
status,
worktree: {
id: `worktree-${agentId}`,
name: agentId,
path: `/tmp/${agentId}`,
branch: `arena/${agentId}`,
isActive: true,
createdAt: 1,
},
stats: {
rounds: 1,
totalTokens: 1000,
inputTokens: 700,
outputTokens: 300,
durationMs: 2000,
toolCalls: 2,
successfulToolCalls: 2,
failedToolCalls: 0,
},
diff: `diff --git a/src/auth.ts b/src/auth.ts
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -1 +1 @@
-old
+new`,
diffSummary: {
files: [{ path: 'src/auth.ts', additions: 1, deletions: 1 }],
additions: 1,
deletions: 1,
},
modifiedFiles: ['src/auth.ts'],
approachSummary: 'Updated the auth implementation inline.',
startedAt: 1,
};
}

View file

@ -547,6 +547,76 @@ describe('<ToolMessage />', () => {
expect(output).toContain('line 30');
});
it('pre-slices large non-shell string output before MaxSizedBox layout', () => {
const longString = Array.from(
{ length: 5000 },
(_, i) => `line ${i + 1}`,
).join('\n');
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="some-other-tool"
resultDisplay={longString}
status={ToolCallStatus.Success}
availableTerminalHeight={12}
/>,
StreamingState.Idle,
);
const output = lastFrame()!;
expect(output).toContain('... first 4995 lines hidden ...');
expect(output).not.toContain('line 4995');
expect(output).toContain('line 4996');
expect(output).toContain('line 4997');
expect(output).toContain('line 4998');
expect(output).toContain('line 4999');
expect(output).toContain('line 5000');
});
it('pre-slices single-line output by visual width before MaxSizedBox layout', () => {
const longSingleLine = Array.from({ length: 1000 }, (_, i) =>
String(i % 10),
).join('');
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="some-other-tool"
contentWidth={20}
resultDisplay={longSingleLine}
status={ToolCallStatus.Success}
availableTerminalHeight={12}
/>,
StreamingState.Idle,
);
const output = lastFrame()!;
expect(output).toMatch(/\.\.\. first \d+ lin/);
expect(output).not.toContain(longSingleLine);
expect(output).toContain(longSingleLine.slice(-10));
});
it('does not pre-slice string output that exactly fits available height', () => {
const exactFitString = Array.from(
{ length: 6 },
(_, i) => `line ${i + 1}`,
).join('\n');
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="some-other-tool"
resultDisplay={exactFitString}
status={ToolCallStatus.Success}
availableTerminalHeight={12}
/>,
StreamingState.Idle,
);
const output = lastFrame()!;
expect(output).not.toContain('lines hidden');
expect(output).toContain('line 1');
expect(output).toContain('line 6');
});
it.each([
['negative', -1],
['fractional', 1.5],

View file

@ -12,7 +12,7 @@ import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, ShellStatsBar } from '../AnsiOutput.js';
import type { ShellStatsBarProps } from '../AnsiOutput.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import { TodoDisplay } from '../TodoDisplay.js';
import type {
TodoResultDisplay,
@ -31,6 +31,7 @@ import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../../config/settings.js';
import { useCompactMode } from '../../contexts/CompactModeContext.js';
import { getCachedStringWidth, toCodePoints } from '../../utils/textUtils.js';
import {
ToolStatusIndicator,
@ -48,6 +49,65 @@ const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5;
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000;
export type TextEmphasis = 'high' | 'medium' | 'low';
function sliceTextForMaxHeight(
text: string,
maxHeight: number | undefined,
maxWidth: number,
): { text: string; hiddenLinesCount: number } {
if (maxHeight === undefined) {
return { text, hiddenLinesCount: 0 };
}
const targetMaxHeight = Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT);
const visibleContentHeight = targetMaxHeight - 1;
const visualWidth = Math.max(1, Math.floor(maxWidth));
const visibleLines: string[] = [];
let visualLineCount = 0;
let currentLine = '';
let currentLineWidth = 0;
const appendVisibleLine = (line: string) => {
visualLineCount += 1;
visibleLines.push(line);
if (visibleLines.length > visibleContentHeight) {
visibleLines.shift();
}
};
const flushCurrentLine = () => {
appendVisibleLine(currentLine);
currentLine = '';
currentLineWidth = 0;
};
for (const char of toCodePoints(text)) {
if (char === '\n') {
flushCurrentLine();
continue;
}
const charWidth = Math.max(getCachedStringWidth(char), 1);
if (currentLineWidth > 0 && currentLineWidth + charWidth > visualWidth) {
flushCurrentLine();
}
currentLine += char;
currentLineWidth += charWidth;
}
flushCurrentLine();
if (visualLineCount <= targetMaxHeight) {
return { text, hiddenLinesCount: 0 };
}
const hiddenLinesCount = visualLineCount - visibleContentHeight;
return {
text: visibleLines.join('\n'),
hiddenLinesCount,
};
}
type DisplayRendererResult =
| { type: 'none' }
| { type: 'todo'; data: TodoResultDisplay }
@ -234,11 +294,21 @@ const StringResultRenderer: React.FC<{
);
}
const sliced = sliceTextForMaxHeight(
displayData,
availableHeight,
childWidth,
);
return (
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={childWidth}
additionalHiddenLinesCount={sliced.hiddenLinesCount}
>
<Box>
<Text wrap="wrap" color={theme.text.primary}>
{displayData}
{sliced.text}
</Text>
</Box>
</MaxSizedBox>

View file

@ -17,7 +17,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { type SettingScope } from '../../config/settings.js';
import { type AlibabaStandardRegion } from '../../constants/alibabaStandardApiKey.js';
import type { AuthState } from '../types.js';
import type { AuthState, HistoryItem } from '../types.js';
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
@ -110,6 +110,10 @@ export interface UIActions {
closeFeedbackDialog: () => void;
temporaryCloseFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
// Rewind selector
openRewindSelector: () => void;
closeRewindSelector: () => void;
handleRewindConfirm: (userItem: HistoryItem) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);

View file

@ -17,6 +17,7 @@ import type {
SettingInputRequest,
PluginChoiceRequest,
} from '../types.js';
import type { TodoItem } from '../components/TodoDisplay.js';
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
@ -110,6 +111,7 @@ export interface UIState {
staticExtraHeight: number;
dialogsVisible: boolean;
pendingHistoryItems: HistoryItemWithoutId[];
stickyTodos: TodoItem[] | null;
btwItem: HistoryItemBtw | null;
setBtwItem: (item: HistoryItemBtw | null) => void;
cancelBtw: () => void;
@ -158,6 +160,9 @@ export interface UIState {
promptSuggestion: string | null;
/** Dismiss prompt suggestion (clears state, aborts speculation) */
dismissPromptSuggestion: () => void;
// Rewind selector
isRewindSelectorOpen: boolean;
rewindEscPending: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);

View file

@ -101,6 +101,7 @@ interface SlashCommandProcessorActions {
openExtensionsManagerDialog: () => void;
openMcpDialog: () => void;
openHooksDialog: () => void;
openRewindSelector: () => void;
}
/**
@ -628,6 +629,9 @@ export const useSlashCommandProcessor = (
case 'extensions_manage':
actions.openExtensionsManagerDialog();
return { type: 'handled' };
case 'rewind':
actions.openRewindSelector();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
default: {

View file

@ -0,0 +1,111 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { MCPServerStatus, type McpClient } from '@qwen-code/qwen-code-core';
import { appEvents } from '../../utils/events.js';
import { useConfigInitMessage } from './useConfigInitMessage.js';
function makeClient(status: MCPServerStatus): McpClient {
return { getStatus: () => status } as unknown as McpClient;
}
describe('useConfigInitMessage', () => {
afterEach(() => {
appEvents.removeAllListeners('mcp-client-update');
});
it('returns null once config is initialized', () => {
const { result } = renderHook(() => useConfigInitMessage(true));
expect(result.current).toBeNull();
});
it('defaults to "Initializing..." while config is still initializing', () => {
const { result } = renderHook(() => useConfigInitMessage(false));
expect(result.current).toBe('Initializing...');
});
it('reports connection progress as MCP clients connect', () => {
const { result } = renderHook(() => useConfigInitMessage(false));
const clients = new Map<string, McpClient>([
['a', makeClient(MCPServerStatus.CONNECTED)],
['b', makeClient(MCPServerStatus.DISCONNECTED)],
['c', makeClient(MCPServerStatus.DISCONNECTED)],
]);
act(() => {
appEvents.emit('mcp-client-update', clients);
});
expect(result.current).toBe('Connecting to MCP servers... (1/3)');
clients.set('b', makeClient(MCPServerStatus.CONNECTED));
act(() => {
appEvents.emit('mcp-client-update', clients);
});
expect(result.current).toBe('Connecting to MCP servers... (2/3)');
});
it('falls back to "Initializing..." when the clients map is empty', () => {
const { result } = renderHook(() => useConfigInitMessage(false));
act(() => {
appEvents.emit(
'mcp-client-update',
new Map<string, McpClient>([
['a', makeClient(MCPServerStatus.CONNECTED)],
]),
);
});
expect(result.current).toBe('Connecting to MCP servers... (1/1)');
act(() => {
appEvents.emit('mcp-client-update', new Map<string, McpClient>());
});
expect(result.current).toBe('Initializing...');
});
it('flips to null as soon as config finishes initializing', () => {
const { result, rerender } = renderHook(
({ initialized }: { initialized: boolean }) =>
useConfigInitMessage(initialized),
{ initialProps: { initialized: false } },
);
act(() => {
appEvents.emit(
'mcp-client-update',
new Map<string, McpClient>([
['a', makeClient(MCPServerStatus.CONNECTED)],
]),
);
});
expect(result.current).toBe('Connecting to MCP servers... (1/1)');
rerender({ initialized: true });
expect(result.current).toBeNull();
});
it('unsubscribes from mcp-client-update on unmount', () => {
const { unmount } = renderHook(() => useConfigInitMessage(false));
expect(appEvents.listenerCount('mcp-client-update')).toBe(1);
unmount();
expect(appEvents.listenerCount('mcp-client-update')).toBe(0);
});
it('unsubscribes when config transitions to initialized', () => {
const { rerender } = renderHook(
({ initialized }: { initialized: boolean }) =>
useConfigInitMessage(initialized),
{ initialProps: { initialized: false } },
);
expect(appEvents.listenerCount('mcp-client-update')).toBe(1);
rerender({ initialized: true });
expect(appEvents.listenerCount('mcp-client-update')).toBe(0);
});
});

View file

@ -5,19 +5,23 @@
*/
import { useEffect, useState } from 'react';
import { appEvents } from './../../utils/events.js';
import { Box, Text } from 'ink';
import { useConfig } from '../contexts/ConfigContext.js';
import { appEvents } from '../../utils/events.js';
import { type McpClient, MCPServerStatus } from '@qwen-code/qwen-code-core';
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
export const ConfigInitDisplay = () => {
const config = useConfig();
const [message, setMessage] = useState(t('Initializing...'));
// Tracks MCP connection progress. Returns the current status string while
// config is initializing, or `null` once complete so callers can fall
// through to their default content.
export function useConfigInitMessage(
isConfigInitialized: boolean,
): string | null {
const [message, setMessage] = useState<string>(() => t('Initializing...'));
useEffect(() => {
if (isConfigInitialized) {
return;
}
const onChange = (clients?: Map<string, McpClient>) => {
if (!clients || clients.size === 0) {
setMessage(t('Initializing...'));
@ -41,13 +45,10 @@ export const ConfigInitDisplay = () => {
return () => {
appEvents.off('mcp-client-update', onChange);
};
}, [config]);
}, [isConfigInitialized]);
return (
<Box marginTop={1}>
<Text>
<GeminiSpinner /> <Text color={theme.text.primary}>{message}</Text>
</Text>
</Box>
);
};
// Gating on isConfigInitialized (rather than clearing state from the effect)
// ensures the first render that flips to initialized returns null without
// a transient frame still showing the old message.
return isConfigInitialized ? null : message;
}

View file

@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useRef, useCallback, useEffect } from 'react';
const DOUBLE_PRESS_TIMEOUT_MS = 800;
/**
* Generic double-press detection hook.
*
* Returns a callback that should be invoked on each press. On the first
* press, optionally calls `onPending(true)` and starts a timer. If a
* second press arrives within 800ms, calls `onDoublePress`. Otherwise,
* the pending state is cleared after the timeout.
*
* @param onDoublePress Callback fired when a double-press is detected
* @param onPending Optional callback to update pending state (for UI hints)
* @returns A callback to invoke on each press
*/
export function useDoublePress(
onDoublePress: () => void,
onPending?: (pending: boolean) => void,
): () => void {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clean up timer on unmount
useEffect(
() => () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
},
[],
);
return useCallback(() => {
if (timeoutRef.current !== null) {
// Second press within the timeout window
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
onPending?.(false);
onDoublePress();
} else {
// First press — start the timer
onPending?.(true);
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
onPending?.(false);
}, DOUBLE_PRESS_TIMEOUT_MS);
}
}, [onDoublePress, onPending]);
}

View file

@ -6,7 +6,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Mock, MockInstance } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useGeminiStream, classifyApiError } from './useGeminiStream.js';
import * as atCommandProcessor from './atCommandProcessor.js';
@ -238,6 +238,10 @@ describe('useGeminiStream', () => {
handleAtCommandSpy = vi.spyOn(atCommandProcessor, 'handleAtCommand');
});
afterEach(() => {
vi.useRealTimers();
});
const mockLoadedSettings: LoadedSettings = {
merged: { preferredEditor: 'vscode' },
user: { path: '/user/settings.json', settings: {} },
@ -823,6 +827,165 @@ describe('useGeminiStream', () => {
});
describe('Cancellation', () => {
it('buffers streamed content until the throttle interval elapses', async () => {
vi.useFakeTimers();
let releaseStream!: () => void;
const holdStream = new Promise<void>((resolve) => {
releaseStream = resolve;
});
const mockStream = (async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Hel',
};
yield {
type: ServerGeminiEventType.Content,
value: 'lo',
};
await holdStream;
})();
mockSendMessageStream.mockReturnValue(mockStream);
const { result } = renderTestHook();
act(() => {
void result.current.submitQuery('test query');
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
expect(result.current.pendingHistoryItems).toEqual([]);
await act(async () => {
vi.advanceTimersByTime(60);
});
expect(result.current.pendingHistoryItems).toEqual([
expect.objectContaining({
type: 'gemini',
text: 'Hello',
}),
]);
act(() => {
result.current.cancelOngoingRequest();
});
await act(async () => {
releaseStream();
});
});
it('buffers streamed thoughts until the throttle interval elapses', async () => {
vi.useFakeTimers();
let releaseStream!: () => void;
const holdStream = new Promise<void>((resolve) => {
releaseStream = resolve;
});
const mockStream = (async function* () {
yield {
type: ServerGeminiEventType.Thought,
value: { description: 'Think' },
};
yield {
type: ServerGeminiEventType.Thought,
value: { description: 'ing' },
};
await holdStream;
})();
mockSendMessageStream.mockReturnValue(mockStream);
const { result } = renderTestHook();
act(() => {
void result.current.submitQuery('test query');
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
expect(result.current.pendingHistoryItems).toEqual([]);
await act(async () => {
vi.advanceTimersByTime(60);
});
expect(result.current.pendingHistoryItems).toEqual([
expect.objectContaining({
type: 'gemini_thought',
text: 'Thinking',
}),
]);
expect(result.current.thought).toEqual({ description: 'Thinking' });
act(() => {
result.current.cancelOngoingRequest();
});
await act(async () => {
releaseStream();
});
});
it('flushes buffered content before cancellation', async () => {
vi.useFakeTimers();
let releaseStream!: () => void;
const holdStream = new Promise<void>((resolve) => {
releaseStream = resolve;
});
const mockStream = (async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Initial',
};
await holdStream;
})();
mockSendMessageStream.mockReturnValue(mockStream);
const { result } = renderTestHook();
act(() => {
void result.current.submitQuery('test query');
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
act(() => {
result.current.cancelOngoingRequest();
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'gemini',
text: 'Initial',
},
expect.any(Number),
);
await act(async () => {
releaseStream();
});
});
it('should cancel an in-progress stream when cancelOngoingRequest is called', async () => {
const mockStream = (async function* () {
yield { type: 'content', value: 'Part 1' };
@ -992,6 +1155,10 @@ describe('useGeminiStream', () => {
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});
await act(async () => {
await Promise.resolve();
});
// Cancel the request
act(() => {
result.current.cancelOngoingRequest();
@ -2811,6 +2978,13 @@ describe('useGeminiStream', () => {
result.current.cancelOngoingRequest();
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'gemini',
text: 'First call content',
},
expect.any(Number),
);
expect(mainAbortSignal?.aborted).toBe(true);
} finally {
resolveFirstCall();

View file

@ -178,6 +178,11 @@ enum StreamProcessingStatus {
}
const EDIT_TOOL_NAMES = new Set(['replace', 'write_file']);
const STREAM_UPDATE_THROTTLE_MS = 60;
type BufferedStreamEvent =
| { kind: 'content'; value: string }
| { kind: 'thought'; value: ThoughtSummary };
function showCitations(settings: LoadedSettings): boolean {
const enabled = settings?.merged?.ui?.showCitations;
@ -216,6 +221,7 @@ export const useGeminiStream = (
) => {
const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const flushBufferedStreamEventsRef = useRef<Set<() => void>>(new Set());
const turnCancelledRef = useRef(false);
const isSubmittingQueryRef = useRef(false);
const lastPromptRef = useRef<PartListUnion | null>(null);
@ -483,6 +489,9 @@ export const useGeminiStream = (
if (turnCancelledRef.current) {
return;
}
for (const flushBufferedStreamEvents of flushBufferedStreamEventsRef.current) {
flushBufferedStreamEvents();
}
turnCancelledRef.current = true;
isSubmittingQueryRef.current = false;
abortControllerRef.current?.abort();
@ -1121,133 +1130,226 @@ export const useGeminiStream = (
let geminiMessageBuffer = '';
let thoughtBuffer = '';
const toolCallRequests: ToolCallRequestInfo[] = [];
dualOutput?.startAssistantMessage();
for await (const event of stream) {
dualOutput?.processEvent(event);
switch (event.type) {
case ServerGeminiEventType.Thought:
// If the thought has a subject, it's a discrete status update rather than
// a streamed textual thought, so we update the thought state directly.
if (event.value.subject) {
setThought(event.value);
} else {
thoughtBuffer = handleThoughtEvent(
event.value,
thoughtBuffer,
userMessageTimestamp,
);
const bufferedEvents: BufferedStreamEvent[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const discardBufferedStreamEvents = () => {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
bufferedEvents.length = 0;
};
const flushBufferedStreamEvents = () => {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (bufferedEvents.length === 0) {
return;
}
while (bufferedEvents.length > 0) {
const nextEvent = bufferedEvents.shift()!;
if (nextEvent.kind === 'content') {
let mergedContent = nextEvent.value;
while (bufferedEvents[0]?.kind === 'content') {
const queuedContent = bufferedEvents.shift();
if (queuedContent?.kind !== 'content') {
break;
}
mergedContent += queuedContent.value;
}
break;
case ServerGeminiEventType.Content:
geminiMessageBuffer = handleContentEvent(
event.value,
mergedContent,
geminiMessageBuffer,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.ToolCallRequest:
toolCallRequests.push(event.value);
// Count tool call args JSON toward token estimation (matches
// Claude Code's input_json_delta handling).
try {
const argsJson = JSON.stringify(event.value.args);
streamingResponseLengthRef.current += argsJson.length;
} catch {
// Best-effort — don't block on serialization errors
continue;
}
let mergedThought = nextEvent.value;
while (bufferedEvents[0]?.kind === 'thought') {
const queuedThought = bufferedEvents.shift();
if (queuedThought?.kind !== 'thought') {
break;
}
break;
case ServerGeminiEventType.UserCancelled:
handleUserCancelledEvent(userMessageTimestamp);
break;
case ServerGeminiEventType.Error:
handleErrorEvent(event.value, userMessageTimestamp);
break;
case ServerGeminiEventType.ChatCompressed:
handleChatCompressionEvent(event.value, userMessageTimestamp);
break;
case ServerGeminiEventType.ToolCallConfirmation:
case ServerGeminiEventType.ToolCallResponse:
// do nothing
break;
case ServerGeminiEventType.MaxSessionTurns:
handleMaxSessionTurnsEvent();
break;
case ServerGeminiEventType.SessionTokenLimitExceeded:
handleSessionTokenLimitExceededEvent(event.value);
break;
case ServerGeminiEventType.Finished:
handleFinishedEvent(
event as ServerGeminiFinishedEvent,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.Citation:
handleCitationEvent(event.value, userMessageTimestamp);
break;
case ServerGeminiEventType.LoopDetected:
// handle later because we want to move pending history to history
// before we add loop detected message to history
loopDetectedRef.current = true;
break;
case ServerGeminiEventType.Retry:
// On fresh restart (escalation / rate-limit / invalid stream),
// clear pending content and buffers to discard the failed attempt.
// On continuation (recovery), keep the pending gemini item AND
// buffers so the model's continuation text appends to them —
// otherwise handleContentEvent would see a null pending item,
// create a fresh one, and reset the buffer to just the new chunk,
// losing the partial text we meant to preserve.
if (!event.isContinuation) {
mergedThought = {
subject: queuedThought.value.subject || mergedThought.subject,
description: `${mergedThought.description ?? ''}${
queuedThought.value.description ?? ''
}`,
};
}
thoughtBuffer = handleThoughtEvent(
mergedThought,
thoughtBuffer,
userMessageTimestamp,
);
}
};
const scheduleBufferedStreamFlush = () => {
if (flushTimer) {
return;
}
flushTimer = setTimeout(() => {
flushBufferedStreamEvents();
}, STREAM_UPDATE_THROTTLE_MS);
};
flushBufferedStreamEventsRef.current.add(flushBufferedStreamEvents);
dualOutput?.startAssistantMessage();
try {
for await (const event of stream) {
dualOutput?.processEvent(event);
switch (event.type) {
case ServerGeminiEventType.Thought:
// If the thought has a subject, it's a discrete status update rather than
// a streamed textual thought, so we update the thought state directly.
if (event.value.subject) {
flushBufferedStreamEvents();
setThought(event.value);
} else {
bufferedEvents.push({ kind: 'thought', value: event.value });
scheduleBufferedStreamFlush();
}
break;
case ServerGeminiEventType.Content:
bufferedEvents.push({ kind: 'content', value: event.value });
scheduleBufferedStreamFlush();
break;
case ServerGeminiEventType.ToolCallRequest:
flushBufferedStreamEvents();
toolCallRequests.push(event.value);
// Count tool call args JSON toward token estimation (matches
// Claude Code's input_json_delta handling).
try {
const argsJson = JSON.stringify(event.value.args);
streamingResponseLengthRef.current += argsJson.length;
} catch {
// Best-effort — don't block on serialization errors
}
break;
case ServerGeminiEventType.UserCancelled:
flushBufferedStreamEvents();
handleUserCancelledEvent(userMessageTimestamp);
break;
case ServerGeminiEventType.Error:
flushBufferedStreamEvents();
handleErrorEvent(event.value, userMessageTimestamp);
break;
case ServerGeminiEventType.ChatCompressed:
flushBufferedStreamEvents();
handleChatCompressionEvent(event.value, userMessageTimestamp);
break;
case ServerGeminiEventType.ToolCallConfirmation:
case ServerGeminiEventType.ToolCallResponse:
flushBufferedStreamEvents();
break;
case ServerGeminiEventType.MaxSessionTurns:
flushBufferedStreamEvents();
handleMaxSessionTurnsEvent();
break;
case ServerGeminiEventType.SessionTokenLimitExceeded:
flushBufferedStreamEvents();
handleSessionTokenLimitExceededEvent(event.value);
break;
case ServerGeminiEventType.Finished:
flushBufferedStreamEvents();
handleFinishedEvent(
event as ServerGeminiFinishedEvent,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.Citation:
flushBufferedStreamEvents();
handleCitationEvent(event.value, userMessageTimestamp);
break;
case ServerGeminiEventType.LoopDetected:
flushBufferedStreamEvents();
// handle later because we want to move pending history to history
// before we add loop detected message to history
loopDetectedRef.current = true;
break;
case ServerGeminiEventType.Retry:
// On fresh restart (escalation / rate-limit / invalid stream),
// clear pending content and buffers to discard the failed attempt.
// On continuation (recovery), keep the pending gemini item AND
// buffers so the model's continuation text appends to them —
// otherwise handleContentEvent would see a null pending item,
// create a fresh one, and reset the buffer to just the new chunk,
// losing the partial text we meant to preserve.
if (!event.isContinuation) {
discardBufferedStreamEvents();
if (pendingHistoryItemRef.current) {
setPendingHistoryItem(null);
}
geminiMessageBuffer = '';
thoughtBuffer = '';
} else {
flushBufferedStreamEvents();
}
// Always discard tool call requests from the truncated/failed
// attempt to prevent duplicate execution after escalation or
// recovery. The recovery path now skips turns that already
// contain a functionCall (see geminiChat.ts), so this only
// clears stale requests from pre-RETRY accumulation.
toolCallRequests.length = 0;
// Show retry info if available (rate-limit / throttling errors)
if (event.retryInfo) {
startRetryCountdown(event.retryInfo);
} else {
// The retry attempt is starting now, so any prior retry UI is stale.
clearRetryCountdown();
}
break;
case ServerGeminiEventType.HookSystemMessage:
flushBufferedStreamEvents();
// Display system message from Stop hooks with "Stop says:" prefix
// First commit any pending AI response to ensure correct ordering
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
geminiMessageBuffer = '';
thoughtBuffer = '';
addItem(
{
type: 'stop_hook_system_message',
message: event.value,
} as HistoryItemWithoutId,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.UserPromptSubmitBlocked:
flushBufferedStreamEvents();
handleUserPromptSubmitBlockedEvent(
event.value,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.StopHookLoop:
flushBufferedStreamEvents();
handleStopHookLoopEvent(event.value, userMessageTimestamp);
break;
default: {
// enforces exhaustive switch-case
const unreachable: never = event;
return unreachable;
}
// Always discard tool call requests from the truncated/failed
// attempt to prevent duplicate execution after escalation or
// recovery. The recovery path now skips turns that already
// contain a functionCall (see geminiChat.ts), so this only
// clears stale requests from pre-RETRY accumulation.
toolCallRequests.length = 0;
// Show retry info if available (rate-limit / throttling errors)
if (event.retryInfo) {
startRetryCountdown(event.retryInfo);
} else {
// The retry attempt is starting now, so any prior retry UI is stale.
clearRetryCountdown();
}
break;
case ServerGeminiEventType.HookSystemMessage:
// Display system message from Stop hooks with "Stop says:" prefix
// First commit any pending AI response to ensure correct ordering
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
{
type: 'stop_hook_system_message',
message: event.value,
} as HistoryItemWithoutId,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.UserPromptSubmitBlocked:
handleUserPromptSubmitBlockedEvent(
event.value,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.StopHookLoop:
handleStopHookLoopEvent(event.value, userMessageTimestamp);
break;
default: {
// enforces exhaustive switch-case
const unreachable: never = event;
return unreachable;
}
}
} finally {
flushBufferedStreamEvents();
discardBufferedStreamEvents();
flushBufferedStreamEventsRef.current.delete(flushBufferedStreamEvents);
}
dualOutput?.finalizeAssistantMessage();
if (toolCallRequests.length > 0) {

Some files were not shown because too many files have changed in this diff Show more