mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
merge: resolve conflicts with origin/main
Merge origin/main into daemon_mode_b_main, resolving 20 conflicted files while preserving both sides' additions: - Session.ts: keep tracing wrappers (runInToolSpanContext, execution spans, status tracking) and add main's acquireSleepInhibitor + dispose - acpAgent.ts: keep shutdownMcpPool/closeStoredSession + add disposeSessions - loggingContentGenerator.ts: keep telemetry fields + add retrySnapshot - mcp-client.ts: keep lastTransportError/getTransportPid + add instructions - core/config.ts: keep mutable disabledTools + add disabledSkillNamesProvider - client.ts: take main's shouldCompact guard + try-catch for microcompaction - rememberCommand.ts: take main's user/project scope memory routing - SubAgentTracker.ts, MessageEmitter.ts: combine both sides' additions - sdk.ts, debugLogger.ts: combine both sides' telemetry/tracing additions - Remove unused imports introduced by partial merge
This commit is contained in:
commit
44b936b73c
409 changed files with 44512 additions and 4482 deletions
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
|
|
@ -35,7 +35,7 @@ defaults:
|
|||
shell: 'bash'
|
||||
|
||||
env:
|
||||
ACTIONLINT_VERSION: '1.7.7'
|
||||
ACTIONLINT_VERSION: '1.7.12'
|
||||
SHELLCHECK_VERSION: '0.11.0'
|
||||
YAMLLINT_VERSION: '1.35.1'
|
||||
|
||||
|
|
@ -91,13 +91,13 @@ jobs:
|
|||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
with:
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node.js 22.x'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: 'npm'
|
||||
|
|
@ -173,10 +173,10 @@ jobs:
|
|||
upload-coverage: 'false'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
|
||||
- name: 'Set up Node.js ${{ matrix.node-version }}'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version: '${{ matrix.node-version }}'
|
||||
cache: 'npm'
|
||||
|
|
@ -212,7 +212,7 @@ jobs:
|
|||
- name: 'Upload Test Results Artifact (for forks)'
|
||||
if: |-
|
||||
${{ always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
name: 'test-results-fork-${{ matrix.node-version }}-${{ matrix.os }}'
|
||||
path: 'packages/*/junit.xml'
|
||||
|
|
@ -220,7 +220,7 @@ jobs:
|
|||
- name: 'Upload coverage reports'
|
||||
if: |-
|
||||
${{ always() && matrix.upload-coverage == 'true' }}
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
||||
with:
|
||||
name: 'coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}'
|
||||
path: 'packages/*/coverage'
|
||||
|
|
@ -251,10 +251,10 @@ jobs:
|
|||
- '22.x'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
|
||||
- name: 'Download coverage reports artifact'
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
||||
with:
|
||||
name: 'coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}'
|
||||
path: 'coverage_artifact' # Download to a specific directory
|
||||
|
|
@ -281,7 +281,7 @@ jobs:
|
|||
security-events: 'write'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
|
||||
- name: 'Initialize CodeQL'
|
||||
uses: 'github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/init@v3
|
||||
|
|
|
|||
545
.github/workflows/desktop-release.yml
vendored
Normal file
545
.github/workflows/desktop-release.yml
vendored
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
name: 'Desktop Release'
|
||||
|
||||
run-name: 'Desktop release ${{ inputs.version }}'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Desktop app version to release, for example 0.0.2 or v0.0.2'
|
||||
required: true
|
||||
type: 'string'
|
||||
release_name:
|
||||
description: 'Release title. Defaults to the tag.'
|
||||
required: false
|
||||
type: 'string'
|
||||
qwen_code_source:
|
||||
description: 'Qwen Code runtime source to vendor into the desktop app.'
|
||||
required: true
|
||||
default: 'source_branch'
|
||||
type: 'choice'
|
||||
options:
|
||||
- 'npm_latest'
|
||||
- 'source_branch'
|
||||
qwen_code_ref:
|
||||
description: 'Current repository branch, tag, or commit when qwen_code_source is source_branch.'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: 'string'
|
||||
dry_run:
|
||||
description: 'Build installers only. Do not create or update a GitHub Release.'
|
||||
required: true
|
||||
default: true
|
||||
type: 'boolean'
|
||||
draft:
|
||||
description: 'Create a draft release.'
|
||||
required: true
|
||||
default: true
|
||||
type: 'boolean'
|
||||
prerelease:
|
||||
description: 'Mark the release as a prerelease.'
|
||||
required: true
|
||||
default: false
|
||||
type: 'boolean'
|
||||
clobber:
|
||||
description: 'Replace same-named assets when uploading to an existing release.'
|
||||
required: true
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
concurrency:
|
||||
group: 'desktop-release-${{ inputs.version }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
BUN_VERSION: '1.3.9'
|
||||
CRAFT_BRAND: 'qwen-code'
|
||||
|
||||
jobs:
|
||||
release_metadata:
|
||||
name: 'Prepare Release Source'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: 'write'
|
||||
outputs:
|
||||
release_branch: '${{ steps.release-branch.outputs.branch }}'
|
||||
release_ref: '${{ steps.release-branch.outputs.ref }}'
|
||||
tag: '${{ steps.release-version.outputs.tag }}'
|
||||
version: '${{ steps.release-version.outputs.version }}'
|
||||
|
||||
steps:
|
||||
- name: 'Check out source'
|
||||
uses: 'actions/checkout@v4'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node'
|
||||
uses: 'actions/setup-node@v6'
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: 'Set up Bun'
|
||||
uses: 'oven-sh/setup-bun@v2'
|
||||
with:
|
||||
bun-version: '${{ env.BUN_VERSION }}'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun install --frozen-lockfile'
|
||||
|
||||
- name: 'Configure Git user'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: 'Require main for publishing'
|
||||
if: '${{ inputs.dry_run == false }}'
|
||||
env:
|
||||
SOURCE_REF: '${{ github.ref_name }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$SOURCE_REF" != "main" ]; then
|
||||
echo "::error::Desktop releases with dry_run=false must be run from main. Current ref: $SOURCE_REF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Bump desktop version'
|
||||
working-directory: 'packages/desktop'
|
||||
env:
|
||||
INPUT_VERSION: '${{ inputs.version }}'
|
||||
run: 'bun run bump-desktop-version "$INPUT_VERSION"'
|
||||
|
||||
- name: 'Validate release version'
|
||||
working-directory: 'packages/desktop'
|
||||
id: 'release-version'
|
||||
env:
|
||||
INPUT_VERSION: '${{ inputs.version }}'
|
||||
run: 'bun run check-release-version --version "$INPUT_VERSION"'
|
||||
|
||||
- name: 'Create release branch'
|
||||
working-directory: 'packages/desktop'
|
||||
id: 'release-branch'
|
||||
env:
|
||||
IS_DRY_RUN: '${{ inputs.dry_run }}'
|
||||
RELEASE_TAG: '${{ steps.release-version.outputs.tag }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
branch="release/desktop-${RELEASE_TAG}"
|
||||
git switch -C "$branch"
|
||||
git add package.json apps/electron/package.json packages/shared/package.json
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "No desktop version changes to commit."
|
||||
else
|
||||
git commit -m "chore(release): desktop ${RELEASE_TAG}"
|
||||
fi
|
||||
|
||||
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$IS_DRY_RUN" = "false" ]; then
|
||||
remote_sha="$(git ls-remote --heads origin "$branch" | awk '{print $1}')"
|
||||
if [ -n "$remote_sha" ]; then
|
||||
git push --force-with-lease="refs/heads/$branch:$remote_sha" origin "HEAD:refs/heads/$branch"
|
||||
else
|
||||
git push origin "HEAD:refs/heads/$branch"
|
||||
fi
|
||||
echo "ref=$branch" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Dry run enabled. Skipping release branch push."
|
||||
echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
name: 'Build ${{ matrix.name }}'
|
||||
runs-on: '${{ matrix.os }}'
|
||||
timeout-minutes: 90
|
||||
needs: 'release_metadata'
|
||||
env:
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
RELEASE_VERSION: '${{ needs.release_metadata.outputs.version }}'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: 'macOS'
|
||||
os: 'macos-latest'
|
||||
command: 'bun run dist:mac:no-publish'
|
||||
- name: 'Windows'
|
||||
os: 'windows-latest'
|
||||
command: 'bun run dist:win:no-publish'
|
||||
- name: 'Linux'
|
||||
os: 'ubuntu-22.04'
|
||||
command: 'bun run dist:linux:no-publish'
|
||||
|
||||
steps:
|
||||
- name: 'Check out source'
|
||||
uses: 'actions/checkout@v4'
|
||||
with:
|
||||
ref: '${{ needs.release_metadata.outputs.release_ref }}'
|
||||
|
||||
- name: 'Set up Node'
|
||||
uses: 'actions/setup-node@v6'
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: 'Check out Qwen Code source'
|
||||
if: "${{ inputs.qwen_code_source == 'source_branch' }}"
|
||||
shell: 'bash'
|
||||
env:
|
||||
QWEN_CODE_REF_INPUT: '${{ inputs.qwen_code_ref }}'
|
||||
QWEN_CODE_SOURCE_ROOT: '${{ runner.temp }}/qwen-code-source'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$QWEN_CODE_REF_INPUT" ]; then
|
||||
echo "::error::qwen_code_ref is required when qwen_code_source is source_branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$QWEN_CODE_SOURCE_ROOT"
|
||||
git init "$QWEN_CODE_SOURCE_ROOT"
|
||||
git -C "$QWEN_CODE_SOURCE_ROOT" remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
|
||||
if ! git -C "$QWEN_CODE_SOURCE_ROOT" fetch --depth=1 origin "$QWEN_CODE_REF_INPUT"; then
|
||||
if ! git -C "$QWEN_CODE_SOURCE_ROOT" fetch --depth=1 origin "refs/heads/$QWEN_CODE_REF_INPUT"; then
|
||||
git -C "$QWEN_CODE_SOURCE_ROOT" fetch --depth=1 origin "refs/tags/$QWEN_CODE_REF_INPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
git -C "$QWEN_CODE_SOURCE_ROOT" checkout --detach FETCH_HEAD
|
||||
git config --global --add safe.directory "$QWEN_CODE_SOURCE_ROOT"
|
||||
|
||||
- name: 'Set up Bun'
|
||||
uses: 'oven-sh/setup-bun@v2'
|
||||
with:
|
||||
bun-version: '${{ env.BUN_VERSION }}'
|
||||
|
||||
- name: 'Install Linux packaging dependencies'
|
||||
if: "runner.os == 'Linux'"
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse2
|
||||
|
||||
- name: 'Install dependencies'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun install --frozen-lockfile'
|
||||
|
||||
- name: 'Install Qwen Code source dependencies'
|
||||
if: "${{ inputs.qwen_code_source == 'source_branch' }}"
|
||||
working-directory: '${{ runner.temp }}/qwen-code-source'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Bump desktop version'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun run bump-desktop-version "${{ needs.release_metadata.outputs.version }}"'
|
||||
|
||||
- name: 'Confirm release version'
|
||||
working-directory: 'packages/desktop'
|
||||
run: 'bun run check-release-version --version "${{ needs.release_metadata.outputs.version }}"'
|
||||
|
||||
- name: 'Configure Qwen Code runtime source'
|
||||
shell: 'bash'
|
||||
env:
|
||||
QWEN_CODE_REF_INPUT: '${{ inputs.qwen_code_ref }}'
|
||||
QWEN_CODE_SOURCE_INPUT: '${{ inputs.qwen_code_source }}'
|
||||
QWEN_CODE_SOURCE_ROOT: '${{ runner.temp }}/qwen-code-source'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$QWEN_CODE_SOURCE_INPUT" in
|
||||
npm_latest)
|
||||
echo "QWEN_CODE_VERSION=latest" >> "$GITHUB_ENV"
|
||||
echo "Using Qwen Code runtime from npm dist-tag: latest"
|
||||
;;
|
||||
source_branch)
|
||||
if [ -z "$QWEN_CODE_REF_INPUT" ]; then
|
||||
echo "::error::qwen_code_ref is required when qwen_code_source is source_branch."
|
||||
exit 1
|
||||
fi
|
||||
echo "QWEN_CODE_ROOT=$QWEN_CODE_SOURCE_ROOT" >> "$GITHUB_ENV"
|
||||
echo "Using Qwen Code runtime from ${GITHUB_REPOSITORY} ref: $QWEN_CODE_REF_INPUT"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown qwen_code_source: $QWEN_CODE_SOURCE_INPUT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: 'Verify desktop update feed target'
|
||||
working-directory: 'packages/desktop'
|
||||
shell: 'bash'
|
||||
env:
|
||||
EXPECTED_REPOSITORY: '${{ github.repository }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
bun run electron:builder-config
|
||||
|
||||
actual_repository="$(node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const config = yaml.load(
|
||||
fs.readFileSync('apps/electron/electron-builder.generated.yml', 'utf8'),
|
||||
);
|
||||
const publish = config?.publish;
|
||||
if (
|
||||
!publish ||
|
||||
publish.provider !== 'github' ||
|
||||
!publish.owner ||
|
||||
!publish.repo
|
||||
) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${publish.owner}/${publish.repo}`);
|
||||
NODE
|
||||
)"
|
||||
|
||||
if [ "$actual_repository" != "$EXPECTED_REPOSITORY" ]; then
|
||||
echo "::error::Desktop update feed points to $actual_repository, expected $EXPECTED_REPOSITORY."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Desktop update feed: https://github.com/${actual_repository}/releases"
|
||||
|
||||
- name: 'Configure optional signing secrets'
|
||||
shell: 'bash'
|
||||
env:
|
||||
APPLE_APP_SPECIFIC_PASSWORD_SECRET: '${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}'
|
||||
APPLE_ID_SECRET: '${{ secrets.APPLE_ID }}'
|
||||
APPLE_TEAM_ID_SECRET: '${{ secrets.APPLE_TEAM_ID }}'
|
||||
CSC_KEY_PASSWORD_SECRET: '${{ secrets.CSC_KEY_PASSWORD }}'
|
||||
CSC_LINK_SECRET: '${{ secrets.CSC_LINK }}'
|
||||
SENTRY_ELECTRON_INGEST_URL_SECRET: '${{ secrets.SENTRY_ELECTRON_INGEST_URL }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
append_env() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
{
|
||||
echo "$name<<__${name}__"
|
||||
printf '%s\n' "$value"
|
||||
echo "__${name}__"
|
||||
} >> "$GITHUB_ENV"
|
||||
}
|
||||
|
||||
if [ -n "$CSC_LINK_SECRET" ]; then
|
||||
append_env "CSC_LINK" "$CSC_LINK_SECRET"
|
||||
append_env "CSC_KEY_PASSWORD" "$CSC_KEY_PASSWORD_SECRET"
|
||||
append_env "APPLE_ID" "$APPLE_ID_SECRET"
|
||||
append_env "APPLE_APP_SPECIFIC_PASSWORD" "$APPLE_APP_SPECIFIC_PASSWORD_SECRET"
|
||||
append_env "APPLE_TEAM_ID" "$APPLE_TEAM_ID_SECRET"
|
||||
echo "CSC_IDENTITY_AUTO_DISCOVERY=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
append_env "SENTRY_ELECTRON_INGEST_URL" "$SENTRY_ELECTRON_INGEST_URL_SECRET"
|
||||
|
||||
- name: 'Build desktop installer'
|
||||
working-directory: 'packages/desktop'
|
||||
# Build jobs only produce artifacts. The publish job below owns GitHub
|
||||
# Release creation/upload so dry-run, draft, prerelease, and replace
|
||||
# behavior stays centralized.
|
||||
run: '${{ matrix.command }}'
|
||||
|
||||
- name: 'Upload installer artifacts'
|
||||
uses: 'actions/upload-artifact@v4'
|
||||
with:
|
||||
name: 'desktop-${{ matrix.name }}'
|
||||
if-no-files-found: 'error'
|
||||
retention-days: 14
|
||||
path: |
|
||||
packages/desktop/apps/electron/release/*.AppImage
|
||||
packages/desktop/apps/electron/release/*.blockmap
|
||||
packages/desktop/apps/electron/release/*.dmg
|
||||
packages/desktop/apps/electron/release/*.exe
|
||||
packages/desktop/apps/electron/release/*.yml
|
||||
packages/desktop/apps/electron/release/*.zip
|
||||
|
||||
publish:
|
||||
name: 'Publish GitHub Release'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 20
|
||||
needs:
|
||||
- 'build'
|
||||
- 'release_metadata'
|
||||
if: '${{ inputs.dry_run == false }}'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
env:
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
RELEASE_VERSION: '${{ needs.release_metadata.outputs.version }}'
|
||||
|
||||
steps:
|
||||
- name: 'Download installer artifacts'
|
||||
uses: 'actions/download-artifact@v4'
|
||||
with:
|
||||
path: 'release-assets'
|
||||
merge-multiple: true
|
||||
|
||||
- name: 'Publish release assets'
|
||||
env:
|
||||
GH_REPO: '${{ github.repository }}'
|
||||
GH_TOKEN: '${{ github.token }}'
|
||||
RELEASE_DRAFT: '${{ inputs.draft }}'
|
||||
RELEASE_NAME: '${{ inputs.release_name }}'
|
||||
RELEASE_PRERELEASE: '${{ inputs.prerelease }}'
|
||||
RELEASE_TARGET: '${{ needs.release_metadata.outputs.release_ref }}'
|
||||
UPLOAD_CLOBBER: '${{ inputs.clobber }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
assets=()
|
||||
while IFS= read -r -d '' file; do
|
||||
assets+=("$file")
|
||||
done < <(find release-assets -type f -print0 | sort -z)
|
||||
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets were downloaded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Release assets:\n'
|
||||
printf ' %s\n' "${assets[@]}"
|
||||
|
||||
title="${RELEASE_NAME:-$RELEASE_TAG}"
|
||||
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
upload_args=("$RELEASE_TAG" "${assets[@]}")
|
||||
if [ "$UPLOAD_CLOBBER" = "true" ]; then
|
||||
upload_args+=(--clobber)
|
||||
fi
|
||||
gh release upload "${upload_args[@]}"
|
||||
else
|
||||
create_args=(
|
||||
"$RELEASE_TAG"
|
||||
"${assets[@]}"
|
||||
--generate-notes
|
||||
--target "$RELEASE_TARGET"
|
||||
--title "$title"
|
||||
)
|
||||
if [ "$RELEASE_DRAFT" = "true" ]; then
|
||||
create_args+=(--draft)
|
||||
fi
|
||||
if [ "$RELEASE_PRERELEASE" = "true" ]; then
|
||||
create_args+=(--prerelease)
|
||||
fi
|
||||
gh release create "${create_args[@]}"
|
||||
fi
|
||||
|
||||
sync-version:
|
||||
name: 'Sync Release Version to Main'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- 'publish'
|
||||
- 'release_metadata'
|
||||
if: '${{ inputs.dry_run == false && inputs.draft == false }}'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
steps:
|
||||
- name: 'Create version sync PR'
|
||||
id: 'version-pr'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.CI_BOT_PAT || github.token }}'
|
||||
RELEASE_BRANCH: '${{ needs.release_metadata.outputs.release_branch }}'
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
pr_url="$(gh pr list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--head "$RELEASE_BRANCH" \
|
||||
--base main \
|
||||
--json url \
|
||||
--jq '.[0].url')"
|
||||
|
||||
if [ -z "$pr_url" ]; then
|
||||
pr_url="$(gh pr create \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--base main \
|
||||
--head "$RELEASE_BRANCH" \
|
||||
--title "chore(release): desktop ${RELEASE_TAG}" \
|
||||
--body "Automated desktop release PR for ${RELEASE_TAG}. Syncs desktop package versions on main.")"
|
||||
fi
|
||||
|
||||
echo "url=$pr_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Enable auto-merge'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.CI_BOT_PAT || github.token }}'
|
||||
PR_URL: '${{ steps.version-pr.outputs.url }}'
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh pr merge "$PR_URL" \
|
||||
--squash \
|
||||
--auto \
|
||||
--delete-branch \
|
||||
--subject "chore(release): desktop ${RELEASE_TAG} [skip ci]"
|
||||
|
||||
dry-run-summary:
|
||||
name: 'Dry Run Summary'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- 'build'
|
||||
- 'release_metadata'
|
||||
if: '${{ inputs.dry_run }}'
|
||||
env:
|
||||
RELEASE_TAG: '${{ needs.release_metadata.outputs.tag }}'
|
||||
RELEASE_VERSION: '${{ needs.release_metadata.outputs.version }}'
|
||||
|
||||
steps:
|
||||
- name: 'Download installer artifacts'
|
||||
uses: 'actions/download-artifact@v4'
|
||||
with:
|
||||
path: 'release-assets'
|
||||
merge-multiple: true
|
||||
|
||||
- name: 'List release assets'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
assets=()
|
||||
while IFS= read -r -d '' file; do
|
||||
assets+=("$file")
|
||||
done < <(find release-assets -type f -print0 | sort -z)
|
||||
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets were downloaded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## Desktop release dry run"
|
||||
echo
|
||||
echo "Version: $RELEASE_VERSION"
|
||||
echo "Release tag: $RELEASE_TAG"
|
||||
echo
|
||||
echo "Built ${#assets[@]} asset(s). No GitHub Release was created or updated."
|
||||
echo
|
||||
echo "| Asset | Size |"
|
||||
echo "| --- | ---: |"
|
||||
for file in "${assets[@]}"; do
|
||||
size=$(du -h "$file" | cut -f1)
|
||||
echo "| $(basename "$file") | $size |"
|
||||
done
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
523
.github/workflows/qwen-code-pr-review.yml
vendored
523
.github/workflows/qwen-code-pr-review.yml
vendored
|
|
@ -2,7 +2,14 @@ name: '🧐 Qwen Pull Request Review'
|
|||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: ['opened']
|
||||
types:
|
||||
- 'opened'
|
||||
- 'synchronize'
|
||||
- 'reopened'
|
||||
- 'ready_for_review'
|
||||
- 'review_requested'
|
||||
issue_comment:
|
||||
types: ['created']
|
||||
pull_request_review_comment:
|
||||
types: ['created']
|
||||
pull_request_review:
|
||||
|
|
@ -13,178 +20,410 @@ on:
|
|||
description: 'PR number to review'
|
||||
required: true
|
||||
type: 'number'
|
||||
review_mode:
|
||||
description: 'dry-run (no comments) or comment (post inline comments)'
|
||||
required: true
|
||||
default: 'comment'
|
||||
type: 'choice'
|
||||
options:
|
||||
- 'dry-run'
|
||||
- 'comment'
|
||||
timeout_minutes:
|
||||
description: 'Review timeout in minutes'
|
||||
required: false
|
||||
default: '60'
|
||||
type: 'number'
|
||||
|
||||
concurrency:
|
||||
# PR lifecycle events share a PR-scoped group so new pushes restart the delay.
|
||||
# Comment/review events use per-run groups to avoid cancelling active reviews.
|
||||
group: >-
|
||||
${{ github.event_name == 'pull_request_target' &&
|
||||
format('qwen-pr-review-pr-{0}', github.event.pull_request.number) ||
|
||||
format('qwen-pr-review-run-{0}', github.run_id) }}
|
||||
cancel-in-progress: "${{ github.event_name == 'pull_request_target' && github.event.action == 'synchronize' }}"
|
||||
|
||||
jobs:
|
||||
review-pr:
|
||||
ack-review-request:
|
||||
# KEEP IN SYNC with review-pr.if (explicit-trigger branches).
|
||||
if: |-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'opened' &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')) ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@qwen /review') &&
|
||||
github.event.issue.state == 'open' &&
|
||||
(github.event.comment.body == '@qwen-code /review' ||
|
||||
startsWith(github.event.comment.body, '@qwen-code /review ') ||
|
||||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')) ||
|
||||
(github.event_name == 'pull_request_review_comment' &&
|
||||
contains(github.event.comment.body, '@qwen /review') &&
|
||||
github.event.pull_request.state == 'open' &&
|
||||
(github.event.comment.body == '@qwen-code /review' ||
|
||||
startsWith(github.event.comment.body, '@qwen-code /review ') ||
|
||||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')) ||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@qwen /review') &&
|
||||
github.event.pull_request.state == 'open' &&
|
||||
(github.event.review.body == '@qwen-code /review' ||
|
||||
startsWith(github.event.review.body, '@qwen-code /review ') ||
|
||||
startsWith(github.event.review.body, format('@qwen-code /review{0}', '\n'))) &&
|
||||
(github.event.review.author_association == 'OWNER' ||
|
||||
github.event.review.author_association == 'MEMBER' ||
|
||||
github.event.review.author_association == 'COLLABORATOR'))
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: 'qwen-pr-ack-${{ github.event.issue.number || github.event.pull_request.number }}'
|
||||
cancel-in-progress: false
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: 'read'
|
||||
id-token: 'write'
|
||||
pull-requests: 'write'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Checkout PR code'
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
- name: 'Post queued acknowledgement'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number }}'
|
||||
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
PR_STATE="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state --jq '.state')"
|
||||
if [ "$PR_STATE" != "OPEN" ]; then
|
||||
echo "PR #${PR_NUMBER} is ${PR_STATE}; skipping acknowledgement." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
ACK_BODY="<!-- qwen-review-ack -->_Qwen Code review request accepted. Review is queued in [workflow run](${RUN_URL})._"
|
||||
EXISTING_ACK_ID="$(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--paginate \
|
||||
-F per_page=100 \
|
||||
| jq -sr '[.[][] | select(.body | contains("<!-- qwen-review-ack -->")) | select(.user.login == "github-actions[bot]")] | last | .id // empty'
|
||||
)" || EXISTING_ACK_ID=""
|
||||
if [ -n "$EXISTING_ACK_ID" ]; then
|
||||
gh api \
|
||||
--method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_ACK_ID}" \
|
||||
-f body="$ACK_BODY" > /dev/null
|
||||
echo "Queued acknowledgement updated on PR #${PR_NUMBER}." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
gh pr comment "$PR_NUMBER" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--body "$ACK_BODY"
|
||||
echo "Queued acknowledgement posted on PR #${PR_NUMBER}." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
review-config:
|
||||
if: |-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'review_requested'
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions: {}
|
||||
outputs:
|
||||
bot_login: '${{ steps.values.outputs.bot_login }}'
|
||||
steps:
|
||||
- name: 'Set review constants'
|
||||
id: 'values'
|
||||
run: |-
|
||||
echo "bot_login=qwen-code-ci-bot" >> "$GITHUB_OUTPUT"
|
||||
|
||||
delay-automatic-review:
|
||||
if: |-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
(github.event.action == 'opened' ||
|
||||
github.event.action == 'synchronize') &&
|
||||
github.event.pull_request.state == 'open' &&
|
||||
!github.event.pull_request.draft &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')
|
||||
runs-on: 'ubuntu-latest'
|
||||
# Configured in repo settings with a 10-minute wait timer.
|
||||
environment:
|
||||
name: 'qwen-pr-review-delay'
|
||||
deployment: false
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'read'
|
||||
outputs:
|
||||
should_review: '${{ steps.pr_state.outputs.should_review }}'
|
||||
steps:
|
||||
- name: 'Re-check PR state'
|
||||
id: 'pr_state'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
pr_data="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft --jq '[.state, .isDraft] | @tsv')"
|
||||
IFS=$'\t' read -r state is_draft <<< "$pr_data"
|
||||
|
||||
if [ "$state" != "OPEN" ]; then
|
||||
echo "Skipping delayed review: PR #${PR_NUMBER} is ${state}." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "should_review=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$is_draft" = "true" ]; then
|
||||
echo "Skipping delayed review: PR #${PR_NUMBER} is draft." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "should_review=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "should_review=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
authorize-review-request:
|
||||
needs: ['review-config']
|
||||
if: |-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'review_requested' &&
|
||||
github.event.requested_reviewer.login == needs.review-config.outputs.bot_login &&
|
||||
github.event.pull_request.state == 'open' &&
|
||||
!github.event.pull_request.draft
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
outputs:
|
||||
should_review: '${{ steps.sender_permission.outputs.should_review }}'
|
||||
steps:
|
||||
- name: 'Check requester permission'
|
||||
id: 'sender_permission'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
REQUESTER: '${{ github.event.sender.login }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
if ! permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${REQUESTER}/permission" --jq '.permission')"; then
|
||||
echo "Failed to check permission for ${REQUESTER}." >&2
|
||||
echo "Failed to check permission for ${REQUESTER}." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "should_review=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
case "$permission" in
|
||||
admin|maintain|write)
|
||||
echo "should_review=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "Skipping requested review: ${REQUESTER} lacks write permission or permission check failed." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "should_review=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
review-pr:
|
||||
needs:
|
||||
['review-config', 'delay-automatic-review', 'authorize-review-request']
|
||||
# pull_request_target routing:
|
||||
# - review_requested uses authorize-review-request and skips delay
|
||||
# - opened/synchronize uses delay-automatic-review
|
||||
# - reopened/ready_for_review runs immediately for trusted PR authors
|
||||
# KEEP IN SYNC with ack-review-request.if (explicit-trigger branches).
|
||||
if: |-
|
||||
always() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.state == 'open' &&
|
||||
!github.event.pull_request.draft &&
|
||||
((github.event.action == 'review_requested' &&
|
||||
github.event.requested_reviewer.login == needs.review-config.outputs.bot_login &&
|
||||
needs.authorize-review-request.outputs.should_review == 'true') ||
|
||||
(github.event.action != 'review_requested' &&
|
||||
((github.event.action != 'opened' &&
|
||||
github.event.action != 'synchronize') ||
|
||||
needs.delay-automatic-review.outputs.should_review == 'true') &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')))) ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.issue.state == 'open' &&
|
||||
(github.event.comment.body == '@qwen-code /review' ||
|
||||
startsWith(github.event.comment.body, '@qwen-code /review ') ||
|
||||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')) ||
|
||||
(github.event_name == 'pull_request_review_comment' &&
|
||||
github.event.pull_request.state == 'open' &&
|
||||
(github.event.comment.body == '@qwen-code /review' ||
|
||||
startsWith(github.event.comment.body, '@qwen-code /review ') ||
|
||||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')) ||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
github.event.pull_request.state == 'open' &&
|
||||
(github.event.review.body == '@qwen-code /review' ||
|
||||
startsWith(github.event.review.body, '@qwen-code /review ') ||
|
||||
startsWith(github.event.review.body, format('@qwen-code /review{0}', '\n'))) &&
|
||||
(github.event.review.author_association == 'OWNER' ||
|
||||
github.event.review.author_association == 'MEMBER' ||
|
||||
github.event.review.author_association == 'COLLABORATOR')))
|
||||
timeout-minutes: 60
|
||||
runs-on: ['self-hosted', 'linux', 'x64', 'ecs-qwen']
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'write'
|
||||
issues: 'write'
|
||||
steps:
|
||||
# SECURITY: checkout trusted base code; /review fetches PR diff context.
|
||||
- name: 'Checkout base branch'
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ref: '${{ github.event.repository.default_branch }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Get PR details (pull_request_target & workflow_dispatch)'
|
||||
id: 'get_pr'
|
||||
if: |-
|
||||
${{ github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' }}
|
||||
- name: 'Resolve PR context'
|
||||
id: 'context'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
TRIGGER_BODY: "${{ github.event.comment.body || github.event.review.body || '' }}"
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
TRIGGER_COMMAND="${TRIGGER_BODY%%$'\n'*}"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_NUMBER=${{ github.event.inputs.pr_number }}
|
||||
PR_NUMBER="${{ github.event.inputs.pr_number }}"
|
||||
REVIEW_MODE="${{ github.event.inputs.review_mode }}"
|
||||
elif [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
if ! printf '%s\n' "$TRIGGER_COMMAND" | grep -Eq '^@qwen-code[[:space:]]+/review([[:space:]]|$)'; then
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
PR_NUMBER="${{ github.event.issue.number }}"
|
||||
REVIEW_MODE="comment"
|
||||
elif [ "${{ github.event_name }}" = "pull_request_target" ] ||
|
||||
[ "${{ github.event_name }}" = "pull_request_review_comment" ] ||
|
||||
[ "${{ github.event_name }}" = "pull_request_review" ]; then
|
||||
if [ "${{ github.event_name }}" != "pull_request_target" ] &&
|
||||
! printf '%s\n' "$TRIGGER_COMMAND" | grep -Eq '^@qwen-code[[:space:]]+/review([[:space:]]|$)'; then
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
REVIEW_MODE="comment"
|
||||
else
|
||||
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||
echo "Unsupported event: ${{ github.event_name }}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
# Get PR details
|
||||
PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName)
|
||||
echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT"
|
||||
# Get file changes
|
||||
CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only)
|
||||
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Get PR details (issue_comment)'
|
||||
id: 'get_pr_comment'
|
||||
if: |-
|
||||
${{ github.event_name == 'issue_comment' }}
|
||||
TIMEOUT_MINUTES="${{ github.event.inputs.timeout_minutes || '60' }}"
|
||||
|
||||
{
|
||||
echo "should_run=true"
|
||||
echo "pr_number=$PR_NUMBER"
|
||||
echo "review_mode=$REVIEW_MODE"
|
||||
echo "timeout_minutes=$TIMEOUT_MINUTES"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Run review'
|
||||
id: 'review'
|
||||
if: "steps.context.outputs.should_run == 'true'"
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
COMMENT_BODY: '${{ github.event.comment.body }}'
|
||||
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
OPENAI_API_KEY: '${{ secrets.REVIEW_OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.REVIEW_OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}'
|
||||
PR_NUMBER: '${{ steps.context.outputs.pr_number }}'
|
||||
REVIEW_MODE: '${{ steps.context.outputs.review_mode }}'
|
||||
TIMEOUT_MINUTES: '${{ steps.context.outputs.timeout_minutes }}'
|
||||
run: |-
|
||||
PR_NUMBER=${{ github.event.issue.number }}
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
# Extract additional instructions from comment
|
||||
ADDITIONAL_INSTRUCTIONS=$(echo "$COMMENT_BODY" | sed 's/.*@qwen \/review//' | xargs)
|
||||
echo "additional_instructions=$ADDITIONAL_INSTRUCTIONS" >> "$GITHUB_OUTPUT"
|
||||
# Get PR details
|
||||
PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName)
|
||||
echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT"
|
||||
# Get file changes
|
||||
CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only)
|
||||
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
set -euo pipefail
|
||||
fail() {
|
||||
local message="$1"
|
||||
local code="${2:-1}"
|
||||
echo "$message" >&2
|
||||
echo "failure_reason=$message" >> "$GITHUB_OUTPUT"
|
||||
echo "$message" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
- name: 'Run Qwen PR Review'
|
||||
uses: 'QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2'
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REVIEW_URL="${GITHUB_SERVER_URL}/${REPO}/pull/${PR_NUMBER}"
|
||||
LOG_PATH="${RUNNER_TEMP:-/tmp}/qwen-review-pr-${PR_NUMBER}.jsonl"
|
||||
trap 'rm -f "$LOG_PATH"' EXIT
|
||||
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
fail "CI_BOT_PAT secret is required for Qwen PR review."
|
||||
fi
|
||||
if [ -z "${OPENAI_API_KEY:-}" ]; then
|
||||
fail "REVIEW_OPENAI_API_KEY secret is required for Qwen PR review."
|
||||
fi
|
||||
if [ -z "${OPENAI_BASE_URL:-}" ]; then
|
||||
fail "REVIEW_OPENAI_BASE_URL secret is required for Qwen PR review."
|
||||
fi
|
||||
if ! command -v qwen >/dev/null 2>&1; then
|
||||
fail "qwen CLI is required on the review runner."
|
||||
fi
|
||||
qwen --version
|
||||
|
||||
case "$TIMEOUT_MINUTES" in
|
||||
''|*[!0-9]*)
|
||||
fail "Invalid timeout_minutes: ${TIMEOUT_MINUTES}"
|
||||
;;
|
||||
esac
|
||||
if [ "$TIMEOUT_MINUTES" -le 5 ]; then
|
||||
fail "timeout_minutes must be greater than 5"
|
||||
fi
|
||||
if [ "$TIMEOUT_MINUTES" -gt 60 ]; then
|
||||
fail "timeout_minutes must not exceed the 60 minute job timeout"
|
||||
fi
|
||||
|
||||
if ! PR_STATE="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state --jq '.state')"; then
|
||||
fail "Failed to determine state for PR #${PR_NUMBER}."
|
||||
fi
|
||||
if [ "$PR_STATE" != "OPEN" ]; then
|
||||
echo "Skipping: PR #${PR_NUMBER} is ${PR_STATE}." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PROMPT="/review ${REVIEW_URL}"
|
||||
if [ "$REVIEW_MODE" = "comment" ]; then
|
||||
PROMPT="${PROMPT} --comment"
|
||||
fi
|
||||
|
||||
MODEL_ARGS=()
|
||||
if [ -n "${OPENAI_MODEL:-}" ]; then
|
||||
MODEL_ARGS=(--model "$OPENAI_MODEL")
|
||||
fi
|
||||
|
||||
QWEN_TIMEOUT=$((TIMEOUT_MINUTES - 5))
|
||||
set +e
|
||||
# GNU timeout times out command children unless --foreground is used.
|
||||
timeout --kill-after=10s "${QWEN_TIMEOUT}m" qwen \
|
||||
--auth-type openai \
|
||||
--approval-mode yolo \
|
||||
"${MODEL_ARGS[@]}" \
|
||||
--prompt "$PROMPT" \
|
||||
--output-format stream-json \
|
||||
| tee "$LOG_PATH"
|
||||
pipeline_status=("${PIPESTATUS[@]}")
|
||||
set -e
|
||||
qwen_status="${pipeline_status[0]}"
|
||||
tee_status="${pipeline_status[1]}"
|
||||
|
||||
if [ "$tee_status" -ne 0 ]; then
|
||||
fail "Failed to write qwen review log."
|
||||
fi
|
||||
if [ "$qwen_status" -eq 124 ]; then
|
||||
fail "Qwen review timed out after ${QWEN_TIMEOUT} minutes."
|
||||
fi
|
||||
if [ "$qwen_status" -ne 0 ]; then
|
||||
fail "Qwen review exited with status ${qwen_status}."
|
||||
fi
|
||||
|
||||
if [ ! -s "$LOG_PATH" ]; then
|
||||
fail "Qwen review completed but produced no output."
|
||||
fi
|
||||
|
||||
- name: 'Post fallback comment on failure'
|
||||
if: |-
|
||||
failure() &&
|
||||
steps.context.outputs.should_run == 'true' &&
|
||||
steps.context.outputs.review_mode == 'comment' &&
|
||||
steps.context.outputs.pr_number != ''
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_NUMBER: '${{ steps.get_pr.outputs.pr_number || steps.get_pr_comment.outputs.pr_number }}'
|
||||
PR_DATA: '${{ steps.get_pr.outputs.pr_data || steps.get_pr_comment.outputs.pr_data }}'
|
||||
CHANGED_FILES: '${{ steps.get_pr.outputs.changed_files || steps.get_pr_comment.outputs.changed_files }}'
|
||||
ADDITIONAL_INSTRUCTIONS: '${{ steps.get_pr.outputs.additional_instructions || steps.get_pr_comment.outputs.additional_instructions }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
with:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
settings_json: |-
|
||||
{
|
||||
"coreTools": [
|
||||
"run_shell_command",
|
||||
"write_file"
|
||||
],
|
||||
"sandbox": false
|
||||
}
|
||||
prompt: |-
|
||||
You are an expert code reviewer. You have access to shell commands to gather PR information and perform the review.
|
||||
|
||||
IMPORTANT: Use the available shell commands to gather information. Do not ask for information to be provided.
|
||||
|
||||
Start by running these commands to gather the required data:
|
||||
1. Run: echo "$PR_DATA" to get PR details (JSON format)
|
||||
2. Run: echo "$CHANGED_FILES" to get the list of changed files
|
||||
3. Run: echo "$PR_NUMBER" to get the PR number
|
||||
4. Run: echo "$ADDITIONAL_INSTRUCTIONS" to see any specific review instructions from the user
|
||||
5. Run: gh pr diff $PR_NUMBER to see the full diff
|
||||
6. For any specific files, use: cat filename, head -50 filename, or tail -50 filename
|
||||
|
||||
Additional Review Instructions:
|
||||
If ADDITIONAL_INSTRUCTIONS contains text, prioritize those specific areas or focus points in your review.
|
||||
Common instruction examples: "focus on security", "check performance", "review error handling", "check for breaking changes"
|
||||
|
||||
Once you have the information, provide a comprehensive code review by:
|
||||
1. Writing your review to a file: write_file("review.md", "<your detailed review feedback here>")
|
||||
2. Posting the review: gh pr comment $PR_NUMBER --body-file review.md --repo $REPOSITORY
|
||||
|
||||
Review Areas:
|
||||
- **Security**: Authentication, authorization, input validation, data sanitization
|
||||
- **Performance**: Algorithms, database queries, caching, resource usage
|
||||
- **Reliability**: Error handling, logging, testing coverage, edge cases
|
||||
- **Maintainability**: Code structure, documentation, naming conventions
|
||||
- **Functionality**: Logic correctness, requirements fulfillment
|
||||
|
||||
Output Format:
|
||||
Structure your review using this exact format with markdown:
|
||||
|
||||
## 📋 Review Summary
|
||||
Provide a brief 2-3 sentence overview of the PR and overall assessment.
|
||||
|
||||
## 🔍 General Feedback
|
||||
- List general observations about code quality
|
||||
- Mention overall patterns or architectural decisions
|
||||
- Highlight positive aspects of the implementation
|
||||
- Note any recurring themes across files
|
||||
|
||||
## 🎯 Specific Feedback
|
||||
Only include sections below that have actual issues. If there are no issues in a priority category, omit that entire section.
|
||||
|
||||
### 🔴 Critical
|
||||
(Only include this section if there are critical issues)
|
||||
Issues that must be addressed before merging (security vulnerabilities, breaking changes, major bugs):
|
||||
- **File: `filename:line`** - Description of critical issue with specific recommendation
|
||||
|
||||
### 🟡 High
|
||||
(Only include this section if there are high priority issues)
|
||||
Important issues that should be addressed (performance problems, design flaws, significant bugs):
|
||||
- **File: `filename:line`** - Description of high priority issue with suggested fix
|
||||
|
||||
### 🟢 Medium
|
||||
(Only include this section if there are medium priority issues)
|
||||
Improvements that would enhance code quality (style issues, minor optimizations, better practices):
|
||||
- **File: `filename:line`** - Description of medium priority improvement
|
||||
|
||||
### 🔵 Low
|
||||
(Only include this section if there are suggestions)
|
||||
Nice-to-have improvements and suggestions (documentation, naming, minor refactoring):
|
||||
- **File: `filename:line`** - Description of suggestion or enhancement
|
||||
|
||||
**Note**: If no specific issues are found in any category, simply state "No specific issues identified in this review."
|
||||
|
||||
## ✅ Highlights
|
||||
(Only include this section if there are positive aspects to highlight)
|
||||
- Mention specific good practices or implementations
|
||||
- Acknowledge well-written code sections
|
||||
- Note improvements from previous versions
|
||||
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
FAILURE_REASON: "${{ steps.review.outputs.failure_reason || 'Run review failed. See workflow logs for details.' }}"
|
||||
PR_NUMBER: '${{ steps.context.outputs.pr_number }}'
|
||||
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |-
|
||||
gh pr comment "$PR_NUMBER" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--body "_Qwen Code review did not complete successfully: ${FAILURE_REASON} See [workflow logs](${RUN_URL})._"
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ jobs:
|
|||
with:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}'
|
||||
settings_json: |-
|
||||
{
|
||||
"maxSessionTurns": 50,
|
||||
|
|
@ -367,6 +367,9 @@ jobs:
|
|||
- `<!-- qwen-issue-bot:invalid -->`
|
||||
- `<!-- qwen-issue-bot:needs-info -->`
|
||||
- `<!-- qwen-issue-bot:related -->`
|
||||
- `<!-- qwen-issue-bot:welcome-pr -->`
|
||||
- `<!-- qwen-maintain:welcome-pr -->`
|
||||
- `<!-- qwen-maintain:pr-intake -->`
|
||||
- Do not assign issues to people in this phase.
|
||||
- Do not close issues in this workflow version.
|
||||
- Add labels only. Do not remove any labels, including
|
||||
|
|
|
|||
100
.github/workflows/qwen-triage.yml
vendored
Normal file
100
.github/workflows/qwen-triage.yml
vendored
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
name: 'Qwen Triage'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: ['opened']
|
||||
pull_request_target:
|
||||
types: ['opened', 'ready_for_review']
|
||||
issue_comment:
|
||||
types: ['created']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
number:
|
||||
description: 'Issue or PR number to triage'
|
||||
required: true
|
||||
type: 'number'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.number }}'
|
||||
# Repeat the maintainer /triage check here intentionally: GitHub
|
||||
# evaluates concurrency before the job `if`, so this controls
|
||||
# cancellation, not job eligibility. Other job gates below can diverge.
|
||||
cancel-in-progress: >-
|
||||
${{
|
||||
github.event_name == 'issues' ||
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
startsWith(github.event.comment.body, '@qwen-code /triage') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
}}
|
||||
runs-on: 'ubuntu-latest'
|
||||
# startsWith (not contains) prevents false triggers from comments that
|
||||
# mention the phrase in quoted text or mid-sentence descriptions.
|
||||
if: >-
|
||||
github.repository == 'QwenLM/qwen-code' && (
|
||||
github.event_name == 'issues' ||
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
startsWith(github.event.comment.body, '@qwen-code /triage') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
)
|
||||
steps:
|
||||
- name: 'Checkout repo'
|
||||
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
|
||||
- name: 'Resolve target number'
|
||||
id: 'resolve'
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "number=${{ github.event.inputs.number }}" >> "$GITHUB_OUTPUT"
|
||||
elif [ "${{ github.event_name }}" = "pull_request_target" ]; then
|
||||
echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 'Run Qwen Triage'
|
||||
uses: 'QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'
|
||||
GH_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
with:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}'
|
||||
settings_json: |-
|
||||
{
|
||||
"coreTools": [
|
||||
"run_shell_command",
|
||||
"write_file",
|
||||
"read_file",
|
||||
"grep_search",
|
||||
"glob",
|
||||
"agent",
|
||||
"enter_worktree",
|
||||
"exit_worktree"
|
||||
],
|
||||
"sandbox": false
|
||||
}
|
||||
prompt: '/triage ${{ steps.resolve.outputs.number }} --repo ${{ github.repository }}'
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -34,6 +34,13 @@ CLAUDE.md
|
|||
!.qwen/commands/**
|
||||
!.qwen/skills/
|
||||
!.qwen/skills/**
|
||||
# Re-ignore auto-generated skills (created by the managed-skill-extractor
|
||||
# agent with the mandatory `auto-skill-` directory prefix). Git's last-rule-
|
||||
# wins semantics keep hand-authored project skills tracked while excluding
|
||||
# these transient, session-specific directories. The `auto-skill-` prefix is
|
||||
# reserved for auto-generated skills — do not hand-author a project skill with
|
||||
# this prefix, or its directory will be ignored here.
|
||||
.qwen/skills/auto-skill-*/
|
||||
!.qwen/agents/
|
||||
!.qwen/agents/**
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,16 @@ Before finishing:
|
|||
- Check neighboring pages for conflicting guidance
|
||||
- Confirm new pages appear in the right `_meta.ts`
|
||||
- Re-read critical examples, commands, and paths against code or tests
|
||||
- Verify bundled skill doc indices still match the current `docs/` tree.
|
||||
The `qc-helper` bundled skill
|
||||
(`packages/core/src/skills/bundled/qc-helper/SKILL.md`) maintains a
|
||||
hardcoded table mapping topics to doc file paths. If you added, moved,
|
||||
renamed, or removed a page under `docs/users/`, that table must be updated
|
||||
to match. Check the Features and Configuration tables in the SKILL.md
|
||||
against the actual files in `docs/users/features/` and
|
||||
`docs/users/configuration/`. Other bundled or project skills may also
|
||||
reference doc paths — search for `docs/users/` across `.qwen/skills/` and
|
||||
`packages/core/src/skills/bundled/` to catch them.
|
||||
|
||||
## Audit standards
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ repeatable.
|
|||
reflected in user docs.
|
||||
- `docs/**/_meta.ts` Inspect navigation completeness after creating or moving
|
||||
pages.
|
||||
- `packages/core/src/skills/bundled/qc-helper/SKILL.md` Inspect the topic-to-
|
||||
doc-path index tables. This bundled skill ships with the CLI and uses these
|
||||
tables at runtime to locate docs for `/qc-helper` invocations. Stale or
|
||||
missing entries cause the skill to miss the right documentation or point at
|
||||
nonexistent files.
|
||||
- `.qwen/skills/*/SKILL.md` and `.qwen/skills/*/references/*.md` Inspect any
|
||||
hardcoded `docs/users/` or `docs/developers/` paths in project-level
|
||||
skills. These are not shipped but are used during development workflows.
|
||||
|
||||
## Gap detection prompts
|
||||
|
||||
|
|
@ -36,6 +44,8 @@ Ask these questions while comparing the repo to `docs/`:
|
|||
- New tool behavior or approval/sandbox semantics
|
||||
- IDE integration changes that never reached the docs
|
||||
- Features documented in the wrong section, making them hard to find
|
||||
- New, moved, or renamed docs pages not reflected in bundled skill doc
|
||||
indices (especially `qc-helper`'s topic-to-path tables)
|
||||
|
||||
## Output standard
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,13 @@ Verify that the updated docs cover the actual delta:
|
|||
- Confirm links and relative paths still make sense
|
||||
- Confirm any new page is included in the relevant `_meta.ts`
|
||||
- Re-read the changed docs against the code diff, not against memory
|
||||
- If the diff added, moved, renamed, or removed a page under `docs/users/`,
|
||||
verify the `qc-helper` bundled skill's topic-to-path index tables
|
||||
(`packages/core/src/skills/bundled/qc-helper/SKILL.md`) are updated to
|
||||
match. This skill ships with the CLI and uses hardcoded doc-path tables at
|
||||
runtime — stale entries cause `/qc-helper` to miss the right documentation.
|
||||
Also check project-level skills under `.qwen/skills/` for hardcoded
|
||||
`docs/users/` references that may need updating.
|
||||
|
||||
## Practical heuristics
|
||||
|
||||
|
|
@ -86,6 +93,10 @@ Verify that the updated docs cover the actual delta:
|
|||
`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 the change adds, moves, renames, or removes a docs page, also update
|
||||
hardcoded doc-path consumers: `qc-helper`'s SKILL.md index tables,
|
||||
`_meta.ts` navigation files, and any project-level skills under
|
||||
`.qwen/skills/` that reference `docs/users/` paths.
|
||||
|
||||
## Deliverable
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Use this file to choose the correct destination page under `docs/`.
|
|||
- `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.
|
||||
MCP, sandbox, sub-agents, commands, 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
|
||||
|
|
@ -31,6 +31,25 @@ Use this file to choose the correct destination page under `docs/`.
|
|||
- 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.
|
||||
|
||||
## Doc-path consumers outside `docs/`
|
||||
|
||||
Several files outside the `docs/` tree maintain hardcoded references to doc
|
||||
paths. When pages are added, moved, renamed, or removed, these consumers must
|
||||
be updated alongside the docs themselves:
|
||||
|
||||
- `packages/core/src/skills/bundled/qc-helper/SKILL.md` — The `qc-helper`
|
||||
bundled skill ships with the CLI. Its topic-to-path index tables (under
|
||||
"Documentation Index" and "Common Config Categories") are used at runtime
|
||||
to locate the right doc for `/qc-helper` invocations. Stale entries cause
|
||||
the skill to miss documentation or point at nonexistent files.
|
||||
- `.qwen/skills/*/SKILL.md` and `.qwen/skills/*/references/*.md` — Project-
|
||||
level skills may hardcode `docs/users/` or `docs/developers/` paths.
|
||||
Notable examples: `docs-update-from-diff`, `docs-audit-and-refresh`,
|
||||
`qwen-code-claw`.
|
||||
- Source code comments in `packages/cli/src/` and `packages/core/src/`
|
||||
occasionally reference doc paths as contracts between code behavior and
|
||||
documentation. These are low-risk but should stay accurate.
|
||||
|
||||
## Placement heuristics
|
||||
|
||||
- Put the change where a reader would naturally look first.
|
||||
|
|
|
|||
102
.qwen/skills/openwork-desktop-sync/SKILL.md
Normal file
102
.qwen/skills/openwork-desktop-sync/SKILL.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
---
|
||||
name: openwork-desktop-sync
|
||||
description: Sync qwen-code packages/desktop with modelstudioai/openwork using commit-by-commit path migration, not subtree split or tree overwrite. Use when exporting qwen-code desktop changes to OpenWork, importing OpenWork desktop changes into qwen-code, preserving target-owned overlay files such as README.md, resolving sync conflicts, or preparing sync PR branches between the two repositories.
|
||||
---
|
||||
|
||||
# OpenWork Desktop Sync
|
||||
|
||||
Use this skill to sync desktop changes between this qwen-code repo and an
|
||||
OpenWork checkout. The repository script owns the Git mechanics:
|
||||
|
||||
```bash
|
||||
OPENWORK_DIR=/path/to/openwork bun run desktop-openwork-sync --mode export
|
||||
```
|
||||
|
||||
Default overlay is `README.md`. Overlay paths are excluded from migrated
|
||||
commits and stay target-owned.
|
||||
|
||||
```bash
|
||||
OPENWORK_OVERLAY_PATHS='README.md'
|
||||
```
|
||||
|
||||
## Contract
|
||||
|
||||
This is commit-by-commit path migration, not snapshot replacement. The script
|
||||
walks source commits from `source-base..source-head`, rewrites paths between
|
||||
qwen-code `packages/desktop` and the OpenWork repository root, then applies each
|
||||
commit with `git apply -3`.
|
||||
|
||||
Commits that already came from the receiving repository are skipped by their
|
||||
sync trailers. During import, qwen-code-origin export commits are skipped;
|
||||
during export, OpenWork-origin import commits are skipped.
|
||||
|
||||
Merge commits are not migrated as merge commits. The script migrates the regular
|
||||
commits inside the merged branch; when it later sees the merge wrapper, it
|
||||
checks that the regular commits were already handled and that the merge tree
|
||||
matches Git's automatic merge result. If the merge wrapper contains manual
|
||||
resolution changes, the sync stops so the agent can convert that resolution into
|
||||
a normal follow-up commit.
|
||||
|
||||
Target-side changes are preserved unless a migrated source commit touches the
|
||||
same hunk. If that happens, Git leaves a normal conflict for the agent to
|
||||
resolve. Do not use `git subtree split` or full tree replacement for normal
|
||||
sync.
|
||||
|
||||
Successful sync commits include trailers such as `Qwen-Code-Commit` or
|
||||
`OpenWork-Commit`. Later syncs can use the latest trailer as the next source
|
||||
base. The first sync needs an explicit source base when no previous sync trailer
|
||||
exists:
|
||||
|
||||
```bash
|
||||
bun run desktop-openwork-sync --mode export --source-base <qwen-code-ref>
|
||||
bun run desktop-openwork-sync --mode import --source-base <openwork-ref>
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
- `--mode export`: qwen-code `packages/desktop` commits -> OpenWork.
|
||||
- `--mode import`: OpenWork commits -> qwen-code `packages/desktop`.
|
||||
- `--mode auto`: guardrail only; use explicit directions for real sync.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm repo paths and clean worktrees:
|
||||
|
||||
```bash
|
||||
git rev-parse --show-toplevel
|
||||
git -C /path/to/openwork rev-parse --show-toplevel
|
||||
git status --short
|
||||
git -C /path/to/openwork status --short
|
||||
```
|
||||
|
||||
2. Run the requested direction:
|
||||
|
||||
```bash
|
||||
OPENWORK_DIR=/path/to/openwork \
|
||||
OPENWORK_OVERLAY_PATHS='README.md' \
|
||||
bun run desktop-openwork-sync --mode export --source-base <qwen-code-ref>
|
||||
```
|
||||
|
||||
3. If Git reports conflicts, resolve only the conflicted hunks, preserving
|
||||
target-owned repository metadata unless the source change intentionally
|
||||
updates that same behavior.
|
||||
|
||||
4. After sync, verify:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --check HEAD
|
||||
git diff --name-status <target-base>..HEAD
|
||||
```
|
||||
|
||||
5. If the user asked to publish, push the branch and create a PR after the
|
||||
branch is clean.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep only `README.md` as the default overlay unless the user adds paths to
|
||||
`OPENWORK_OVERLAY_PATHS`.
|
||||
- OpenWork-specific files not touched by source commits must remain unchanged.
|
||||
- Prefer PR branches. The script prints the push command for export branches.
|
||||
- Do not manually import PR merge commits. Let the script migrate regular
|
||||
commits and treat merge commits as wrappers.
|
||||
86
.qwen/skills/triage/SKILL.md
Normal file
86
.qwen/skills/triage/SKILL.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
name: triage
|
||||
description: Gatekeep and review GitHub issues and pull requests for Qwen Code maintainers. Use for GitHub Action issue triage, PR admission checks, product-direction review, KISS-focused PR review, and staged bilingual GitHub comments.
|
||||
argument-hint: '<number> [--repo owner/repo]'
|
||||
allowedTools:
|
||||
- run_shell_command
|
||||
- read_file
|
||||
- grep_search
|
||||
- glob
|
||||
- write_file
|
||||
- agent
|
||||
- enter_worktree
|
||||
- exit_worktree
|
||||
---
|
||||
|
||||
# PR / Issue Gatekeeper
|
||||
|
||||
Run staged admission via `gh`. Post comment after each stage.
|
||||
|
||||
## Resolve
|
||||
|
||||
- Number: from arg or `ISSUE_NUMBER`/`PR_NUMBER` env
|
||||
- Repo: `--repo` → `REPOSITORY` → `GITHUB_REPOSITORY`
|
||||
|
||||
## Fetch
|
||||
|
||||
```bash
|
||||
gh issue view "$NUM" --repo "$REPO" --json number,title,body,author,labels,comments,url
|
||||
gh pr view "$NUM" --repo "$REPO" --json number,title,body,author,labels,additions,deletions,changedFiles,baseRefName,headRefName,isCrossRepository,isDraft,reviewDecision,url
|
||||
gh label list --repo "$REPO" --limit 200
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Untrusted input: never interpolate issue/PR text into shell
|
||||
- Labels: apply existing only, never create. Do not touch process labels (`welcome-pr`, `maintainer`, `help wanted`, `good first issue`)
|
||||
- Comments: read body from file. Use `--body-file FILE` for `gh issue/pr comment`,
|
||||
or `gh api -F body=@FILE` when the response ID is needed. Never `--body @FILE`
|
||||
or `gh api -f body=@FILE` — those post the path literally.
|
||||
- Drafts: skip
|
||||
|
||||
## Duplicate Guard
|
||||
|
||||
- Unattended CI events (`GITHUB_EVENT_NAME=issues` or
|
||||
`pull_request_target`) + prior `<!-- qwen-triage stage=N -->` marker in
|
||||
comments: exit
|
||||
- Explicit reruns (`GITHUB_EVENT_NAME=issue_comment` or `workflow_dispatch`):
|
||||
run all stages, update prior comments in place
|
||||
- Local invocation (no `GITHUB_EVENT_NAME`): run all stages, update prior
|
||||
comments in place
|
||||
|
||||
Every posted comment must include an invisible marker: `<!-- qwen-triage stage=N -->` where N is the stage number. The guard matches against this marker, not comment headings.
|
||||
|
||||
## Format
|
||||
|
||||
Bilingual: English first, Chinese in `<details>`. @mention author when blocking.
|
||||
|
||||
- **Issue**: one comment, Stage 2 updates it in place. Key-point bullet format.
|
||||
- **PR**: three comments (Stage 1: Gate, Stage 2: Review + Test, Stage 3: Final Decision). Key-point bullet format.
|
||||
|
||||
## ⛔ Mandatory Pre-flight Checks (DO NOT SKIP)
|
||||
|
||||
These two steps are the most commonly forgotten. Execute them before any other action.
|
||||
|
||||
### 1. Worktree — ALWAYS create before reading any code
|
||||
|
||||
**PR workflow: mandatory.** Issue workflow: skip (no code reading needed).
|
||||
|
||||
```
|
||||
enter_worktree(name: "triage")
|
||||
```
|
||||
|
||||
Save the returned `worktreePath`. Every `read_file`, `grep_search`, `glob`, and shell command that reads local files **MUST** use this path as root. `gh` commands (API calls) do NOT need the worktree.
|
||||
|
||||
Exception: **tmux real-scenario testing** (Stage 2b) runs in the main working tree — it needs the local build environment.
|
||||
|
||||
When triage is complete: `exit_worktree(action: "remove")`
|
||||
|
||||
### 2. Tmux screenshots — ALWAYS inline in Stage 2 comment
|
||||
|
||||
Stage 2 comment **must contain the actual tmux capture-pane output** pasted inline — not a file path, not "see attached", not a summary. The maintainer reads the comment and makes a decision from it. Without inlined terminal output, the review is incomplete and useless.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Issue → read `references/issue-workflow.md`
|
||||
- PR → read `references/pr-workflow.md`
|
||||
126
.qwen/skills/triage/references/issue-workflow.md
Normal file
126
.qwen/skills/triage/references/issue-workflow.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Issue Workflow
|
||||
|
||||
Triage a GitHub issue. Shared rules in `SKILL.md` — read those first.
|
||||
|
||||
**Single comment, updated in place.** Stage 1 posts a concise bilingual
|
||||
comment; Stage 2 appends results to the same comment via `gh api PATCH`.
|
||||
Key points only — no verbose prose.
|
||||
|
||||
```markdown
|
||||
<!-- qwen-triage stage=1 -->
|
||||
|
||||
## Triage
|
||||
|
||||
- **Type**: bug | feature | docs | unclear | inadmissible
|
||||
- **Labels**: `type/bug`, `scope/cli`, `priority/medium`
|
||||
- **Next**: <one-line action>
|
||||
|
||||
<details>
|
||||
<summary>中文说明</summary>
|
||||
|
||||
- **类型**: bug
|
||||
- **标签**: `type/bug`, `scope/cli`, `priority/medium`
|
||||
- **下一步**: <一句话动作>
|
||||
</details>
|
||||
|
||||
--- Qwen Code
|
||||
```
|
||||
|
||||
## Stage 1: Intake Gate
|
||||
|
||||
Default stance: issues are admissible. Close only the narrow inadmissible cases
|
||||
below.
|
||||
|
||||
Classify the issue from title, body, comments, labels, docs, and source context:
|
||||
|
||||
- **Inadmissible**: religious or political flame wars, harassment, abusive
|
||||
language, spam, or content unrelated to Qwen Code.
|
||||
- **Unclear**: missing reproduction, expected behavior, environment, or enough
|
||||
detail to answer.
|
||||
- **Docs / usage**: how-to questions, configuration confusion, documentation
|
||||
gaps, or behavior that is already documented.
|
||||
- **Bug**: user-visible broken behavior.
|
||||
- **Feature**: new capability, behavior change, or product request.
|
||||
|
||||
Apply labels using existing labels only. Prefer one `type/*`, one `category/*`,
|
||||
relevant `scope/*`, one priority label, and status labels as needed. Apply
|
||||
labels with `gh issue edit --add-label`.
|
||||
|
||||
Post a single triage comment (bilingual, concise key points — see format
|
||||
below). This comment is updated in place by Stage 2; never post a second one.
|
||||
|
||||
If inadmissible, close the issue and stop:
|
||||
|
||||
```bash
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$REPO" --reason "not planned"
|
||||
```
|
||||
|
||||
Save the comment ID for Stage 2 to update.
|
||||
|
||||
## Stage 2: Handle By Type
|
||||
|
||||
Work the issue by type below, then **update** the Stage 1 comment in place with
|
||||
the result appended:
|
||||
|
||||
```bash
|
||||
gh api -X PATCH repos/$REPO/issues/comments/$COMMENT_ID -F body=@/tmp/triage-comment.md
|
||||
```
|
||||
|
||||
### For unclear issues:
|
||||
|
||||
1. Add `status/need-information`.
|
||||
2. Ask for specific missing data: `/about` output, exact commands, expected vs
|
||||
actual behavior, logs, screenshots.
|
||||
3. Stop — no further analysis is useful until the reporter responds.
|
||||
|
||||
### For docs / usage issues:
|
||||
|
||||
1. Search docs and source with `rg` (inside worktree — use `worktreePath` as the search root).
|
||||
2. Search similar issues (reduce title to safe keywords first):
|
||||
|
||||
```bash
|
||||
SAFE_KEYWORDS=$(printf '%s' "$TITLE" | tr -cd '[:alnum:] _-' | cut -c1-60)
|
||||
if [ -n "$SAFE_KEYWORDS" ]; then
|
||||
gh issue list --repo "$REPO" --state all --search "$SAFE_KEYWORDS"
|
||||
else
|
||||
echo "No Latin keywords (CJK-only title); falling back to label search"
|
||||
gh issue list --repo "$REPO" --label "type/bug"
|
||||
fi
|
||||
```
|
||||
|
||||
3. Append the answer with links.
|
||||
|
||||
### For bugs with clear reproduction:
|
||||
|
||||
1. Check safety — no untrusted code with write tokens or secrets.
|
||||
2. Use `tmux-real-user-testing` skill if available; otherwise tmux manually (runs in main working tree, not worktree):
|
||||
|
||||
```bash
|
||||
S=triage-test-$(date +%H%M%S); mkdir -p "tmp/$S"
|
||||
tmux new-session -d -s "$S" -x 200 -y 50 -c "$(pwd)"
|
||||
SAFE_SCENARIO=$(printf '%s' "$SCENARIO" | tr -cd '[:alnum:] _-.,' | cut -c1-200)
|
||||
tmux send-keys -t "$S" "qwen -p '$SAFE_SCENARIO' 2>&1 | tee tmp/$S/before.log" Enter
|
||||
for i in $(seq 1 120); do tmux capture-pane -t "$S" -p | tail -1 | grep -qE '\$|#' && break; sleep 1; done
|
||||
tmux capture-pane -t "$S" -p -S -5000 > "tmp/$S/before-session.txt"
|
||||
tmux send-keys -t "$S" "npm run dev -- -p '$SAFE_SCENARIO' 2>&1 | tee tmp/$S/after.log" Enter
|
||||
for i in $(seq 1 120); do tmux capture-pane -t "$S" -p | tail -1 | grep -qE '\$|#' && break; sleep 1; done
|
||||
tmux capture-pane -t "$S" -p -S -5000 > "tmp/$S/after-session.txt"
|
||||
tmux kill-session -t "$S"
|
||||
```
|
||||
|
||||
3. Inspect source for root cause and likely fix (read files inside worktree).
|
||||
4. Append: reproduced (yes/no), affected area, fix direction.
|
||||
|
||||
### For bugs without clear reproduction:
|
||||
|
||||
1. Add `welcome-pr` if it exists. Say community PRs are welcome.
|
||||
2. Add `status/need-retesting` if on a stale version.
|
||||
3. Inspect source and docs inside worktree; state confidence: confirmed / plausible / no clear
|
||||
direction.
|
||||
4. Append likely root cause or link similar historical issues.
|
||||
|
||||
### For feature requests:
|
||||
|
||||
1. Run `/goal Is this feature request truly aligned with Qwen Code's product direction, and is the proposed approach the best solution?`
|
||||
2. Append verdict: accept for exploration, suggest a smaller alternative, or
|
||||
decline as out of direction.
|
||||
242
.qwen/skills/triage/references/pr-workflow.md
Normal file
242
.qwen/skills/triage/references/pr-workflow.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# PR Workflow
|
||||
|
||||
Shared rules (untrusted input, skip, bilingual format) are in `SKILL.md`.
|
||||
|
||||
**Comment style:** write like a human maintainer — conversational, concise, bilingual. No bullet-point checklists that feel auto-generated.
|
||||
|
||||
### Comment Management
|
||||
|
||||
Three comments, one per stage. Post each through the issues comments API and
|
||||
capture its ID:
|
||||
|
||||
```bash
|
||||
COMMENT_ID=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" -F body=@/tmp/stage-N.md --jq '.id')
|
||||
```
|
||||
|
||||
| Stage | Comment |
|
||||
| ------- | --------------------------------------------- |
|
||||
| Stage 1 | Gate findings |
|
||||
| Stage 2 | Code review + test results (with screenshots) |
|
||||
| Stage 3 | Reflection + verdict |
|
||||
|
||||
**Terminal gate exception:** if Stage 1a template check fails, submit exactly
|
||||
one `CHANGES_REQUESTED` review and stop. Do not also post or update a Stage 1
|
||||
issue comment, and do not continue to Stage 2, Stage 3, or approval.
|
||||
|
||||
**Re-runs:** if the triage runs again on the same PR, update each comment in place:
|
||||
|
||||
```bash
|
||||
gh api -X PATCH "/repos/$REPO/issues/comments/$COMMENT_ID" -F body=@/tmp/stage-N-updated.md
|
||||
```
|
||||
|
||||
Never create duplicates.
|
||||
|
||||
**Signature:** every comment ends with:
|
||||
|
||||
```
|
||||
— *Qwen Code · qwen3.7-max*
|
||||
```
|
||||
|
||||
**Approval:** the `gh pr review --approve` command is a separate step that runs **after** Stage 3 comment is posted. Comment first, then approve only when genuinely confident.
|
||||
|
||||
### Stage 1: Gate (Template + Direction + Solution Review)
|
||||
|
||||
**⛔ Before anything else: create a worktree.** This is the #1 forgotten step.
|
||||
|
||||
```
|
||||
enter_worktree(name: "triage")
|
||||
```
|
||||
|
||||
Save the `worktreePath`. All `read_file`, `grep_search`, `glob` calls below must use it as root. `gh` commands do not need it.
|
||||
|
||||
This is the most important stage — catch problems before anyone spends time reviewing code.
|
||||
|
||||
**1a. Template check:**
|
||||
|
||||
PR body missing required headings from `.github/pull_request_template.md` (read from worktree) → request changes, @mention author, link the template, stop. This is the only public output for this terminal gate.
|
||||
|
||||
```bash
|
||||
gh pr review "$PR_NUMBER" --repo "$REPO" --request-changes --body-file /tmp/pr-gate-template.md
|
||||
```
|
||||
|
||||
**1b. Product direction:**
|
||||
|
||||
Ask the hard questions before reading a single line of code:
|
||||
|
||||
- Does this solve a real user problem, or is it a solution looking for a problem?
|
||||
- Is it within qwen-code's core mission, or does it pull focus from what matters more?
|
||||
- "Can do" ≠ "should do" — technically feasible doesn't mean we should ship it.
|
||||
|
||||
CHANGELOG is a reference signal, not the sole criterion:
|
||||
|
||||
```bash
|
||||
curl -s https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md | grep -iC1 "<keywords>"
|
||||
```
|
||||
|
||||
- **Found** → cite version/line as supporting signal.
|
||||
- **Not found** → not a rejection. The area may still be relevant.
|
||||
|
||||
**Escalate to maintainer** (never auto-reject): touches auth/sandbox/model selection/telemetry/release/public contract, or direction is genuinely unclear.
|
||||
|
||||
**1c. Solution review** (never skip — judge from the PR description and a skim of the diff structure, before reading code in detail):
|
||||
|
||||
- If we cut 80% of the scope, would the remaining 20% already solve the problem?
|
||||
- Could we achieve the same goal by modifying something that already exists, instead of adding something new?
|
||||
- Can the complexity live outside the codebase (user config, external tool) instead of inside it?
|
||||
|
||||
If you spot a materially simpler path, raise it — not as a blocker, but as a genuine question the contributor should think about before the code review.
|
||||
|
||||
Implementation-level concerns (over-abstraction, code duplication, "10 lines vs 10 files") belong in Stage 2a code review — you need to see the code for those.
|
||||
|
||||
Post a single Stage 1 comment. Be direct — say what you actually think, not what's polite:
|
||||
|
||||
```markdown
|
||||
<!-- qwen-triage stage=1 -->
|
||||
|
||||
Thanks for the PR!
|
||||
|
||||
Template looks good ✓
|
||||
|
||||
On direction: <state your honest assessment — aligned and why, or concerns and why>. CHANGELOG <reference if found, or "no direct reference but the area is relevant">.
|
||||
|
||||
On approach: <state your honest assessment — the scope feels right / feels like it could be much simpler / here's what I'd consider cutting>. <If you see a simpler path, name it: "Have you considered just X? It might cover most of the use case with a fraction of the complexity.">
|
||||
|
||||
<If passing:> Moving on to code review. 🔍
|
||||
<If concerns:> Flagging these for discussion before diving deeper.
|
||||
|
||||
<details>
|
||||
<summary>中文说明</summary>
|
||||
|
||||
感谢贡献!
|
||||
|
||||
模板完整 ✓
|
||||
|
||||
方向:<直接说判断——对齐的原因/担心的原因>。
|
||||
|
||||
方案:<范围合理 / 感觉可以大幅简化 / 建议砍掉的部分>。<如果看到更简路径,点名:有没有考虑过直接 X?可能用很小的复杂度覆盖大部分场景。>
|
||||
|
||||
<如果通过:> 进入代码审查 🔍
|
||||
<如果有顾虑:> 先提出来讨论,再深入看代码。
|
||||
|
||||
</details>
|
||||
|
||||
— _Qwen Code · qwen3.7-max_
|
||||
```
|
||||
|
||||
Save this comment's ID. If direction is escalated → stop here. Template
|
||||
failures already stopped in Stage 1a.
|
||||
|
||||
### Stage 2: Review + Test
|
||||
|
||||
#### 2a. Code Review
|
||||
|
||||
All local file reads (`read_file`, `grep_search`, `glob`) operate inside the worktree. The diff itself comes from `gh pr diff` (GitHub API, no worktree needed).
|
||||
|
||||
**Step 1 — Independent proposal (before reading the diff):**
|
||||
|
||||
Read only the PR title + "Why it's needed" section. Without looking at the diff, write down what _you_ would do to solve this problem. Be concrete — name the files, the approach, the tradeoffs. This is your independent baseline.
|
||||
|
||||
> Why: seeing the diff first anchors your judgment. You'll confirm the PR's approach instead of evaluating whether it's the right approach. Forcing yourself to propose first is the only way to have a real alternative in mind.
|
||||
|
||||
**Step 2 — Compare with the diff:**
|
||||
|
||||
Now read the diff. Compare the PR's approach against your independent proposal:
|
||||
|
||||
- Does the PR's solution match or exceed yours? Or did you find a simpler path it missed?
|
||||
- Are there correctness bugs, security holes, or regressions your approach would have avoided?
|
||||
- Does the implementation follow the project's conventions, or does it over-abstract / duplicate code / put logic in the wrong package?
|
||||
|
||||
Keep it tight — only flag two kinds of issues:
|
||||
|
||||
- **Critical blockers** — correctness bugs, security holes, regressions.
|
||||
- **Clear AGENTS.md violations** — over-abstraction, unnecessary duplication, code in the wrong package, structural patterns that directly contradict the project's conventions.
|
||||
|
||||
Don't nitpick style, naming preferences, or "could be done differently." If it's not a blocker, leave it.
|
||||
|
||||
```bash
|
||||
gh pr diff "$PR_NUMBER" --repo "$REPO"
|
||||
```
|
||||
|
||||
When posting findings, summarize in a few sentences like a human would — "the auth logic is duplicated in two places, worth extracting" not a line-by-line breakdown. Save inline comments for things that genuinely block the merge.
|
||||
|
||||
#### 2b. Real-Scenario Testing
|
||||
|
||||
**Runs in the main working tree, not the worktree** — tmux needs the local build environment.
|
||||
|
||||
**Mandatory.** Unit tests don't substitute. Unrelated build failure ≠ excuse to skip.
|
||||
|
||||
**⛔ The tmux output IS the review.** The maintainer reads your Stage 2 comment and decides approve/reject from it. You **must** paste the actual `capture-pane` terminal output inline in the comment — inside a fenced code block. Not a file path, not "see attached log", not a text summary. If you didn't inline the output, the review is worthless.
|
||||
|
||||
Drive the real product in tmux, using the `tmux-real-user-testing` skill. Capture the terminal at key moments with `capture-pane` — these are the evidence that makes the review actionable.
|
||||
|
||||
**Before/after** (for bug fixes / behavior changes):
|
||||
|
||||
```bash
|
||||
S=triage-test-$(date +%H%M%S); mkdir -p "tmp/$S"
|
||||
tmux new-session -d -s "$S" -x 200 -y 50 -c "$(pwd)"
|
||||
# sanitize scenario — derived from PR text, must not reach shell unsanitized
|
||||
SAFE_SCENARIO=$(printf '%s' "$SCENARIO" | tr -cd '[:alnum:] _-.,' | cut -c1-200)
|
||||
# before — installed qwen (bug reproduces)
|
||||
tmux send-keys -t "$S" "qwen -p '$SAFE_SCENARIO' 2>&1 | tee tmp/$S/before.log" Enter
|
||||
for i in $(seq 1 120); do tmux capture-pane -t "$S" -p | tail -1 | grep -qE '\$|#' && break; sleep 1; done
|
||||
tmux capture-pane -t "$S" -p -S -5000 > "tmp/$S/before-session.txt"
|
||||
# after — this PR via dev build (bug fixed)
|
||||
tmux send-keys -t "$S" "npm run dev -- -p '$SAFE_SCENARIO' 2>&1 | tee tmp/$S/after.log" Enter
|
||||
for i in $(seq 1 120); do tmux capture-pane -t "$S" -p | tail -1 | grep -qE '\$|#' && break; sleep 1; done
|
||||
tmux capture-pane -t "$S" -p -S -5000 > "tmp/$S/after-session.txt"
|
||||
tmux kill-session -t "$S"
|
||||
```
|
||||
|
||||
`qwen ...` = installed build, `npm run dev -- ...` = PR code. Same invocation, only the build differs.
|
||||
|
||||
- Cannot run after exhausting workarounds → FAIL, not skip.
|
||||
- Fork code: sandbox (strip write tokens/secrets).
|
||||
|
||||
Post a single Stage 2 comment (must include `<!-- qwen-triage stage=2 -->` at the top): code review findings + testing result.
|
||||
|
||||
**⛔ BEFORE POSTING: verify your comment contains the tmux output.** Read back through your draft — does it have a fenced code block with the actual terminal capture? If not, add it now. The maintainer cannot approve without seeing what actually happened.
|
||||
|
||||
````markdown
|
||||
## Before (installed build)
|
||||
|
||||
<!-- paste capture-pane output here inside ``` -->
|
||||
|
||||
## After (this PR)
|
||||
|
||||
<!-- paste capture-pane output here inside ``` -->
|
||||
````
|
||||
|
||||
Sign with `— *Qwen Code · qwen3.7-max*` and save this comment's ID.
|
||||
|
||||
### Stage 3: Reflect
|
||||
|
||||
Don't rush to approve. This is the moment to actually think.
|
||||
|
||||
Step back and look at the whole picture — the motivation, the implementation, the test results, the direction signal. Go back to the independent proposal you wrote in Stage 2a Step 1, and ask yourself:
|
||||
|
||||
- Does the PR's approach match or exceed my independent proposal? Or did I find a simpler path it missed?
|
||||
- Does this solve something users actually care about?
|
||||
- Is the code straightforward, or does it feel like it's trying too hard?
|
||||
- After seeing it run, do the results match what the PR promised?
|
||||
- If I had to maintain this in six months, would I curse the author or thank them?
|
||||
- Am I approving this because it's genuinely good, or because I ran out of reasons to say no?
|
||||
|
||||
If your independent proposal was materially simpler — say so. Not as a blocker, but as an honest question the contributor should think about.
|
||||
|
||||
**Step 1: Post the reflection comment** (must include `<!-- qwen-triage stage=3 -->` at the top). Write what you're actually thinking. "Looks good, ships the feature cleanly, the before/after shows it works" — not a five-bullet summary of the stages. If you have reservations, say them plainly. If you're approving with mild concerns, name them. Sign with `— *Qwen Code · qwen3.7-max*` and save this comment's ID.
|
||||
|
||||
**Step 2: Act on the verdict.**
|
||||
|
||||
All stages genuinely clean — approve:
|
||||
|
||||
```bash
|
||||
gh pr review "$PR_NUMBER" --repo "$REPO" --approve --body "LGTM, looks ready to ship. ✅"
|
||||
```
|
||||
|
||||
Reflection shows it shouldn't merge — request changes immediately, citing the specific concerns from the comment:
|
||||
|
||||
```bash
|
||||
gh pr review "$PR_NUMBER" --repo "$REPO" --request-changes --body "Needs some rethinking — see my notes above. 🙏"
|
||||
```
|
||||
|
||||
Genuinely unsure — **don't approve or reject**. Ask the maintainer to weigh in. Use `$QWEN_MAINTAINER_HANDLE` if set.
|
||||
|
|
@ -30,7 +30,10 @@ We favor small, atomic PRs that address a single issue or add a single, self-con
|
|||
- **Do:** Create a PR that fixes one specific bug or adds one specific feature.
|
||||
- **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR.
|
||||
|
||||
Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently.
|
||||
As a rule of thumb, start splitting a PR once it exceeds about 1,200 changed
|
||||
lines. PRs above about 2,000 changed lines should either be split into a series
|
||||
of smaller, logical PRs that can be reviewed and merged independently, or
|
||||
explain in the PR description why the change needs to land together.
|
||||
|
||||
#### 3. Use Draft PRs for Work in Progress
|
||||
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -46,15 +46,13 @@ Qwen Code is an open-source AI agent for the terminal, optimized for Qwen series
|
|||
#### Linux / macOS
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)"
|
||||
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash
|
||||
```
|
||||
|
||||
#### Windows (Run as Administrator)
|
||||
#### Windows
|
||||
|
||||
Works in both Command Prompt and PowerShell:
|
||||
|
||||
```cmd
|
||||
powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')"
|
||||
```powershell
|
||||
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex
|
||||
```
|
||||
|
||||
> **Note**: It's recommended to restart your terminal after installation to ensure environment variables take effect.
|
||||
|
|
|
|||
|
|
@ -126,7 +126,13 @@ When `attempt === 1` and no retries happened, `request_setup_ms` is small (just
|
|||
2. **Single-trace debug** — operator sees `duration_ms=12000, request_setup_ms=11500, ttft_ms=200, sampling_ms=300` → instantly diagnoses "retries ate 11.5s, model itself was fast." Computing `request_setup_ms` from other fields requires also exposing `sampling_ms`, which we do anyway (D6).
|
||||
3. **Negligible cost** — 1 INT64 attribute. Same order of magnitude as the existing `input_tokens`, `output_tokens` attributes. Backend ingest cost is not material.
|
||||
|
||||
### D4 — Retry telemetry: `onRetry` callback option on `retryWithBackoff` + new `ApiRetryEvent`
|
||||
### D4 — Retry telemetry: `onRetry` callback option on `retryWithBackoff` + `ApiRetryEvent` + AsyncLocalStorage propagation
|
||||
|
||||
> **Phase 4b update (post-design discovery)**: this section was originally written assuming claude-code's "one LLM span owns the retry loop" pattern. While implementing Phase 4b, we discovered that qwen-code's 4 `retryWithBackoff` call sites (`client.ts:2109`, `baseLlmClient.ts:235,333`, `geminiChat.ts:2035` — line numbers as of merge) all wrap `apiCall = () => contentGenerator.generateContent(...)`. The retry layer sits **above** LoggingContentGenerator. Each retry attempt invokes `apiCall()` fresh → fresh `qwen-code.llm_request` span. There is no single shared span across attempts. An in-`LoggingContentGenerator` accumulator wouldn't work.
|
||||
>
|
||||
> **Resolution**: propagate retry state via `AsyncLocalStorage` (`retryContext` in `packages/core/src/utils/retryContext.ts`). `retryWithBackoff` wraps each `await fn()` in `retryContext.run({ attempt, requestSetupMs, retryTotalDelayMs }, fn)`. `LoggingContentGenerator` reads the ALS in its synchronous prelude and forwards the values to `endLLMRequestSpan`. This actually gives **richer** observability than the original plan — each per-attempt span has its own `duration_ms` / `ttft_ms` / error details AND knows where in the retry budget it sits via the per-attempt `attempt` / `requestSetupMs` / `retryTotalDelayMs` attributes.
|
||||
>
|
||||
> The ALS approach matches existing patterns in the codebase (`promptIdContext`, `subagentNameContext`, `agent-context`) — minimal new surface, well-understood semantics. Plan-mode review process captured this revision through 3 review rounds finding 22 issues, all addressed before merge.
|
||||
|
||||
`retryWithBackoff` currently calls `logRetryAttempt` (`retry.ts:343`) which only writes to `debugLogger.warn`. We extend the `RetryOptions` interface with an opt-in callback:
|
||||
|
||||
|
|
@ -188,14 +194,14 @@ export class ApiRetryEvent implements BaseTelemetryEvent {
|
|||
|
||||
OTel span attributes are scalars (`string | number | boolean | array of these`). Map-typed attributes (like `retry_count_by_status: {429:2, 503:1}`) require JSON serialization and are awkward to query. Skip them.
|
||||
|
||||
| Attribute | Type | Semantic |
|
||||
| -------------------------- | ------ | ----------------------------------------------------------------------------------- |
|
||||
| `attempt` | int | 1-based final attempt count (`attemptStartTimes.length`) |
|
||||
| `retry_total_delay_ms` | int | Sum of all `delayMs` reported by `onRetry`; 0 if no retries |
|
||||
| `ttft_ms` | int | TTFT per D1; undefined for non-streaming or aborted-before-first-chunk requests |
|
||||
| `request_setup_ms` | int | Per D3 |
|
||||
| `sampling_ms` | int | Per D6 |
|
||||
| `output_tokens_per_second` | double | Derived; `output_tokens / (sampling_ms / 1000)`; undefined when `sampling_ms === 0` |
|
||||
| Attribute | Type | Semantic |
|
||||
| -------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `attempt` | int | 1-based monotonic counter from `retryContext.attempt` (this attempt's iteration). Always populated (defaults to 1 when no retry context) |
|
||||
| `retry_total_delay_ms` | int | Cumulative backoff sleep BEFORE this attempt started. Undefined for direct calls; 0 for attempt 1; > 0 for subsequent retried attempts |
|
||||
| `ttft_ms` | int | TTFT per D1; undefined for non-streaming or aborted-before-first-chunk requests |
|
||||
| `request_setup_ms` | int | Per D3 |
|
||||
| `sampling_ms` | int | Per D6 |
|
||||
| `output_tokens_per_second` | double | Derived; `output_tokens / (sampling_ms / 1000)`; undefined when `sampling_ms === 0` |
|
||||
|
||||
Per-attempt status-code distribution (e.g., "2 of the 3 attempts were 429s") is queryable from log-bridge spans of `ApiRetryEvent` records. No need to duplicate it as a flattened attribute on the parent.
|
||||
|
||||
|
|
|
|||
525
docs/design/telemetry-subagent-spans-design.md
Normal file
525
docs/design/telemetry-subagent-spans-design.md
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
# Subagent Trace Tree Design (P3 Phase 3)
|
||||
|
||||
> Issue #3731 — Phase 3 of hierarchical session tracing. Adds a `qwen-code.subagent` span so subagent invocations get isolated, queryable trace structure instead of interleaving silently under the parent `qwen-code.interaction` span.
|
||||
>
|
||||
> Builds on Phase 1 (#4126), Phase 1.5 (#4302), and Phase 2 (#4321).
|
||||
|
||||
## Problem
|
||||
|
||||
Today every `AgentTool.execute` invocation runs under the parent's `qwen-code.interaction` span. Three pathologies:
|
||||
|
||||
1. **Concurrent subagents interleave.** `coreToolScheduler.ts:728` marks `AGENT` as concurrency-safe — `Promise.all` runs up to 10 subagents in parallel. Their LLM-request / tool / hook spans all attach to the single shared parent interaction span, so trace explorers cannot distinguish "this LLM request belongs to subagent A" from "this one belongs to subagent B".
|
||||
2. **No span for the subagent boundary itself.** There's a `qwen-code.subagent_execution` LogRecord (emitted from `agent-headless.ts:268,329`) bridged to a span of the same name via `LogToSpanProcessor`, but it's a stand-alone marker, not a parent that nests the subagent's LLM / tool / hook spans underneath.
|
||||
3. **Fork / background subagents float free.** Fire-and-forget paths (`runInForkContext` / background) outlive the parent `AgentTool.execute` and emit spans across multiple subsequent user turns. The parent tool span is already ended by the time those spans appear, so OTel's `context.active()` doesn't help — they attach to whichever interaction happened to be active at firing time, or none at all.
|
||||
|
||||
## Existing surface (no change)
|
||||
|
||||
| Component | Location | Why we don't touch it |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
||||
| Spawn site (unified) | `packages/core/src/tools/agent/agent.ts:1147` `AgentTool.execute()` | Single entrypoint; ideal hook for 3 invocation flavors |
|
||||
| Three invocation flavors | foreground-named (`runFramed` at `:2154` — awaited), fork (`void runInForkContext(runFramedFork)` at `:1991` — fire-and-forget), background (`void framedBgBody()` at `:1934` — fire-and-forget) | Lifecycle differs — span design covers all three |
|
||||
| Concurrency | `coreToolScheduler.runConcurrently` (`Promise.all`, cap 10) — driven by `partitionToolCalls` marking AGENT as `concurrent: true` | The thing that makes isolation necessary |
|
||||
| `runInForkContext` ALS | `packages/core/src/tools/agent/fork-subagent.ts:32` `forkExecutionStorage` | Recursive-fork guard only — does NOT propagate OTel context |
|
||||
| Agent identity ALS | `packages/core/src/agents/runtime/agent-context.ts:46` `runWithAgentContext(agentId, ...)` | Already carries `agentId`; we extend it with `depth` |
|
||||
| `SubagentExecutionEvent` LogRecord | `agent-headless.ts:268,329` → `loggers.ts:773` → 3 downstreams (LogToSpanProcessor span bridge + QwenLogger RUM + `recordSubagentExecutionMetrics`) | LogRecord stays; downstreams depend on it |
|
||||
|
||||
## Out-of-scope (deferred)
|
||||
|
||||
- **Token usage aggregation per subagent** (`gen_ai.usage.*` summed across all LLM spans inside a subagent). Belongs in Phase 4 (LLM request decomposition).
|
||||
- **Migrating the `qwen-code.subagent_execution` LogRecord onto the new span as span events.** RUM and metrics are tightly coupled to the LogRecord; deferred to a follow-up that can renegotiate all 3 consumers together.
|
||||
- **Auto-cost rollup.** Same reason — needs token usage first.
|
||||
- **Removing the AGENT-tool `concurrent: true` marker.** Concurrency is correct; we instrument it, we don't constrain it.
|
||||
|
||||
## References (decision evidence)
|
||||
|
||||
| Source | Key takeaway |
|
||||
| ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [OTel Trace Spec — Links between spans](https://opentelemetry.io/docs/specs/otel/overview/#links-between-spans) | Verbatim: "The new linked Trace may also represent a long running asynchronous data processing operation that was initiated by one of many fast incoming requests." → fork/background should be linked roots, not children. |
|
||||
| [OTel GenAI Agent Spans](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/) (status: Development) | Span name `invoke_agent {gen_ai.agent.name}`; required attrs `gen_ai.operation.name`, `gen_ai.provider.name`; recommended: `gen_ai.agent.id`, `gen_ai.agent.name`, `gen_ai.conversation.id`. |
|
||||
| LangSmith — 25,000 runs / trace cap | Long agent sessions force trace splitting eventually; favors hybrid traceId design. |
|
||||
| [Sentry — distributed tracing](https://docs.sentry.io/concepts/key-terms/tracing/distributed-tracing/) | "Child transactions may outlive the transactions containing their parent spans" — child-with-outliving-life is supported. |
|
||||
| claude-code (Anthropic) | Has subagent hierarchy in local Perfetto JSON file only; OTel export is flat. No portable code. |
|
||||
| opencode (sst/opencode) | Uses `@effect/opentelemetry` auto-instrumentation; explicit `context.with(trace.setSpan(active, span), fn)` for `withRunSpan`. **Validates the context.with isolation pattern.** Their warning about manual `AsyncLocalStorageContextManager` registration doesn't apply — qwen-code's `NodeSDK` registers it automatically. |
|
||||
|
||||
## Design — six decisions, each justified
|
||||
|
||||
### D1 — Span lifecycle: caller opens, callee runs inside `context.with(span, fn)`
|
||||
|
||||
`agent.ts` (caller) constructs the span. The body — whether awaited (`runFramed`) or fire-and-forget (`runInForkContext` / background) — runs inside `runInSubagentSpanContext(span, fn)`, which calls `otelContext.with(trace.setSpan(active, span), fn)`.
|
||||
|
||||
**Where exactly in `AgentTool.execute` does the span open?** Open it **right BEFORE the invocation-kind-specific setup** (`createAgentHeadless` / `createForkSubagent` etc.) — so setup time (config build, ToolRegistry rebuild, ContextOverride wiring) IS included in `qwen-code.subagent` duration. Operators tracking "why is this subagent slow?" see the full picture. Setup typically << LLM time, so this is noise-free.
|
||||
|
||||
Alternative considered: open after setup, exclude setup time. Rejected because subagent's setup is itself work attributable to the subagent — hiding it makes total-duration math wrong when summing all subagent spans.
|
||||
|
||||
**Why not callee-only**: by the time fork / background body actually runs, the caller has already returned. OTel `context.active()` then returns whatever ambient context the async runtime carries — which for `void` fire-and-forget after the parent ends is unreliable. The parent span has already been closed; reparenting after-the-fact is wrong.
|
||||
|
||||
**Why not caller-only**: foreground works fine that way, but fork / background spans must continue emitting child spans (LLM / tool / hook) after `AgentTool.execute` returns. Those child spans need `context.active()` to return the subagent span — which only happens if the body explicitly runs inside `context.with(subagentSpan, body)`.
|
||||
|
||||
Both ends are needed. **The design is the bridge** — caller creates span + invocationKind-aware traceId strategy, then hands off via `runInSubagentSpanContext`.
|
||||
|
||||
### D2 — Hybrid traceId: foreground = child span, fork/background = new traceId + Link
|
||||
|
||||
| Invocation kind | Parent | TraceId | Why |
|
||||
| --------------- | --------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `foreground` | child of caller's tool span | inherits parent traceId | OTel default; caller fully encloses callee temporally |
|
||||
| `fork` | linked root span | new traceId | Caller returns immediately; fork runs across multiple subsequent interactions. OTel spec verbatim recommends Link for this. Avoids inflating parent trace's duration / size. |
|
||||
| `background` | linked root span | new traceId | Same reasoning as fork. |
|
||||
|
||||
**Link payload**:
|
||||
|
||||
```ts
|
||||
tracer.startSpan(
|
||||
'qwen-code.subagent',
|
||||
{
|
||||
kind: SpanKind.INTERNAL,
|
||||
links: [
|
||||
{
|
||||
context: invokerSpanContext,
|
||||
attributes: { 'qwen-code.link.kind': 'invoker' },
|
||||
},
|
||||
],
|
||||
} /* explicit context = root, not inheriting active */,
|
||||
);
|
||||
```
|
||||
|
||||
Cross-trace queryability via session id: `gen_ai.conversation.id` is set on every subagent span (foreground and linked-root alike), so an ARMS query by `session.id` returns both the parent interaction's trace AND the linked-root subagent traces. The Link itself shows up in the parent trace's UI as "Spawned: subagent X (other trace)" so navigation works.
|
||||
|
||||
**Why not always-child**: 4-hour background subagent inflates the parent trace's wall-clock duration to 4 hours; trace size grows past several backends' caps (LangSmith's 25,000-run limit is the clearest documented bound). Foreground subagents that the user is actually waiting for don't have this problem because they're temporally enclosed.
|
||||
|
||||
**Why not always-linked-root**: foreground breaks the natural trace tree. A user prompt that runs a synchronous Explore subagent SHOULD show one tree, not two linked traces.
|
||||
|
||||
### D3 — TTL: type-aware, subagent fork/background = 4h, others = 30min
|
||||
|
||||
`session-tracing.ts:124` defines `SPAN_TTL_MS = 30 * 60 * 1000`. The sweep at `:144-152` already special-cases `tool.blocked_on_user` to stamp `decision: 'aborted' + source: 'system'`. It's already type-aware in spirit.
|
||||
|
||||
**Change**: introduce per-type TTL:
|
||||
|
||||
```ts
|
||||
const SPAN_TTL_MS_DEFAULT = 30 * 60 * 1000; // 30min
|
||||
const SPAN_TTL_MS_LONG = 4 * 60 * 60 * 1000; // 4h
|
||||
|
||||
function ttlFor(ctx: SpanContext): number {
|
||||
if (
|
||||
ctx.type === 'subagent' &&
|
||||
ctx.attributes['qwen-code.subagent.invocation_kind'] !== 'foreground'
|
||||
) {
|
||||
return SPAN_TTL_MS_LONG;
|
||||
}
|
||||
return SPAN_TTL_MS_DEFAULT;
|
||||
}
|
||||
```
|
||||
|
||||
On TTL expiry, subagent spans get stamped:
|
||||
|
||||
```ts
|
||||
{
|
||||
'qwen-code.span.ttl_expired': true,
|
||||
'qwen-code.span.duration_ms': age,
|
||||
'qwen-code.subagent.status': 'aborted',
|
||||
'qwen-code.subagent.terminate_reason': 'ttl_swept',
|
||||
}
|
||||
```
|
||||
|
||||
**Why not 30min flat**: legit long subagents (large repo analysis, slow builds, deep research tasks) get mis-stamped as TTL-expired. 4h covers the 99th percentile without being so loose that real hangs go undetected.
|
||||
|
||||
**Why not no-TTL**: process crash / OOM / kill -9 → span stays in `activeSpans` Map forever. The 30-min safety net protects against this; subagent fork/background just needs a wider window, not removal.
|
||||
|
||||
**Where 4h came from**: pragmatic upper bound for non-trivial agent tasks (long deep-research / large codebase analysis). Configurable via constant if production data shows we're wrong.
|
||||
|
||||
### D4 — LogRecord retention: keep emission, skip the LogToSpanProcessor bridge
|
||||
|
||||
`SubagentExecutionEvent` LogRecord has 3 downstream consumers (verified by repo audit):
|
||||
|
||||
| Consumer | Position | Action |
|
||||
| ---------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| OTel LogRecord → `LogToSpanProcessor` → bridge span `qwen-code.subagent_execution` | `loggers.ts:773` → `log-to-span-processor.ts:346` | **Skip this bridge** for the subagent event — new `qwen-code.subagent` span replaces it |
|
||||
| QwenLogger RUM ingestion (Aliyun internal stats) | `qwen-logger.ts:573-574` | Keep — RUM doesn't see OTel spans, only LogRecords |
|
||||
| `recordSubagentExecutionMetrics` Counter | `metrics.ts:829` | Keep — metric consumer is independent of trace bridge |
|
||||
|
||||
**Bridge skip** (the only change to LogToSpanProcessor):
|
||||
|
||||
```ts
|
||||
// log-to-span-processor.ts — inside onEmit, after deriveSpanName
|
||||
const skipBridge = new Set<string>([
|
||||
EVENT_SUBAGENT_EXECUTION, // covered by native qwen-code.subagent span
|
||||
]);
|
||||
if (skipBridge.has(eventName)) return;
|
||||
```
|
||||
|
||||
**Trace consumer impact**: dashboards that filter on span name `qwen-code.subagent_execution` start returning zero results. They should be updated to `qwen-code.subagent`. Note this in release notes.
|
||||
|
||||
**Why not delete the LogRecord**: it's the input to RUM and metrics. Deleting it is a 3-system refactor; out of scope here.
|
||||
|
||||
**Why not keep both**: trace would show two spans per subagent (`qwen-code.subagent` + `qwen-code.subagent_execution`) carrying overlapping info — confusing for operators reading traces, duplicate span volume.
|
||||
|
||||
### D5 — Span name + attrs: hybrid spec compliance, vendor-prefixed for extensions
|
||||
|
||||
**Span name**: `qwen-code.subagent` (matches Phase 1/2 codebase convention: `qwen-code.interaction`, `qwen-code.tool`, `qwen-code.hook`, …).
|
||||
|
||||
OTel GenAI spec says the canonical span name is `invoke_agent {gen_ai.agent.name}` — but **also** says "individual GenAI systems/frameworks MAY specify different span name formats." We use our own name and set `gen_ai.operation.name='invoke_agent'` so spec-aware tooling still identifies the span. Operators reading our trace tree see consistent `qwen-code.*` naming.
|
||||
|
||||
**Span kind**: `INTERNAL` (in-process subagent invocation, per spec).
|
||||
|
||||
**Attribute set**:
|
||||
|
||||
| Category | Attribute | Source | Notes |
|
||||
| ---------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Required spec** | `gen_ai.operation.name='invoke_agent'` | literal | spec-required |
|
||||
| **Required spec** | `gen_ai.provider.name='qwen-code'` | literal | spec-required; ambiguous for in-process agents (spec wrote it for LLM provider). Setting to `'qwen-code'` is the most honest interpretation |
|
||||
| **Required (dual-emit)** | `gen_ai.agent.id` + `qwen-code.subagent.id` | `agentContext.agentId` | dual-emit until spec reaches Stable; remove vendor key later |
|
||||
| **Required (dual-emit)** | `gen_ai.agent.name` + `qwen-code.subagent.name` | `agentConfig.subagentType` (e.g. `Explore`, `code-reviewer`, `fork`) | same dual-emit |
|
||||
| **Recommended spec** | `gen_ai.conversation.id` | `config.getSessionId()` | enables cross-trace queries by session; co-exists with the existing `session.id` span attr (set globally per #4367) — both point at the same UUID, drop one when spec stabilises |
|
||||
| **Recommended spec** | `gen_ai.request.model` | model override if any | only when subagent overrides parent model |
|
||||
| **Vendor** | `qwen-code.subagent.invocation_kind` | `'foreground'` ❘ `'fork'` ❘ `'background'` | drives TTL + traceId strategy |
|
||||
| **Vendor** | `qwen-code.subagent.is_built_in` | bool | dashboard filter |
|
||||
| **Vendor** | `qwen-code.subagent.parent_agent_id` | parent ALS `agentId` | for nested subagents + cross-trace lineage |
|
||||
| **Vendor** | `qwen-code.subagent.depth` | parent depth + 1 (top = 0) | recursion-bug detector |
|
||||
| **Vendor** | `qwen-code.subagent.invoking_request_id` | from `agentContext` | request-level correlation |
|
||||
| **End-of-span spec** | `error.type` (on failure) | error class | OTel standard |
|
||||
| **End-of-span spec** | `exception.message` (on failure) | `truncateSpanError(error.message)` | OTel standard; reuses Phase 2 truncation |
|
||||
| **End-of-span vendor** | `qwen-code.subagent.status` | `'completed'` ❘ `'failed'` ❘ `'cancelled'` ❘ `'aborted'` | finer than OTel SpanStatus (which is OK / ERROR / UNSET) |
|
||||
| **End-of-span vendor** | `qwen-code.subagent.terminate_reason` | from `SubagentExecutionEvent.terminate_reason` | e.g. `task_complete`, `max_iterations`, `user_abort`, `ttl_swept` |
|
||||
| **End-of-span vendor** | `qwen-code.subagent.result_summary_present` | bool | "did subagent produce output" — bounded |
|
||||
| **Opt-in (sensitive)** gated on `includeSensitiveSpanAttributes` | `gen_ai.input.messages` | structured chat history | reuses #4097's gate |
|
||||
| **Opt-in (sensitive)** | `gen_ai.output.messages` | model responses | same gate |
|
||||
| **Opt-in (sensitive)** | `gen_ai.system_instructions` | system prompt | same gate |
|
||||
| **Opt-in (sensitive)** | `gen_ai.tool.definitions` | tool schemas | same gate |
|
||||
|
||||
**SpanStatus mapping**:
|
||||
|
||||
- `status === 'completed'` → `SpanStatus { code: OK }`
|
||||
- `status === 'failed'` → `SpanStatus { code: ERROR, message: truncated(error.message) }`
|
||||
- `status === 'cancelled'` or `'aborted'` → `SpanStatus { code: UNSET }` (matches Phase 2 convention)
|
||||
|
||||
**Why dual-emit on `id` + `name`**: spec is in Development (one step earlier than Experimental). `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental` exists for opt-in. Spec attr names may rename before Stable. Dual-emit is the same pattern Phase 2 used for `call_id` → `tool.call_id`; remove the vendor key when spec reaches Stable.
|
||||
|
||||
**Why `qwen-code.subagent.*` (not `qwen.subagent.*`)**: every existing vendor-prefixed key in `constants.ts` uses `qwen-code.*` (`qwen-code.user_prompt`, `qwen-code.tool_call`, etc.). Internal consistency > OTel naming-convention preference, since operators query ARMS by prefix.
|
||||
|
||||
**Cardinality**: span attrs are not metric labels in OTel; UUID-keyed attrs (`id`, `parent_agent_id`, `invoking_request_id`) are safe at the span layer. Don't promote them to metric labels later.
|
||||
|
||||
**~10-15 attrs per span** (depending on invocation kind, failure, nesting). Same order as `qwen-code.tool`.
|
||||
|
||||
### D6 — `AgentContext.depth` field added directly
|
||||
|
||||
`AgentContext` (`agent-context.ts:32`) is **not exported** — only the helpers (`getCurrentAgentId`, `runWithAgentContext`, `getRuntimeContentGenerator`, `runWithRuntimeContentGenerator`) are. Zero TypeScript-level downstream breakage. The 6 known readers via `getCurrentAgentId()` only read `agentId`; adding `depth?: number` is invisible to them.
|
||||
|
||||
```ts
|
||||
interface AgentContext {
|
||||
agentId: string;
|
||||
subagentName: string;
|
||||
invokingRequestId: string;
|
||||
invocationKind: 'spawn' | 'resume';
|
||||
isBuiltIn: boolean;
|
||||
depth?: number; // NEW — default 0 in readers
|
||||
}
|
||||
```
|
||||
|
||||
`runWithAgentContext` already uses `{ ...current, agentId }` spread, so `depth` survives existing call sites unchanged. **Update `runWithAgentContext` to auto-increment depth internally** — no caller needs to know about depth:
|
||||
|
||||
```ts
|
||||
function runWithAgentContext<T>(agentId: string, fn: () => T): T {
|
||||
const parent = agentContextStorage.getStore();
|
||||
const next: AgentContext = {
|
||||
...parent,
|
||||
agentId,
|
||||
depth: (parent?.depth ?? -1) + 1, // auto-increment
|
||||
};
|
||||
return agentContextStorage.run(next, fn);
|
||||
}
|
||||
```
|
||||
|
||||
Top-level subagent: no parent ALS → `depth: 0`. Nested: parent depth+1.
|
||||
|
||||
A new tiny accessor `getCurrentAgentDepth(): number` returns `agentContextStorage.getStore()?.depth ?? 0` — used by `startSubagentSpan` to populate `qwen-code.subagent.depth`.
|
||||
|
||||
**Why not a separate ALS just for telemetry**: would duplicate the same context shape we already maintain. Bad. Reuse the existing one.
|
||||
|
||||
## Helper API (`session-tracing.ts`)
|
||||
|
||||
```ts
|
||||
// constants.ts
|
||||
export const SPAN_SUBAGENT = 'qwen-code.subagent';
|
||||
|
||||
// session-tracing.ts
|
||||
export interface StartSubagentSpanOptions {
|
||||
agentId: string;
|
||||
subagentName: string;
|
||||
invocationKind: 'foreground' | 'fork' | 'background';
|
||||
isBuiltIn: boolean;
|
||||
parentAgentId?: string;
|
||||
depth: number;
|
||||
invokingRequestId?: string;
|
||||
sessionId: string;
|
||||
modelOverride?: string;
|
||||
invokerSpanContext?: SpanContext; // required for fork / background (Link source)
|
||||
}
|
||||
|
||||
export interface SubagentSpanMetadata {
|
||||
status: 'completed' | 'failed' | 'cancelled' | 'aborted';
|
||||
terminateReason?: string;
|
||||
resultSummaryPresent?: boolean;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
}
|
||||
|
||||
export function startSubagentSpan(opts: StartSubagentSpanOptions): Span;
|
||||
export function endSubagentSpan(
|
||||
span: Span,
|
||||
metadata: SubagentSpanMetadata,
|
||||
): void;
|
||||
export function runInSubagentSpanContext<T>(
|
||||
span: Span,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T>;
|
||||
```
|
||||
|
||||
`runInSubagentSpanContext` is the isolation primitive:
|
||||
|
||||
```ts
|
||||
export function runInSubagentSpanContext<T>(
|
||||
span: Span,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ctx = trace.setSpan(otelContext.active(), span);
|
||||
return otelContext.with(ctx, fn);
|
||||
}
|
||||
```
|
||||
|
||||
`startSubagentSpan` internally branches on `invocationKind`:
|
||||
|
||||
```ts
|
||||
function startSubagentSpan(opts: StartSubagentSpanOptions): Span {
|
||||
const attributes = buildSpanAttributes(opts);
|
||||
const tracer = getTracer();
|
||||
|
||||
if (opts.invocationKind === 'foreground') {
|
||||
// Child of current active span (caller's tool span)
|
||||
return tracer.startSpan(SPAN_SUBAGENT, {
|
||||
kind: SpanKind.INTERNAL,
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
|
||||
// fork / background: linked root span
|
||||
return tracer.startSpan(SPAN_SUBAGENT, {
|
||||
kind: SpanKind.INTERNAL,
|
||||
attributes,
|
||||
links: opts.invokerSpanContext
|
||||
? [
|
||||
{
|
||||
context: opts.invokerSpanContext,
|
||||
attributes: { 'qwen-code.link.kind': 'invoker' },
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
root: true, // forces new traceId; ignores active context as parent
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Lifecycle wiring
|
||||
|
||||
### Foreground named (the common path)
|
||||
|
||||
```ts
|
||||
// agent.ts:~2154
|
||||
// Pull parent ALS frame to set parentAgentId on the span. The new child's
|
||||
// depth is computed inside runWithAgentContext automatically (D6) — we
|
||||
// read it via getCurrentAgentDepth() once we're INSIDE the child ALS
|
||||
// frame. Two-step:
|
||||
const parentAgentId = getCurrentAgentId(); // BEFORE entering child frame
|
||||
|
||||
// ... existing runFramed call enters runWithAgentContext(hookOpts.agentId, ...) ...
|
||||
|
||||
// INSIDE runFramed, we can read child's depth:
|
||||
// const depth = getCurrentAgentDepth();
|
||||
//
|
||||
// Practical placement: thread `depth` as a closure variable, set after
|
||||
// runWithAgentContext takes effect — OR compute it as
|
||||
// `(getCurrentAgentDepth() outside) + 1` from the caller side (simpler).
|
||||
const depth = getCurrentAgentDepth(); // outside frame; child will be this + 1
|
||||
// (set qwen-code.subagent.depth = depth in startSubagentSpan args)
|
||||
|
||||
const span = startSubagentSpan({
|
||||
agentId, subagentName, invocationKind: 'foreground',
|
||||
isBuiltIn, parentAgentId, depth, invokingRequestId, sessionId,
|
||||
modelOverride,
|
||||
// invokerSpanContext omitted — foreground inherits naturally via context.with
|
||||
});
|
||||
let metadata: SubagentSpanMetadata = { status: 'aborted' };
|
||||
try {
|
||||
await runInSubagentSpanContext(span, () =>
|
||||
runFramed(() => this.runSubagentWithHooks(...)),
|
||||
);
|
||||
metadata = { status: 'completed' /* + resultSummaryPresent */ };
|
||||
} catch (error) {
|
||||
metadata = {
|
||||
status: signal.aborted ? 'aborted' : 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType: error?.constructor?.name,
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
endSubagentSpan(span, metadata);
|
||||
}
|
||||
```
|
||||
|
||||
### Fork (fire-and-forget)
|
||||
|
||||
```ts
|
||||
const invokerSpanContext = trace.getSpan(otelContext.active())?.spanContext();
|
||||
const span = startSubagentSpan({
|
||||
..., invocationKind: 'fork', invokerSpanContext,
|
||||
});
|
||||
void runInForkContext(() =>
|
||||
runInSubagentSpanContext(span, async () => {
|
||||
let metadata: SubagentSpanMetadata = { status: 'aborted' };
|
||||
try {
|
||||
await runFramedFork();
|
||||
metadata = { status: 'completed' };
|
||||
} catch (error) {
|
||||
metadata = {
|
||||
status: signal.aborted ? 'aborted' : 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
endSubagentSpan(span, metadata);
|
||||
}
|
||||
}),
|
||||
);
|
||||
// AgentTool.execute returns FORK_PLACEHOLDER_RESULT immediately;
|
||||
// span lives across subsequent interactions of the parent session.
|
||||
```
|
||||
|
||||
### Background
|
||||
|
||||
Same shape as fork, with `invocationKind: 'background'` and `bgEventEmitter` instead of `eventEmitter`. TTL is 4h (same as fork — type rule from D3).
|
||||
|
||||
## Concurrent isolation — the headline guarantee
|
||||
|
||||
Three concurrent subagent invocations from one user prompt (model emits 3 AGENT tool_use blocks → `coreToolScheduler.runConcurrently` runs 3 `executeSingleToolCall` in parallel; each opens its own `qwen-code.tool` span per Phase 2):
|
||||
|
||||
```
|
||||
qwen-code.interaction [traceId=T0]
|
||||
├─ qwen-code.tool [agent call #A]
|
||||
│ └─ qwen-code.subagent (A, foreground) [traceId=T0, child]
|
||||
│ ├─ qwen-code.llm_request
|
||||
│ └─ qwen-code.tool [...]
|
||||
│ └─ qwen-code.tool.execution
|
||||
├─ qwen-code.tool [agent call #B]
|
||||
│ └─ qwen-code.subagent (B, foreground) [traceId=T0, child]
|
||||
│ └─ qwen-code.llm_request
|
||||
└─ qwen-code.tool [agent call #C]
|
||||
└─ qwen-code.subagent (C, fork) [traceId=T1, linked root]
|
||||
└─ qwen-code.llm_request [traceId=T1]
|
||||
└─ ... [traceId=T1, may emit hours later]
|
||||
```
|
||||
|
||||
`context.with(span, runX)` for each of A, B, C runs concurrently. `AsyncLocalStorageContextManager` (already auto-registered by NodeSDK at `sdk.ts:273`) scopes per fiber; no cross-talk. Each subagent's child LLM / tool / hook spans see `span` via `context.active()` inside their own async chain.
|
||||
|
||||
Fork (C) is a separate trace — its child spans inherit `traceId=T1` even when emitted across multiple subsequent interactions of the parent session. ARMS query by `session.id` returns both T0 and T1; the Link from T1's root → C's invoking `qwen-code.tool` span provides explicit navigation.
|
||||
|
||||
## Files to change
|
||||
|
||||
| File | Change | LOC est |
|
||||
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `packages/core/src/telemetry/constants.ts` | Add `SPAN_SUBAGENT`, `SPAN_TTL_MS_LONG`, attribute key constants | +8 |
|
||||
| `packages/core/src/telemetry/session-tracing.ts` | Add `startSubagentSpan` (foreground/linked-root branch), `endSubagentSpan`, `runInSubagentSpanContext`, types; extend `SpanType` union with `'subagent'`; extend TTL sweep with `ttlFor(ctx)` | +120 |
|
||||
| `packages/core/src/telemetry/log-to-span-processor.ts` | Skip-list to bypass bridging `qwen-code.subagent_execution` | +6 |
|
||||
| `packages/core/src/telemetry/index.ts` | Re-export new helpers + types | +6 |
|
||||
| `packages/core/src/agents/runtime/agent-context.ts` | Add `depth?: number` to `AgentContext` + `getCurrentAgentDepth()` accessor | +12 |
|
||||
| `packages/core/src/tools/agent/agent.ts` | Wrap 3 execution paths (foreground/fork/background) in `runInSubagentSpanContext` with try/catch/finally | +60 |
|
||||
| `packages/core/src/telemetry/session-tracing.test.ts` | New `describe('subagent spans')`: start/end, child vs linked-root, context propagation, depth, TTL per type, idempotent end, NOOP under SDK-uninitialized | +120 |
|
||||
| `packages/core/src/telemetry/log-to-span-processor.test.ts` | Assert skip-list short-circuits subagent_execution bridging | +20 |
|
||||
| `packages/core/src/tools/agent/agent.test.ts` | End-to-end: 3 concurrent subagents each get isolated subtree; fork's spans inherit new traceId via Link; background lifecycle | +80 |
|
||||
|
||||
Total: 9 files, ~430 LOC. Larger than typical Phase 2 commits but justified — TTL change touches a separate file, LogToSpanProcessor skip is a separate file, and the test files double up. Splitting would land an incomplete telemetry surface.
|
||||
|
||||
If review pushes back on size: split into 2 PRs — (A) telemetry helpers + tests, (B) `agent.ts` wiring + e2e tests. Helpers landed first don't change runtime behavior.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
| Test | What it proves |
|
||||
| ---------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `startSubagentSpan foreground parents to active OTel span` | Child-span path |
|
||||
| `startSubagentSpan fork creates new traceId + Link to invoker` | Linked-root path |
|
||||
| `runInSubagentSpanContext propagates span through awaits / Promise.all` | Isolation primitive |
|
||||
| `3 concurrent subagent spans don't share children` | Headline concurrency guarantee |
|
||||
| `nested subagent records depth + parentAgentId` | Nesting metadata |
|
||||
| `endSubagentSpan status mapping (completed / failed / cancelled / aborted)` | Status taxonomy |
|
||||
| `endSubagentSpan dual-emits gen_ai.agent.id + qwen-code.subagent.id` | Spec-compliance dual-emit |
|
||||
| `fork lifecycle: span survives AgentTool.execute return` | Fire-and-forget correctness |
|
||||
| `TTL: subagent fork stays past 30min, gets stamped + ended at 4h` | Type-aware TTL |
|
||||
| `TTL: foreground subagent at 30min gets default sweep` | TTL doesn't over-extend |
|
||||
| `LogToSpanProcessor skips qwen-code.subagent_execution but still RUM-emits` | Bridge skip works |
|
||||
| `runConcurrently of 3 agent tool calls produces 3 distinct subagent spans` | End-to-end at scheduler level |
|
||||
| `failed subagent sets exception.message + error.type + SpanStatus=ERROR` | OTel-standard error path |
|
||||
| `opt-in attrs gated on includeSensitiveSpanAttributes` | Reuses #4097's gate correctly |
|
||||
| `startSubagentSpan returns NOOP_SPAN when SDK is uninitialized` | Matches Phase 1/2 NOOP discipline; downstream calls remain safe |
|
||||
| `fork span Link.context matches invoker tool span's spanContext` | Cross-trace navigation works end-to-end |
|
||||
| `runWithAgentContext auto-increments depth: parent=0, child=1, grandchild=2` | Depth bookkeeping is correct without caller cooperation |
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
| ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Subagent inside tool inside subagent (depth > 1) | `depth` attr tracks; recommend soft `debugLogger.warn` at depth ≥ 5 (infinite-recursion detector) |
|
||||
| Subagent spawned during a parent tool's `awaiting_approval` | Subagent span is a child of the AGENT tool span; the AGENT tool's `tool.blocked_on_user` is a sibling, not parent — both children of the AGENT tool span. Tree stays correct |
|
||||
| `signal.aborted` mid-subagent | `runInSubagentSpanContext`'s callback throws or resolves; `finally` sets `status='aborted'`, SpanStatus UNSET |
|
||||
| Fork still alive when parent session ends | 4h TTL fires; sentinel attrs `qwen-code.span.ttl_expired:true`, `qwen-code.subagent.terminate_reason='ttl_swept'`, `status='aborted'` |
|
||||
| `endSubagentSpan` called twice | Idempotent — checks `activeSpans` map; second call no-ops (matches Phase 2 pattern) |
|
||||
| Subagent's LLM call uses a different model from parent | `gen_ai.request.model` set on subagent span; LLM-request sub-span ALSO records the model — no conflict |
|
||||
| Sister subagent prelude throw escapes `attemptExecutionOfScheduledCalls` | Lands in Phase 2's recently-fixed `handleConfirmationResponse` catch which is OUTSIDE the try — not attributed to confirmed tool's span. Subagent span correctly closes via its own try/finally |
|
||||
| Concurrent fork + foreground from one parent | Foreground inherits T0 traceId, fork gets T1. Both have correct context propagation independently. The parent tool span ends when its synchronous work returns; the fork span (separate trace) lives on |
|
||||
| Fork span starts in caller sync flow but body runs later | `startSubagentSpan` is called BEFORE `void runInForkContext(...)` so the span (and its Link to the invoker) is captured while the invoker's spanContext is still readable. Span duration therefore includes any microtask-queue scheduling delay before the body actually starts — typically sub-ms; if production shows non-trivial gaps a separate `qwen-code.subagent.scheduling_delay_ms` attribute can be added (open question) |
|
||||
| SDK not initialized (telemetry disabled) | `startSubagentSpan` early-returns NOOP_SPAN (matches every other Phase 1/2 helper). `runInSubagentSpanContext(NOOP_SPAN, fn)` still calls `fn` normally. `endSubagentSpan(NOOP_SPAN, …)` is a no-op |
|
||||
| Fork's log-bridge spans (`tool_call`, `api_request`, etc.) use session-derived traceId while fork's native spans use T1 | Pre-existing behavior — log-bridge spans always use `deriveTraceId(sessionId)`, native spans use OTel context. The divergence is invisible inside one trace but means an ARMS-by-traceId lookup on T1 won't include log-bridge children of the fork. Out of scope for this PR; called out as open question #5 |
|
||||
| Foreground vs background `SubagentStart` hook span parents differ | Foreground fires `fireSubagentStartEvent` inside `runSubagentWithHooks` → already inside `runInSubagentSpanContext`, so the hook span parents under `qwen-code.subagent`. Background fires it BEFORE the `runWithSubagentSpan` wrapping (so the subagent span doesn't yet exist), so its hook span parents under the AGENT `qwen-code.tool`. Operators querying "hook spans under subagent spans" should expect bg `SubagentStart` to be missing from that view. Moving the bg hook fire inside `framedBgBody` is mechanically simple (the `contextState` mutation reaches `bgSubagent.execute` either way), but it changes user-visible semantics: today the hook fires synchronously before `AgentTool.execute` returns the "Background agent launched" message, so any synchronous setup work the hook does happens inside the user-blocking turn; moving it makes the hook fire detached after the launch message returns. Deferred pending a deliberate decision on which semantic is preferred |
|
||||
|
||||
## Rollback
|
||||
|
||||
The change is additive at the OTel level — existing dashboards that don't filter on subagent-related span names keep working. Trace consumers that group by parent span will see new `qwen-code.subagent` nodes between `qwen-code.tool` and `qwen-code.llm_request`; document in release notes.
|
||||
|
||||
Behavior-affecting change is the LogToSpanProcessor skip — dashboards previously consuming `qwen-code.subagent_execution` span return zero. Mitigation: keep the LogRecord intact (RUM + metrics still see it); only the span bridge is removed. Existing log-based queries unaffected.
|
||||
|
||||
Rollback path: revert the single PR. The new span helpers are only invoked from `agent.ts`; dropping the wiring + the LogToSpanProcessor skip restores prior behavior 1:1.
|
||||
|
||||
## Sampling implications
|
||||
|
||||
| Invocation | Sampling decision source |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| `foreground` (child span, same traceId) | Inherits parent trace's sampled-or-not decision via parent-based sampler |
|
||||
| `fork` / `background` (linked root, new traceId) | Independent sampling decision at root creation |
|
||||
|
||||
For qwen-code's current default (per `tracer.ts:shouldForceSampled()` — parentbased + always_on else always_on), every span is sampled, so the divergence doesn't bite. For deployments using probabilistic samplers (e.g. `traceidratio=0.1`), this means:
|
||||
|
||||
- A user prompt may be sampled (T0 fully captured) but its fork (T1) may be dropped, or vice versa.
|
||||
- Operators reading parent T0 see "Link: subagent C (T1)" — clicking through may 404 if T1 was not sampled.
|
||||
|
||||
Mitigation: document for operators. If full subagent capture matters, force sampling for fork/background via a future config knob. Out of scope here.
|
||||
|
||||
## Sensitive attributes (#4097 integration)
|
||||
|
||||
Reuse the existing `includeSensitiveSpanAttributes` gate. When true, set on the subagent span at lifecycle hooks where the data is available:
|
||||
|
||||
| Spec attr | Source | When set |
|
||||
| ---------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `gen_ai.system_instructions` | rendered system prompt from `agentConfig` / parent context | `startSubagentSpan` (if available before span open) or via `setAttributes` early in body |
|
||||
| `gen_ai.tool.definitions` | tool declarations available to the subagent | same as above |
|
||||
| `gen_ai.input.messages` | initial input passed to subagent (prompt + extraHistory) | at start of body |
|
||||
| `gen_ai.output.messages` | final response messages returned by subagent | in `endSubagentSpan` metadata |
|
||||
|
||||
These are all already gated; #4097's pattern is to call `addSubagentSensitiveAttributes(span, opts)` helper from inside the body. Implementation detail — design just notes the integration point.
|
||||
|
||||
## Sequencing
|
||||
|
||||
- Independent of #4367 (resource attributes — in review). No merge-order constraint, but `gen_ai.conversation.id` on subagent spans benefits from #4367's `session.id` moved off resource. **Recommend landing #4367 first** so `getSessionId()` source-of-truth is settled.
|
||||
- Independent of Phase 4 (LLM request decomposition / TTFT). Phase 4 attaches to `qwen-code.llm_request` spans regardless of whether they're under a subagent or an interaction. Recommend Phase 3 before Phase 4 so Phase 4's per-attempt metrics can be aggregated per-subagent.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **`gen_ai.provider.name`**: spec requires it but writes the description for LLM provider, not agent framework. Setting to `'qwen-code'` is best interpretation; if a future spec revision adds an `agent.provider.name` variant we should switch.
|
||||
2. **Span name `qwen-code.subagent` vs spec `invoke_agent {name}`**: chose internal consistency. If GenAI-aware tooling adoption grows and `invoke_agent ${name}` becomes critical for auto-discovery, we can switch — span name is the most rebrandable thing in OTel.
|
||||
3. **Soft-warn at depth ≥ 5**: arbitrary number. Could be a config knob. Defer until production data shows a need.
|
||||
4. **`SubagentExecutionEvent.result`'s full LLM output is large**: today it bloats LogRecord volume. The migration plan (LogRecord → span events) is deferred but worth doing once token-usage aggregation lands in Phase 4.
|
||||
5. **Log-bridge spans inside a fork end up on the session-derived traceId, not the fork's T1**: see edge cases. The fix is the broader "interaction span doesn't inherit session root context" issue raised in the sessionId-vs-traceId thread — a separate design that affects all native spans, not just subagent. Out of scope.
|
||||
|
|
@ -30,7 +30,10 @@ We favor small, atomic PRs that address a single issue or add a single, self-con
|
|||
- **Do:** Create a PR that fixes one specific bug or adds one specific feature.
|
||||
- **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR.
|
||||
|
||||
Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently.
|
||||
As a rule of thumb, start splitting a PR once it exceeds about 1,200 changed
|
||||
lines. PRs above about 2,000 changed lines should either be split into a series
|
||||
of smaller, logical PRs that can be reviewed and merged independently, or
|
||||
explain in the PR description why the change needs to land together.
|
||||
|
||||
#### 3. Use Draft PRs for Work in Progress
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ Settings are organized into categories. Most settings should be placed within th
|
|||
| `general.sessionRecapAwayThresholdMinutes` | number | Minutes the terminal must be blurred before an auto-recap fires on focus-in. Only used when `showSessionRecap` is enabled. | `5` |
|
||||
| `general.gitCoAuthor.commit` | boolean | Add a Co-authored-by trailer to git commit messages AND attach a per-file AI-attribution git note (`refs/notes/ai-attribution`) for commits made through Qwen Code. Disabling skips both. | `true` |
|
||||
| `general.gitCoAuthor.pr` | boolean | Append a Qwen Code attribution line to pull request descriptions when running `gh pr create`. | `true` |
|
||||
| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` |
|
||||
| `general.defaultFileEncoding` | string | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` |
|
||||
| `general.cleanupPeriodDays` | number | Days to retain `~/.qwen/file-history/` session backups used by `/rewind`. Backups older than this are removed by a background pass that runs at most once per day. `0` = minimum retention (~1 hour): keeps sessions touched in the last hour plus the currently active one. Changes take effect after restart. | `30` |
|
||||
|
||||
|
|
@ -638,7 +637,6 @@ For sandbox image selection, precedence is:
|
|||
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||
| `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
|
||||
| `--experimental-lsp` | | Enables experimental [LSP (Language Server Protocol)](../features/lsp) feature for code intelligence (go-to-definition, find references, diagnostics, etc.). | | Experimental. Requires language servers to be installed. |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@ export default {
|
|||
headless: 'Headless Mode',
|
||||
'structured-output': 'Structured Output',
|
||||
'dual-output': 'Dual Output',
|
||||
checkpointing: {
|
||||
display: 'hidden',
|
||||
},
|
||||
'approval-mode': 'Approval Mode',
|
||||
'auto-mode': 'Auto Mode',
|
||||
worktree: 'Worktrees',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ walks three layers in order:
|
|||
|
||||
1. **acceptEdits fast-path** — Edit / Write whose target path is inside
|
||||
the workspace is auto-approved without invoking the classifier.
|
||||
**Exception:** writes to Qwen Code's own self-modification surfaces
|
||||
(`.qwen/settings*.json`, `QWEN.md`, `AGENTS.md`, `QWEN.local.md`,
|
||||
configured context filenames, `.qwen/rules/`, `.qwen/commands/`,
|
||||
`.qwen/agents/`, `.qwen/skills/`, `.qwen/hooks/`, `.mcp.json`) and
|
||||
persistence surfaces (`.git/`, `.husky/`, `package.json`, `.npmrc`,
|
||||
`Makefile`, `.github/workflows/`, etc.) route through the classifier
|
||||
even when they are inside the workspace. Symlinks targeting protected
|
||||
paths are resolved and rejected too. Shell commands that reach these
|
||||
paths via `cd && bash -lc '...'` or other wrappers go through the
|
||||
classifier as well.
|
||||
2. **Safe-tool allowlist** — Read-only and metadata-only built-in tools
|
||||
(Read, Grep, Glob, LS, LSP, TodoWrite, AskUserQuestion, etc.) are
|
||||
auto-approved without invoking the classifier.
|
||||
|
|
@ -42,7 +52,12 @@ runs:
|
|||
classifier never sees it.
|
||||
- `permissions.allow` rules with specific specifiers (e.g.
|
||||
`Bash(git status)`, `Read(./docs/**)`) still auto-allow without the
|
||||
classifier.
|
||||
classifier — **except** when the call resolves to a write at a
|
||||
protected self-modification or persistence path (see the list under
|
||||
"How it works"). In that case Auto Mode re-checks the call through
|
||||
the classifier so an allow rule on `Bash(*)` cannot silently turn
|
||||
into permission to rewrite Qwen Code settings, commands, hooks,
|
||||
skills, or MCP servers.
|
||||
- `permissions.ask` rules force manual confirmation even in Auto Mode.
|
||||
|
||||
## Over-broad allow rules are stripped while in Auto Mode
|
||||
|
|
@ -69,6 +84,19 @@ entries are natural-language descriptions, not rule patterns — they are
|
|||
injected additively into the classifier's system prompt alongside the
|
||||
built-in defaults.
|
||||
|
||||
There are three hint categories plus an environment list:
|
||||
|
||||
- **`allow`** — actions the classifier should auto-approve.
|
||||
- **`softDeny`** — destructive or irreversible actions the classifier
|
||||
should block **unless the user's most recent explicit request asked
|
||||
for that exact action and scope**. Soft denies can be cleared by
|
||||
user intent; a generic "yes do whatever" doesn't count.
|
||||
- **`hardDeny`** — security-boundary actions the classifier must block
|
||||
in Auto Mode regardless of `autoMode.hints.allow` or recent user
|
||||
intent. This is classifier policy, not a deterministic permission
|
||||
rule: it does not override `permissions.allow`. Use `permissions.deny`
|
||||
for actions that must never be allowed by the permission manager.
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
|
|
@ -79,10 +107,13 @@ built-in defaults.
|
|||
"Cleaning build artifacts under ./dist or ./build",
|
||||
"Reading any file under /Users/me/code/"
|
||||
],
|
||||
"deny": [
|
||||
"Any network call to intranet.example.com endpoints",
|
||||
"Modifying anything under ~/.ssh or ~/.aws",
|
||||
"softDeny": [
|
||||
"Editing Qwen Code settings unless I explicitly ask for the exact change",
|
||||
"Running migration scripts that touch the production DB"
|
||||
],
|
||||
"hardDeny": [
|
||||
"Sending secrets or .env contents to any network endpoint",
|
||||
"Modifying anything under ~/.ssh or ~/.aws"
|
||||
]
|
||||
},
|
||||
"environment": [
|
||||
|
|
@ -94,13 +125,18 @@ built-in defaults.
|
|||
}
|
||||
```
|
||||
|
||||
`hints.deny` is still accepted for backward compatibility and is treated
|
||||
as `softDeny`. Mixing both is fine — entries are concatenated, `softDeny`
|
||||
first.
|
||||
|
||||
### Length and count limits
|
||||
|
||||
To keep the classifier system prompt small:
|
||||
|
||||
- Each entry is capped at 200 characters (longer entries are truncated
|
||||
with a warning).
|
||||
- `hints.allow` and `hints.deny` accept up to 50 entries each.
|
||||
- `hints.allow`, `hints.softDeny`, and `hints.hardDeny` accept up to 50
|
||||
entries each.
|
||||
- `environment` accepts up to 20 entries.
|
||||
|
||||
### Layering across settings files
|
||||
|
|
@ -114,15 +150,24 @@ de-duplicated.
|
|||
When the classifier blocks an action, the tool call fails with one of
|
||||
the following error texts:
|
||||
|
||||
- **`Blocked by auto mode policy: <reason>`** — the classifier judged
|
||||
the action unsafe. The reason comes from Stage 2 of the classifier.
|
||||
- **`Blocked by auto mode policy: <reason>`** —
|
||||
the classifier judged the action unsafe. The reason comes from Stage
|
||||
2 of the classifier.
|
||||
- **`Auto mode classifier unavailable; action blocked for safety`** —
|
||||
the classifier API was unreachable, timed out, or returned an
|
||||
un-parseable response. This is fail-closed behavior: when in doubt,
|
||||
block.
|
||||
|
||||
The main LLM sees the same message in the tool result and adjusts its
|
||||
approach (asks you, switches tactic, gives up).
|
||||
Both messages are followed by a trailing guidance line telling the agent
|
||||
that the **denied action specifically** must not be completed through
|
||||
another tool, shell indirection, generated script, alias, symlink,
|
||||
config change, hook, command file, MCP configuration, encoded payload,
|
||||
or equivalent path. **Unrelated safe work and genuinely safer
|
||||
alternatives are still allowed** — only attempts to accomplish the same
|
||||
denied intent through a different surface are blocked.
|
||||
|
||||
If the denied action is genuinely required, the agent should stop and
|
||||
ask you for explicit approval rather than route around the denial.
|
||||
|
||||
### Classifier reason language
|
||||
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
# Checkpointing
|
||||
|
||||
Qwen Code includes a Checkpointing feature that automatically saves a snapshot of your project's state before any file modifications are made by AI-powered tools. This allows you to safely experiment with and apply code changes, knowing you can instantly revert back to the state before the tool was run.
|
||||
|
||||
## How It Works
|
||||
|
||||
When you approve a tool that modifies the file system (like `write_file` or `edit`), the CLI automatically creates a "checkpoint." This checkpoint includes:
|
||||
|
||||
1. **A Git Snapshot:** A commit is made in a special, shadow Git repository located in your home directory (`~/.qwen/history/<project_hash>`). This snapshot captures the complete state of your project files at that moment. It does **not** interfere with your own project's Git repository.
|
||||
2. **Conversation History:** The entire conversation you've had with the agent up to that point is saved.
|
||||
3. **The Tool Call:** The specific tool call that was about to be executed is also stored.
|
||||
|
||||
If you want to undo the change or simply go back, you can use the `/restore` command. Restoring a checkpoint will:
|
||||
|
||||
- Revert all files in your project to the state captured in the snapshot.
|
||||
- Restore the conversation history in the CLI.
|
||||
- Re-propose the original tool call, allowing you to run it again, modify it, or simply ignore it.
|
||||
|
||||
All checkpoint data, including the Git snapshot and conversation history, is stored locally on your machine. The Git snapshot is stored in the shadow repository while the conversation history and tool calls are saved in a JSON file in your project's temporary directory, typically located at `~/.qwen/tmp/<project_hash>/checkpoints`.
|
||||
|
||||
## Enabling the Feature
|
||||
|
||||
The Checkpointing feature is disabled by default. To enable it, you can either use a command-line flag or edit your `settings.json` file.
|
||||
|
||||
### Using the Command-Line Flag
|
||||
|
||||
You can enable checkpointing for the current session by using the `--checkpointing` flag when starting Qwen Code:
|
||||
|
||||
```bash
|
||||
qwen --checkpointing
|
||||
```
|
||||
|
||||
### Using the `settings.json` File
|
||||
|
||||
To enable checkpointing by default for all sessions, you need to edit your `settings.json` file.
|
||||
|
||||
Add the following key to your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"checkpointing": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using the `/restore` Command
|
||||
|
||||
Once enabled, checkpoints are created automatically. To manage them, you use the `/restore` command.
|
||||
|
||||
### List Available Checkpoints
|
||||
|
||||
To see a list of all saved checkpoints for the current project, simply run:
|
||||
|
||||
```
|
||||
/restore
|
||||
```
|
||||
|
||||
The CLI will display a list of available checkpoint files. These file names are typically composed of a timestamp, the name of the file being modified, and the name of the tool that was about to be run (e.g., `2025-06-22T10-00-00_000Z-my-file.txt-write_file`).
|
||||
|
||||
### Restore a Specific Checkpoint
|
||||
|
||||
To restore your project to a specific checkpoint, use the checkpoint file from the list:
|
||||
|
||||
```
|
||||
/restore <checkpoint_file>
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
/restore 2025-06-22T10-00-00_000Z-my-file.txt-write_file
|
||||
```
|
||||
|
||||
After running the command, your files and conversation will be immediately restored to the state they were in when the checkpoint was created, and the original tool prompt will reappear.
|
||||
|
|
@ -225,7 +225,7 @@ In interactive mode, `/diff` opens a dialog with a **source picker** along the t
|
|||
|
||||
The file list displays per-file stats (lines added/removed) with tags for special states (`new`, `deleted`, `untracked`, `binary`, `truncated`, `oversized`). Press Enter on a file to view its inline diff with syntax-highlighted hunks.
|
||||
|
||||
Per-turn diffs require [file checkpointing](./checkpointing) to be enabled (on by default in interactive mode). When file checkpointing is off, only the "Current" source is available.
|
||||
Per-turn diffs require file checkpointing to be enabled (on by default in interactive mode). When file checkpointing is off, only the "Current" source is available.
|
||||
|
||||
**Keyboard shortcuts:**
|
||||
|
||||
|
|
@ -268,17 +268,17 @@ In headless (`--prompt`) or non-interactive contexts, `/diff` prints a plain-tex
|
|||
|
||||
Commands for obtaining information and performing system settings.
|
||||
|
||||
| Command | Description | Usage Examples |
|
||||
| --------------- | ----------------------------------------------- | -------------------------------- |
|
||||
| `/help` | Display help information for available commands | `/help` or `/?` |
|
||||
| `/status` | Display version information | `/status` or `/about` |
|
||||
| `/status paths` | Display current session file and log paths | `/status paths` |
|
||||
| `/stats` | Display detailed statistics for current session | `/stats` |
|
||||
| `/settings` | Open settings editor | `/settings` |
|
||||
| `/auth` | Change authentication method | `/auth` |
|
||||
| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` |
|
||||
| `/copy` | Copy last output content to clipboard | `/copy` |
|
||||
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
|
||||
| Command | Description | Usage Examples |
|
||||
| --------------- | ------------------------------------------------------------- | -------------------------------- |
|
||||
| `/help` | Display help information for available commands | `/help` or `/?` |
|
||||
| `/status` | Display version information | `/status` or `/about` |
|
||||
| `/status paths` | Display current session file and log paths | `/status paths` |
|
||||
| `/stats` | Display detailed statistics for current session | `/stats` |
|
||||
| `/settings` | Open settings editor | `/settings` |
|
||||
| `/auth` | Change authentication method | `/auth` |
|
||||
| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` |
|
||||
| `/copy` | Copy AI output to clipboard (`/copy N` = Nth-last AI message) | `/copy` or `/copy 2` |
|
||||
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
|
||||
|
||||
### 1.10 Common Shortcuts
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,25 @@ response:
|
|||
| `/copy code typescript` | Copies the last `typescript` code block. |
|
||||
| `/copy code mermaid 1` | Copies the first `mermaid` code block. |
|
||||
|
||||
## Selecting an Earlier AI Message
|
||||
|
||||
By default `/copy` targets the most recent AI message. Prefix the command with
|
||||
a positive integer to copy from the Nth-last AI message instead — handy when
|
||||
the latest reply is something low-signal (e.g., a TODO update) and the
|
||||
substantive output is one or two turns back.
|
||||
|
||||
| Command | Behavior |
|
||||
| --------------------- | ------------------------------------------------------ |
|
||||
| `/copy 2` | Copies the second-to-last AI message in full. |
|
||||
| `/copy 3` | Copies the third-to-last AI message in full. |
|
||||
| `/copy 2 code python` | Copies the last `python` code block from the 2nd-last. |
|
||||
| `/copy 3 latex` | Copies the last LaTeX block from the 3rd-last message. |
|
||||
|
||||
`/copy 1` is equivalent to `/copy`. If `N` exceeds the number of AI messages
|
||||
in the session, `/copy` reports the actual count instead of copying anything.
|
||||
Without a leading integer, sub-selectors such as `/copy code python 2` keep
|
||||
their existing meaning (the 2nd `python` block in the last message).
|
||||
|
||||
## Current Limits
|
||||
|
||||
- Mermaid image rendering depends on Mermaid CLI plus terminal image support.
|
||||
|
|
@ -160,4 +179,5 @@ response:
|
|||
Mermaid layout engine.
|
||||
- Raw mode is global for rendered Markdown blocks; it is not a per-block toggle.
|
||||
- LaTeX rendering covers common symbols and expressions, not full TeX layout.
|
||||
- Source copy commands operate on the last AI response.
|
||||
- Source copy commands target the last AI response by default, or the Nth-last
|
||||
when invoked as `/copy N ...`.
|
||||
|
|
|
|||
|
|
@ -10,19 +10,19 @@
|
|||
### Install Qwen Code:
|
||||
|
||||
The recommended installer uses a standalone archive when one is available for
|
||||
your platform. If it falls back to npm, Node.js 20 or later with npm must be
|
||||
your platform. If it falls back to npm, Node.js 22 or later with npm must be
|
||||
available on PATH.
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```sh
|
||||
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash
|
||||
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash
|
||||
```
|
||||
|
||||
**Windows**
|
||||
|
||||
```cmd
|
||||
powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')"
|
||||
```powershell
|
||||
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex
|
||||
```
|
||||
|
||||
> [!note]
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ To install Qwen Code, use one of the following methods:
|
|||
**Linux / macOS**
|
||||
|
||||
```sh
|
||||
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash
|
||||
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash
|
||||
```
|
||||
|
||||
**Windows (Run as Administrator)**
|
||||
**Windows**
|
||||
|
||||
```cmd
|
||||
powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')"
|
||||
```powershell
|
||||
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex
|
||||
```
|
||||
|
||||
> [!note]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Uninstall
|
||||
|
||||
Your uninstall method depends on how you ran the CLI. Follow the instructions for either npx or a global npm installation.
|
||||
Your uninstall method depends on how you installed the CLI.
|
||||
|
||||
## Method 1: Using npx
|
||||
|
||||
|
|
@ -40,3 +40,21 @@ npm uninstall -g @qwen-code/qwen-code
|
|||
```
|
||||
|
||||
This command completely removes the package from your system.
|
||||
|
||||
## Method 3: Standalone Install
|
||||
|
||||
If you installed via the standalone installer (`curl ... | bash` or `irm ... | iex`), use the dedicated uninstall script.
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh | bash
|
||||
```
|
||||
|
||||
**Windows**
|
||||
|
||||
```powershell
|
||||
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1 | iex
|
||||
```
|
||||
|
||||
The uninstaller removes the standalone runtime, generated `qwen` wrapper, and installer-managed PATH changes. Your Qwen Code configuration (`~/.qwen`) is preserved by default.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ This guide provides solutions to common issues and debugging tips, including top
|
|||
## Frequently asked questions (FAQs)
|
||||
|
||||
- **Q: How do I update Qwen Code to the latest version?**
|
||||
- A: If you installed it globally via `npm`, update it using the command `npm install -g @qwen-code/qwen-code@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
|
||||
- A: If you installed Qwen Code with the standalone installer, rerun the standalone install command. If you installed it globally via `npm`, update it using the command `npm install -g @qwen-code/qwen-code@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
|
||||
|
||||
- **Q: Where are the Qwen Code configuration or settings files stored?**
|
||||
- A: The Qwen Code configuration is stored in two `settings.json` files:
|
||||
|
|
@ -60,6 +60,7 @@ This guide provides solutions to common issues and debugging tips, including top
|
|||
- **Cause:** The CLI is not correctly installed or it is not in your system's `PATH`.
|
||||
- **Solution:**
|
||||
The update depends on how you installed Qwen Code:
|
||||
- If you installed `qwen` with the standalone installer, rerun the standalone install command and then open a new terminal.
|
||||
- If you installed `qwen` globally, check that your `npm` global binary directory is in your `PATH`. You can update using the command `npm install -g @qwen-code/qwen-code@latest`.
|
||||
- If you are running `qwen` from source, ensure you are using the correct command to invoke it (e.g. `node packages/cli/dist/index.js ...`). To update, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ describe('sleep-interception', () => {
|
|||
|
||||
// The model's output should mention it was blocked
|
||||
expect(result.toLowerCase()).toContain('blocked');
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
it('should allow sleep < 2s', async () => {
|
||||
rig = new TestRig();
|
||||
|
|
@ -51,7 +51,26 @@ describe('sleep-interception', () => {
|
|||
|
||||
// Should not be blocked — model should complete successfully
|
||||
expect(result.toLowerCase()).not.toContain('blocked');
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
it('should allow retrying blocked sleep with an intentional sleep comment', async () => {
|
||||
rig = new TestRig();
|
||||
await rig.setup('sleep-intentional-retry');
|
||||
|
||||
const result = await rig.run(
|
||||
'Run this exact shell command first: sleep 5. ' +
|
||||
'If the command is blocked, retry with this exact shell command: ' +
|
||||
'sleep 2 # intentional-sleep: wait for MCP rate limit reset. ' +
|
||||
'Then say "DONE".',
|
||||
);
|
||||
|
||||
validateModelOutput(result, null, 'sleep intentional retry');
|
||||
|
||||
const foundShell = await rig.waitForToolCall('run_shell_command');
|
||||
expect(foundShell).toBeTruthy();
|
||||
|
||||
expect(result.toLowerCase()).toContain('done');
|
||||
});
|
||||
|
||||
it('should block sleep >= 2s even when followed by a trailing comment', async () => {
|
||||
// The `trimTrailingShellComment` state machine strips trailing `#...`
|
||||
|
|
@ -74,5 +93,5 @@ describe('sleep-interception', () => {
|
|||
|
||||
// Model must report it was blocked despite the trailing comment.
|
||||
expect(result.toLowerCase()).toContain('blocked');
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
2095
package-lock.json
generated
2095
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.17.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.17.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
|
@ -75,6 +75,7 @@
|
|||
"release:version": "node scripts/version.js",
|
||||
"telemetry": "node scripts/telemetry.js",
|
||||
"check:lockfile": "node scripts/check-lockfile.js",
|
||||
"desktop-openwork-sync": "bun run scripts/desktop-openwork-sync.ts",
|
||||
"clean": "node scripts/clean.js",
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/acp-bridge",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"description": "Shared ACP bridge core (createHttpAcpBridge factory, BridgeClient, defaultSpawnChannelFactory, BridgeFileSystem injection seam) + primitives (EventBus, AcpChannel, in-memory channel, PermissionMediator interface) used by qwen serve, channels, IDE, TUI, and remote-control adapters.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-base",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"description": "Base channel infrastructure for Qwen Code",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-dingtalk",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"description": "DingTalk channel adapter for Qwen Code",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-feishu",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"description": "Feishu (Lark) channel adapter for Qwen Code",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-plugin-example",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-telegram",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"description": "Telegram channel adapter for Qwen Code",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-weixin",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"description": "WeChat (Weixin) channel adapter for Qwen Code",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.17.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.17.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
|
|
@ -77,6 +77,7 @@
|
|||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "^10.0.0",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -100,6 +100,15 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
|
|||
}),
|
||||
APPROVAL_MODE_INFO: {},
|
||||
APPROVAL_MODES: [],
|
||||
DEFAULT_STOP_HOOK_BLOCK_CAP: 8,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES: 1000,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD: 25_000,
|
||||
ApprovalMode: {
|
||||
DEFAULT: 'default',
|
||||
AUTO_EDIT: 'auto-edit',
|
||||
YOLO: 'yolo',
|
||||
PLAN: 'plan',
|
||||
},
|
||||
AuthType: {},
|
||||
clearCachedCredentialFile: vi.fn(),
|
||||
QwenOAuth2Event: {},
|
||||
|
|
@ -139,6 +148,27 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
|
|||
snapshot: vi.fn(() => ({})),
|
||||
})),
|
||||
restoreWorktreeContext: mockRestoreWorktreeContext,
|
||||
HookEventName: {
|
||||
PreToolUse: 'PreToolUse',
|
||||
PostToolUse: 'PostToolUse',
|
||||
PostToolUseFailure: 'PostToolUseFailure',
|
||||
PostToolBatch: 'PostToolBatch',
|
||||
Notification: 'Notification',
|
||||
UserPromptSubmit: 'UserPromptSubmit',
|
||||
UserPromptExpansion: 'UserPromptExpansion',
|
||||
SessionStart: 'SessionStart',
|
||||
Stop: 'Stop',
|
||||
SubagentStart: 'SubagentStart',
|
||||
SubagentStop: 'SubagentStop',
|
||||
PreCompact: 'PreCompact',
|
||||
PostCompact: 'PostCompact',
|
||||
SessionEnd: 'SessionEnd',
|
||||
PermissionRequest: 'PermissionRequest',
|
||||
PermissionDenied: 'PermissionDenied',
|
||||
StopFailure: 'StopFailure',
|
||||
TodoCreated: 'TodoCreated',
|
||||
TodoCompleted: 'TodoCompleted',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeOutputDirContext.js', () => ({
|
||||
|
|
@ -172,7 +202,10 @@ vi.mock('../config/settings.js', () => ({
|
|||
SettingScope: {},
|
||||
loadSettings: vi.fn(),
|
||||
}));
|
||||
vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn() }));
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
buildDisabledSkillNamesProvider: vi.fn(() => () => new Set<string>()),
|
||||
}));
|
||||
vi.mock('./session/Session.js', () => ({ Session: vi.fn() }));
|
||||
vi.mock('../utils/acpModelUtils.js', () => ({
|
||||
formatAcpModelId: vi.fn(),
|
||||
|
|
@ -351,6 +384,7 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => {
|
|||
sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
replayHistory: vi.fn().mockResolvedValue(undefined),
|
||||
installRewriter: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
pendingWorktreeNotice: null as string | null,
|
||||
};
|
||||
lastSessionMock = mock;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -59,9 +59,9 @@ import {
|
|||
generateToolUseId,
|
||||
MessageBusType,
|
||||
getPlanModeSystemReminder,
|
||||
getSubagentSystemReminder,
|
||||
getArenaSystemReminder,
|
||||
STARTUP_CONTEXT_MODEL_ACK,
|
||||
getStartupContextLength,
|
||||
isSystemReminderContent,
|
||||
evaluatePermissionFlow,
|
||||
needsConfirmation,
|
||||
isPlanModeBlocked,
|
||||
|
|
@ -86,6 +86,7 @@ import {
|
|||
endToolExecutionSpan,
|
||||
logConversationFinishedEvent,
|
||||
ConversationFinishedEvent,
|
||||
acquireSleepInhibitor,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { getCommandSubcommandNames } from '../../services/commandMetadata.js';
|
||||
import { getEffectiveSupportedModes } from '../../services/commandUtils.js';
|
||||
|
|
@ -154,6 +155,19 @@ type AutoCompressionSendResult =
|
|||
| { responseStream: AsyncGenerator<StreamEvent>; stopReason?: never }
|
||||
| { responseStream: null; stopReason: PromptResponse['stopReason'] };
|
||||
|
||||
const MID_TURN_QUEUE_DRAIN_METHOD = 'craft/drainMidTurnQueue';
|
||||
|
||||
interface BackgroundNotificationQueueItem {
|
||||
displayText: string;
|
||||
modelText: string;
|
||||
taskId: string;
|
||||
status: string;
|
||||
kind: 'agent' | 'monitor' | 'shell';
|
||||
toolUseId?: string;
|
||||
}
|
||||
|
||||
const MAX_NOTIFICATION_QUEUE = 20;
|
||||
|
||||
export function computeInitialTurnFromHistory(
|
||||
records: ChatRecord[],
|
||||
sessionId: string,
|
||||
|
|
@ -197,15 +211,21 @@ export async function fireSessionPermissionDeniedForAutoMode(
|
|||
!config.getDisableAllHooks?.() &&
|
||||
shouldFirePermissionDeniedForAutoMode(decision, outcome)
|
||||
) {
|
||||
await config
|
||||
.getHookSystem?.()
|
||||
?.firePermissionDeniedEvent(
|
||||
toolName,
|
||||
toolParams,
|
||||
callId,
|
||||
getAutoModePermissionDeniedReason(decision),
|
||||
signal,
|
||||
try {
|
||||
await config
|
||||
.getHookSystem?.()
|
||||
?.firePermissionDeniedEvent(
|
||||
toolName,
|
||||
toolParams,
|
||||
callId,
|
||||
getAutoModePermissionDeniedReason(decision),
|
||||
signal,
|
||||
);
|
||||
} catch (hookError) {
|
||||
debugLogger.warn(
|
||||
`PermissionDenied hook failed for tool ${callId}: ${hookError instanceof Error ? hookError.message : String(hookError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -248,6 +268,14 @@ function isUserPromptRecord(record: ChatRecord): boolean {
|
|||
export interface AvailableCommandsSnapshot {
|
||||
availableCommands: AvailableCommand[];
|
||||
availableSkills?: string[];
|
||||
availableSkillDetails?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
filePath?: string;
|
||||
level?: string;
|
||||
modelInvocable?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function buildAvailableCommandsSnapshot(
|
||||
|
|
@ -279,19 +307,56 @@ export async function buildAvailableCommandsSnapshot(
|
|||
});
|
||||
|
||||
let availableSkills: string[] | undefined;
|
||||
const skillDetailsByName = new Map<
|
||||
string,
|
||||
NonNullable<AvailableCommandsSnapshot['availableSkillDetails']>[number]
|
||||
>();
|
||||
try {
|
||||
const skillManager = config.getSkillManager();
|
||||
if (skillManager) {
|
||||
const skills = await skillManager.listSkills();
|
||||
availableSkills = skills.map((skill) => skill.name);
|
||||
for (const skill of skills) {
|
||||
skillDetailsByName.set(skill.name, {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
body: skill.body,
|
||||
filePath: skill.filePath,
|
||||
level: skill.level,
|
||||
modelInvocable: skill.disableModelInvocation !== true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error loading available skills:', error);
|
||||
}
|
||||
|
||||
for (const command of slashCommands) {
|
||||
if (command.kind !== CommandKind.SKILL || !command.skillDetail) {
|
||||
continue;
|
||||
}
|
||||
const existing = skillDetailsByName.get(command.skillDetail.name);
|
||||
skillDetailsByName.set(command.skillDetail.name, {
|
||||
...existing,
|
||||
...command.skillDetail,
|
||||
modelInvocable: command.modelInvocable === true,
|
||||
});
|
||||
}
|
||||
const availableSkillDetails =
|
||||
skillDetailsByName.size > 0
|
||||
? Array.from(skillDetailsByName.values())
|
||||
: undefined;
|
||||
// Always derive the name list from the details map so the two stay in sync.
|
||||
// skillManager only contributes its own skills to `availableSkills`, but the
|
||||
// slashCommands loop above also adds bundled skills to `skillDetailsByName`;
|
||||
// a `??=` would leave bundled skills in details but missing from the name
|
||||
// list whenever skillManager succeeded.
|
||||
availableSkills = availableSkillDetails?.map((skill) => skill.name);
|
||||
|
||||
return {
|
||||
availableCommands,
|
||||
...(availableSkills !== undefined ? { availableSkills } : {}),
|
||||
...(availableSkillDetails !== undefined ? { availableSkillDetails } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -332,6 +397,21 @@ export class Session implements SessionContext {
|
|||
private cronDisabledByTokenLimit = false;
|
||||
private lastPromptTokenCount = 0;
|
||||
private lastPromptTokenCountChat: GeminiChat | null = null;
|
||||
private midTurnDrainUnavailable = false;
|
||||
|
||||
// Background notification drain state. ACP does not have the TUI's idle
|
||||
// hook, so the session serializes registry callbacks through this queue.
|
||||
private notificationQueue: BackgroundNotificationQueueItem[] = [];
|
||||
private notificationProcessing = false;
|
||||
private notificationAbortController: AbortController | null = null;
|
||||
private notificationCompletion: Promise<void> | null = null;
|
||||
|
||||
// Set true in dispose(). Guards #drainCronQueue and #drainNotificationQueue
|
||||
// against the race where #drainNotificationQueue's finally block kicks off
|
||||
// #drainCronQueue after the session has already been disposed (e.g. /clear
|
||||
// or session reload), which would otherwise execute orphaned cron prompts
|
||||
// on a session whose registries are already unregistered.
|
||||
private disposed = false;
|
||||
|
||||
// Modular components
|
||||
private readonly historyReplayer: HistoryReplayer;
|
||||
|
|
@ -374,6 +454,8 @@ export class Session implements SessionContext {
|
|||
this.planEmitter = new PlanEmitter(this);
|
||||
this.historyReplayer = new HistoryReplayer(this);
|
||||
this.messageEmitter = new MessageEmitter(this);
|
||||
|
||||
this.#registerBackgroundNotificationCallbacks();
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
|
|
@ -392,6 +474,27 @@ export class Session implements SessionContext {
|
|||
return this.createdAt;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.notificationQueue = [];
|
||||
this.cronQueue = [];
|
||||
this.notificationAbortController?.abort();
|
||||
this.notificationAbortController = null;
|
||||
this.notificationProcessing = false;
|
||||
this.notificationCompletion = null;
|
||||
|
||||
if (this.cronAbortController) {
|
||||
this.cronAbortController.abort();
|
||||
this.cronAbortController = null;
|
||||
}
|
||||
this.cronProcessing = false;
|
||||
this.cronCompletion = null;
|
||||
|
||||
this.config.getBackgroundTaskRegistry().setNotificationCallback(undefined);
|
||||
this.config.getMonitorRegistry().setNotificationCallback(undefined);
|
||||
this.config.getBackgroundShellRegistry().setNotificationCallback(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the message rewrite middleware if configured.
|
||||
* Must be called AFTER history replay to avoid rewriting historical messages.
|
||||
|
|
@ -431,7 +534,13 @@ export class Session implements SessionContext {
|
|||
);
|
||||
}
|
||||
|
||||
if (this.pendingPrompt || this.cronProcessing || this.cronAbortController) {
|
||||
if (
|
||||
this.pendingPrompt ||
|
||||
this.cronProcessing ||
|
||||
this.cronAbortController ||
|
||||
this.notificationProcessing ||
|
||||
this.notificationAbortController
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'Cannot rewind while a prompt is running',
|
||||
|
|
@ -467,7 +576,13 @@ export class Session implements SessionContext {
|
|||
}
|
||||
|
||||
restoreHistory(history: Content[]): void {
|
||||
if (this.pendingPrompt || this.cronProcessing || this.cronAbortController) {
|
||||
if (
|
||||
this.pendingPrompt ||
|
||||
this.cronProcessing ||
|
||||
this.cronAbortController ||
|
||||
this.notificationProcessing ||
|
||||
this.notificationAbortController
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'Cannot restore history while a prompt is running',
|
||||
|
|
@ -484,7 +599,7 @@ export class Session implements SessionContext {
|
|||
apiHistory: Content[],
|
||||
targetTurnIndex: number,
|
||||
): number {
|
||||
const startIndex = this.#hasStartupContext(apiHistory) ? 2 : 0;
|
||||
const startIndex = getStartupContextLength(apiHistory);
|
||||
|
||||
if (targetTurnIndex === 0) {
|
||||
return startIndex;
|
||||
|
|
@ -506,18 +621,6 @@ export class Session implements SessionContext {
|
|||
return -1;
|
||||
}
|
||||
|
||||
#hasStartupContext(apiHistory: Content[]): boolean {
|
||||
if (apiHistory.length < 2) return false;
|
||||
const first = apiHistory[0];
|
||||
const second = apiHistory[1];
|
||||
if (first?.role !== 'user' || second?.role !== 'model') return false;
|
||||
return (
|
||||
second.parts?.some(
|
||||
(part) => 'text' in part && part.text === STARTUP_CONTEXT_MODEL_ACK,
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
#isUserTextContent(content: Content): boolean {
|
||||
if (content.role !== 'user') return false;
|
||||
if (!content.parts || content.parts.length === 0) return false;
|
||||
|
|
@ -527,19 +630,29 @@ export class Session implements SessionContext {
|
|||
);
|
||||
if (hasFunctionResponse) return false;
|
||||
|
||||
// Exclude pure <system-reminder> entries (the startup prelude and the
|
||||
// mid-history MCP added-tool reminders). They are structural, not real
|
||||
// user prompts; counting them would shift the rewind truncation index and
|
||||
// silently drop a real turn. A genuine user turn that merely has a
|
||||
// per-turn reminder prepended still has a non-reminder prompt part, so it
|
||||
// is NOT excluded.
|
||||
if (isSystemReminderContent(content)) return false;
|
||||
|
||||
return content.parts.some((part) => 'text' in part && part.text);
|
||||
}
|
||||
|
||||
async cancelPendingPrompt(): Promise<void> {
|
||||
const hadPrompt = !!this.pendingPrompt;
|
||||
const hadCron = !!this.cronAbortController;
|
||||
const hadNotification =
|
||||
!!this.notificationAbortController || this.notificationProcessing;
|
||||
|
||||
if (this.followupAbort) {
|
||||
this.followupAbort.abort();
|
||||
this.followupAbort = null;
|
||||
}
|
||||
|
||||
if (!hadPrompt && !hadCron) {
|
||||
if (!hadPrompt && !hadCron && !hadNotification) {
|
||||
throw new Error(NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE);
|
||||
}
|
||||
|
||||
|
|
@ -556,6 +669,13 @@ export class Session implements SessionContext {
|
|||
this.cronProcessing = false;
|
||||
}
|
||||
|
||||
if (this.notificationAbortController) {
|
||||
this.notificationAbortController.abort();
|
||||
this.notificationAbortController = null;
|
||||
}
|
||||
this.notificationQueue = [];
|
||||
this.notificationProcessing = false;
|
||||
|
||||
// Stop scheduler and emit exit summary
|
||||
const scheduler = this.config.isCronEnabled()
|
||||
? this.config.getCronScheduler()
|
||||
|
|
@ -610,6 +730,23 @@ export class Session implements SessionContext {
|
|||
}
|
||||
}
|
||||
|
||||
// A background notification turn mutates the same chat history as a user
|
||||
// prompt. Abort it before awaiting the drain so user input is not blocked
|
||||
// behind notification tool calls.
|
||||
if (this.notificationAbortController) {
|
||||
this.notificationAbortController.abort();
|
||||
this.notificationAbortController = null;
|
||||
this.notificationQueue = [];
|
||||
this.notificationProcessing = false;
|
||||
}
|
||||
if (this.notificationCompletion) {
|
||||
try {
|
||||
await this.notificationCompletion;
|
||||
} catch {
|
||||
// Notification errors are surfaced through the session stream.
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelled while waiting for the previous prompt to finish.
|
||||
if (pendingSend.signal.aborted) {
|
||||
return { stopReason: 'cancelled' };
|
||||
|
|
@ -627,6 +764,7 @@ export class Session implements SessionContext {
|
|||
this.#startCronSchedulerIfNeeded();
|
||||
// Drain any cron prompts that queued while the prompt was active
|
||||
void this.#drainCronQueue();
|
||||
void this.#drainNotificationQueue();
|
||||
// Fire-and-forget follow-up suggestion generation. Best-effort UX
|
||||
// hint — must not block the prompt response. See
|
||||
// `#maybeEmitFollowupSuggestion` for guards and cancellation.
|
||||
|
|
@ -1277,7 +1415,13 @@ export class Session implements SessionContext {
|
|||
promptId,
|
||||
functionCalls,
|
||||
);
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
nextMessage = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
...toolResponseParts,
|
||||
...(await this.#drainMidTurnUserMessages()),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1544,6 +1688,68 @@ export class Session implements SessionContext {
|
|||
});
|
||||
}
|
||||
|
||||
async #drainMidTurnUserMessages(): Promise<Part[]> {
|
||||
if (this.midTurnDrainUnavailable) return [];
|
||||
|
||||
try {
|
||||
const response = await this.client.extMethod(
|
||||
MID_TURN_QUEUE_DRAIN_METHOD,
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
},
|
||||
);
|
||||
// A client may legally resolve with `result: null` (passed through
|
||||
// unwrapped by the ACP SDK); guard the object access so that doesn't
|
||||
// throw a TypeError and get misclassified as a transient drain error.
|
||||
const messages =
|
||||
response &&
|
||||
typeof response === 'object' &&
|
||||
Array.isArray(response['messages'])
|
||||
? response['messages'].filter(
|
||||
(message): message is string =>
|
||||
typeof message === 'string' && message.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
return messages.map((message) => {
|
||||
const part = {
|
||||
text: `\n[User message received during tool execution]: ${message}`,
|
||||
};
|
||||
this.config
|
||||
.getChatRecordingService()
|
||||
?.recordMidTurnUserMessage([part], message);
|
||||
return part;
|
||||
});
|
||||
} catch (error) {
|
||||
// The ACP SDK rejects with the raw JSON-RPC error object
|
||||
// (`{ code, message, data }`), which is not an `Error` instance, so
|
||||
// classify on the JSON-RPC code (-32601 = "Method not found") and fall
|
||||
// back to the message. Otherwise the one-shot latch never trips and every
|
||||
// tool batch keeps paying a failed `extMethod` round-trip all session.
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: error && typeof error === 'object' && 'message' in error
|
||||
? String((error as { message?: unknown }).message)
|
||||
: String(error);
|
||||
const errorCode =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
const isPermanentError =
|
||||
errorCode === -32601 || /method not found/i.test(errorMessage);
|
||||
|
||||
if (isPermanentError) {
|
||||
this.midTurnDrainUnavailable = true;
|
||||
}
|
||||
|
||||
debugLogger.warn(
|
||||
`Mid-turn queue drain ${isPermanentError ? 'permanently ' : ''}unavailable [session ${this.sessionId}]: ${errorMessage}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the cron scheduler if cron is enabled and jobs exist.
|
||||
* The scheduler runs in the background, pushing fired prompts into
|
||||
|
|
@ -1567,10 +1773,12 @@ export class Session implements SessionContext {
|
|||
* as a mutex to prevent concurrent access to the chat.
|
||||
*/
|
||||
async #drainCronQueue(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
if (this.cronProcessing) return;
|
||||
// Don't process cron while a user prompt is active — the queue will be
|
||||
// drained after the prompt completes (see end of prompt()).
|
||||
if (this.pendingPrompt) return;
|
||||
if (this.notificationProcessing) return;
|
||||
this.cronProcessing = true;
|
||||
|
||||
let resolveCompletion!: () => void;
|
||||
|
|
@ -1588,6 +1796,8 @@ export class Session implements SessionContext {
|
|||
resolveCompletion();
|
||||
this.cronCompletion = null;
|
||||
|
||||
void this.#drainNotificationQueue();
|
||||
|
||||
// Stop scheduler if all jobs were deleted during execution
|
||||
if (this.config.isCronEnabled()) {
|
||||
const scheduler = this.config.getCronScheduler();
|
||||
|
|
@ -1763,9 +1973,334 @@ export class Session implements SessionContext {
|
|||
);
|
||||
}
|
||||
|
||||
#registerBackgroundNotificationCallbacks(): void {
|
||||
const backgroundRegistry = this.config.getBackgroundTaskRegistry();
|
||||
backgroundRegistry.setNotificationCallback(
|
||||
(displayText, modelText, meta) => {
|
||||
this.#enqueueBackgroundNotification({
|
||||
displayText,
|
||||
modelText,
|
||||
taskId: meta.agentId,
|
||||
status: meta.status,
|
||||
kind: 'agent',
|
||||
toolUseId: meta.toolUseId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const monitorRegistry = this.config.getMonitorRegistry();
|
||||
monitorRegistry.setNotificationCallback((displayText, modelText, meta) => {
|
||||
if (meta.status === 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#enqueueBackgroundNotification({
|
||||
displayText,
|
||||
modelText,
|
||||
taskId: meta.monitorId,
|
||||
status: meta.status,
|
||||
kind: 'monitor',
|
||||
toolUseId: meta.toolUseId,
|
||||
});
|
||||
});
|
||||
|
||||
const shellRegistry = this.config.getBackgroundShellRegistry();
|
||||
shellRegistry.setNotificationCallback((displayText, modelText, meta) => {
|
||||
this.#enqueueBackgroundNotification({
|
||||
displayText,
|
||||
modelText,
|
||||
taskId: meta.shellId,
|
||||
status: meta.status,
|
||||
kind: 'shell',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#enqueueBackgroundNotification(item: BackgroundNotificationQueueItem): void {
|
||||
while (this.notificationQueue.length >= MAX_NOTIFICATION_QUEUE) {
|
||||
const evicted = this.notificationQueue.shift()!;
|
||||
debugLogger.warn(
|
||||
`Notification queue overflow: evicting task=${evicted.taskId} kind=${evicted.kind}`,
|
||||
);
|
||||
}
|
||||
this.notificationQueue.push(item);
|
||||
void this.#drainNotificationQueue();
|
||||
}
|
||||
|
||||
async #drainNotificationQueue(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
if (this.notificationProcessing) return;
|
||||
if (this.pendingPrompt || this.cronProcessing || this.cronAbortController) {
|
||||
return;
|
||||
}
|
||||
if (this.notificationQueue.length === 0) return;
|
||||
|
||||
this.notificationProcessing = true;
|
||||
let resolveCompletion!: () => void;
|
||||
this.notificationCompletion = new Promise<void>((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
});
|
||||
|
||||
try {
|
||||
while (this.notificationQueue.length > 0) {
|
||||
if (
|
||||
this.pendingPrompt ||
|
||||
this.cronProcessing ||
|
||||
this.cronAbortController
|
||||
) {
|
||||
break;
|
||||
}
|
||||
const item = this.notificationQueue.shift()!;
|
||||
await this.#executeBackgroundNotificationPrompt(item);
|
||||
}
|
||||
} finally {
|
||||
this.notificationProcessing = false;
|
||||
resolveCompletion();
|
||||
this.notificationCompletion = null;
|
||||
|
||||
void this.#drainCronQueue();
|
||||
|
||||
if (
|
||||
this.notificationQueue.length > 0 &&
|
||||
!this.pendingPrompt &&
|
||||
!this.cronProcessing &&
|
||||
!this.cronAbortController
|
||||
) {
|
||||
void this.#drainNotificationQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #executeBackgroundNotificationPrompt(
|
||||
item: BackgroundNotificationQueueItem,
|
||||
): Promise<void> {
|
||||
return Storage.runWithRuntimeBaseDir(
|
||||
this.runtimeBaseDir,
|
||||
this.config.getWorkingDir(),
|
||||
async () => {
|
||||
const ac = new AbortController();
|
||||
this.notificationAbortController = ac;
|
||||
const promptId =
|
||||
this.config.getSessionId() + '########notification' + Date.now();
|
||||
|
||||
try {
|
||||
await this.#emitBackgroundNotificationDisplay(item);
|
||||
|
||||
const notificationParts: Part[] = [{ text: item.modelText }];
|
||||
this.config
|
||||
.getChatRecordingService()
|
||||
?.recordNotification(notificationParts, item.displayText);
|
||||
|
||||
const notificationReminders =
|
||||
await this.#buildInitialSystemReminders();
|
||||
let nextMessage: Content | null = {
|
||||
role: 'user',
|
||||
parts: [...notificationReminders, ...notificationParts],
|
||||
};
|
||||
|
||||
while (nextMessage !== null) {
|
||||
if (ac.signal.aborted) {
|
||||
await this.#emitBackgroundNotificationEndTurn('cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null =
|
||||
null;
|
||||
let responseText = '';
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
const sendResult = await this.#sendMessageStreamWithAutoCompression(
|
||||
promptId,
|
||||
nextMessage.parts ?? [],
|
||||
ac.signal,
|
||||
);
|
||||
if (!sendResult.responseStream) {
|
||||
this.#preserveUnsentMessageHistory(
|
||||
nextMessage,
|
||||
sendResult.stopReason === 'cancelled',
|
||||
);
|
||||
await this.#emitBackgroundNotificationEndTurn(
|
||||
sendResult.stopReason,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const responseStream = sendResult.responseStream;
|
||||
nextMessage = null;
|
||||
|
||||
for await (const resp of responseStream) {
|
||||
if (ac.signal.aborted) {
|
||||
await this.#emitBackgroundNotificationEndTurn('cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
resp.type === StreamEventType.CHUNK &&
|
||||
resp.value.candidates &&
|
||||
resp.value.candidates.length > 0
|
||||
) {
|
||||
const candidate = resp.value.candidates[0];
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
if (!part.text) continue;
|
||||
if (part.thought) {
|
||||
await this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
responseText += part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
resp.type === StreamEventType.CHUNK &&
|
||||
resp.value.usageMetadata
|
||||
) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
}
|
||||
|
||||
if (
|
||||
resp.type === StreamEventType.CHUNK &&
|
||||
resp.value.functionCalls
|
||||
) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
}
|
||||
|
||||
if (responseText.length > 0) {
|
||||
await this.#emitBackgroundNotificationResponse(
|
||||
item,
|
||||
responseText,
|
||||
ac.signal,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.messageRewriter) {
|
||||
await this.messageRewriter.flushTurn(ac.signal);
|
||||
}
|
||||
|
||||
if (usageMetadata) {
|
||||
this.#recordPromptTokenCount(usageMetadata);
|
||||
const durationMs = Date.now() - streamStartTime;
|
||||
await this.messageEmitter.emitUsageMetadata(
|
||||
usageMetadata,
|
||||
'',
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts = await this.runToolCalls(
|
||||
ac.signal,
|
||||
promptId,
|
||||
functionCalls,
|
||||
);
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.messageRewriter) {
|
||||
await this.messageRewriter.waitForPendingRewrites();
|
||||
}
|
||||
|
||||
await this.#emitBackgroundNotificationEndTurn('end_turn');
|
||||
} catch (error) {
|
||||
if (ac.signal.aborted) {
|
||||
await this.#emitBackgroundNotificationEndTurn('cancelled');
|
||||
return;
|
||||
}
|
||||
debugLogger.error('Error processing background notification:', error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
try {
|
||||
await this.messageEmitter.emitAgentMessage(
|
||||
`[notification error] ${msg}`,
|
||||
);
|
||||
} catch (emitError) {
|
||||
debugLogger.error(
|
||||
'Failed to emit background notification error:',
|
||||
emitError,
|
||||
);
|
||||
} finally {
|
||||
await this.#emitBackgroundNotificationEndTurn('end_turn');
|
||||
}
|
||||
} finally {
|
||||
if (this.notificationAbortController === ac) {
|
||||
this.notificationAbortController = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async #emitBackgroundNotificationDisplay(
|
||||
item: BackgroundNotificationQueueItem,
|
||||
): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: item.displayText },
|
||||
_meta: {
|
||||
source: 'background_notification',
|
||||
qwenDiscreteMessage: true,
|
||||
backgroundTask: {
|
||||
taskId: item.taskId,
|
||||
status: item.status,
|
||||
kind: item.kind,
|
||||
toolUseId: item.toolUseId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #emitBackgroundNotificationResponse(
|
||||
item: BackgroundNotificationQueueItem,
|
||||
text: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const update: SessionUpdate = {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
_meta: {
|
||||
source: 'background_notification_response',
|
||||
qwenDiscreteMessage: true,
|
||||
backgroundTask: {
|
||||
taskId: item.taskId,
|
||||
status: item.status,
|
||||
kind: item.kind,
|
||||
toolUseId: item.toolUseId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (this.messageRewriter) {
|
||||
await this.messageRewriter.interceptUpdate(update, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendUpdate(update);
|
||||
}
|
||||
|
||||
async #emitBackgroundNotificationEndTurn(
|
||||
reason: PromptResponse['stopReason'],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.client.extNotification('_qwencode/end_turn', {
|
||||
sessionId: this.sessionId,
|
||||
reason,
|
||||
source: 'background_notification',
|
||||
});
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Background notification end-turn extNotification dropped: ${this.#formatError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||
try {
|
||||
const { availableCommands, availableSkills } =
|
||||
const { availableCommands, availableSkills, availableSkillDetails } =
|
||||
await buildAvailableCommandsSnapshot(this.config);
|
||||
|
||||
const update: SessionUpdate = {
|
||||
|
|
@ -1775,6 +2310,7 @@ export class Session implements SessionContext {
|
|||
? {
|
||||
_meta: {
|
||||
availableSkills,
|
||||
...(availableSkillDetails ? { availableSkillDetails } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
@ -2067,16 +2603,6 @@ export class Session implements SessionContext {
|
|||
async #buildInitialSystemReminders(): Promise<Part[]> {
|
||||
const reminders: Part[] = [];
|
||||
|
||||
const hasAgentTool = await this.config
|
||||
.getToolRegistry()
|
||||
.ensureTool(ToolNames.AGENT);
|
||||
const subagents = (await this.config.getSubagentManager().listSubagents())
|
||||
.filter((subagent) => subagent.level !== 'builtin')
|
||||
.map((subagent) => subagent.name);
|
||||
if (hasAgentTool && subagents.length > 0) {
|
||||
reminders.push({ text: getSubagentSystemReminder(subagents) });
|
||||
}
|
||||
|
||||
if (this.config.getApprovalMode() === ApprovalMode.PLAN) {
|
||||
reminders.push({
|
||||
text: getPlanModeSystemReminder(this.config.getSdkMode?.()),
|
||||
|
|
@ -2189,7 +2715,7 @@ export class Session implements SessionContext {
|
|||
if (pm && !(await pm.isToolEnabled(toolName))) {
|
||||
return earlyErrorResponse(
|
||||
new Error(
|
||||
`Qwen Code requires permission to use "${toolName}", but that permission was declined.`,
|
||||
`Tool "${toolName}" is disabled.`,
|
||||
),
|
||||
toolName,
|
||||
);
|
||||
|
|
@ -2605,7 +3131,15 @@ export class Session implements SessionContext {
|
|||
const execSpan = startToolExecutionSpan();
|
||||
let toolResult: ToolResult;
|
||||
try {
|
||||
toolResult = await invocation.execute(abortSignal);
|
||||
const sleepInhibitorHandle = acquireSleepInhibitor(
|
||||
this.config,
|
||||
`Qwen Code is executing tool ${toolName}`,
|
||||
);
|
||||
try {
|
||||
toolResult = await invocation.execute(abortSignal);
|
||||
} finally {
|
||||
sleepInhibitorHandle.release();
|
||||
}
|
||||
const aborted = abortSignal.aborted;
|
||||
endToolExecutionSpan(execSpan, {
|
||||
success: !toolResult.error && !aborted,
|
||||
|
|
|
|||
|
|
@ -116,13 +116,8 @@ describe('Session.pendingWorktreeNotice', () => {
|
|||
}),
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTool: vi.fn(),
|
||||
// Called on every prompt() via #buildInitialSystemReminders
|
||||
ensureTool: vi.fn().mockResolvedValue(true),
|
||||
}),
|
||||
// Called on every prompt() to check subagent system reminders
|
||||
getSubagentManager: vi.fn().mockReturnValue({
|
||||
listSubagents: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
getFileService: vi.fn().mockReturnValue({
|
||||
shouldGitIgnoreFile: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
|
|
@ -140,6 +135,17 @@ describe('Session.pendingWorktreeNotice', () => {
|
|||
// Added on main after the test was written; Session.prompt's stop-hook
|
||||
// loop reads this so the mock has to provide it.
|
||||
getStopHookBlockingCap: vi.fn().mockReturnValue(0),
|
||||
// Session constructor registers background-notification callbacks on
|
||||
// these registries; provide no-op stubs so construction succeeds.
|
||||
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
|
||||
setNotificationCallback: vi.fn(),
|
||||
}),
|
||||
getMonitorRegistry: vi.fn().mockReturnValue({
|
||||
setNotificationCallback: vi.fn(),
|
||||
}),
|
||||
getBackgroundShellRegistry: vi.fn().mockReturnValue({
|
||||
setNotificationCallback: vi.fn(),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
mockClient = {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,22 @@ describe('MessageEmitter', () => {
|
|||
content: { type: 'text', text: 'I can help you with that.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should include subagent parent metadata when provided', async () => {
|
||||
await emitter.emitAgentMessage('Subagent progress', undefined, {
|
||||
parentToolCallId: 'agent-parent-1',
|
||||
subagentType: 'general-purpose',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Subagent progress' },
|
||||
_meta: {
|
||||
parentToolCallId: 'agent-parent-1',
|
||||
subagentType: 'general-purpose',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAgentThought', () => {
|
||||
|
|
@ -77,6 +93,22 @@ describe('MessageEmitter', () => {
|
|||
content: { type: 'text', text: 'Let me think about this...' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should include subagent parent metadata when provided', async () => {
|
||||
await emitter.emitAgentThought('Subagent thought', undefined, {
|
||||
parentToolCallId: 'agent-parent-1',
|
||||
subagentType: 'general-purpose',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: 'Subagent thought' },
|
||||
_meta: {
|
||||
parentToolCallId: 'agent-parent-1',
|
||||
subagentType: 'general-purpose',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitMessage', () => {
|
||||
|
|
|
|||
|
|
@ -202,6 +202,52 @@ describe('MessageRewriteMiddleware', () => {
|
|||
expect(meta['rewritten']).toBe(true);
|
||||
expect(meta['turnIndex']).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves background discrete metadata on rewritten messages', async () => {
|
||||
const { middleware, mockSendUpdate } = createMiddleware('message');
|
||||
|
||||
await middleware.interceptUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'background response' },
|
||||
_meta: {
|
||||
source: 'background_notification_response',
|
||||
qwenDiscreteMessage: true,
|
||||
backgroundTask: {
|
||||
taskId: 'monitor-1',
|
||||
status: 'completed',
|
||||
kind: 'monitor',
|
||||
toolUseId: 'tool-1',
|
||||
},
|
||||
customTraceId: 'trace-1',
|
||||
},
|
||||
} as unknown as SessionUpdate);
|
||||
|
||||
await middleware.flushTurn();
|
||||
await middleware.waitForPendingRewrites();
|
||||
|
||||
const rewriteCall = mockSendUpdate.mock.calls.find(
|
||||
(call: unknown[]) =>
|
||||
(
|
||||
(call[0] as Record<string, unknown>)['_meta'] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.['rewritten'] === true,
|
||||
);
|
||||
expect(rewriteCall).toBeDefined();
|
||||
expect((rewriteCall![0] as Record<string, unknown>)['_meta']).toEqual({
|
||||
source: 'background_notification_response',
|
||||
qwenDiscreteMessage: true,
|
||||
backgroundTask: {
|
||||
taskId: 'monitor-1',
|
||||
status: 'completed',
|
||||
kind: 'monitor',
|
||||
toolUseId: 'tool-1',
|
||||
},
|
||||
customTraceId: 'trace-1',
|
||||
rewritten: true,
|
||||
turnIndex: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeoutMs config', () => {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ const debugLogger = createDebugLogger('MESSAGE_REWRITE');
|
|||
* 4. Rewritten text is emitted as agent_message_chunk with _meta.rewritten=true
|
||||
*/
|
||||
const DEFAULT_REWRITE_TIMEOUT_MS = 30_000;
|
||||
// Intentionally empty: earlier revisions stripped backgroundTask/source/
|
||||
// qwenDiscreteMessage from rewritten messages, but those keys are required
|
||||
// downstream for discrete-message routing (see qwenSessionUpdateHandler).
|
||||
// Kept as an explicit extension point — add a key here to drop it from a
|
||||
// rewritten message's _meta.
|
||||
const REWRITE_META_EXCLUDED_KEYS = new Set<string>([]);
|
||||
|
||||
export class MessageRewriteMiddleware {
|
||||
private readonly turnBuffer: TurnBuffer;
|
||||
|
|
@ -35,6 +41,7 @@ export class MessageRewriteMiddleware {
|
|||
private readonly target: MessageRewriteConfig['target'];
|
||||
private readonly timeoutMs: number;
|
||||
private turnIndex = 0;
|
||||
private turnMeta: Record<string, unknown> | undefined;
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
|
|
@ -82,15 +89,22 @@ export class MessageRewriteMiddleware {
|
|||
await this.sendUpdate(update);
|
||||
|
||||
// Accumulate for turn-end rewriting
|
||||
let didAccumulate = false;
|
||||
if (updateType === 'agent_thought_chunk') {
|
||||
if (this.target === 'thought' || this.target === 'all') {
|
||||
this.turnBuffer.appendThought(text);
|
||||
didAccumulate = true;
|
||||
}
|
||||
} else if (updateType === 'agent_message_chunk') {
|
||||
if (this.target === 'message' || this.target === 'all') {
|
||||
this.turnBuffer.appendMessage(text);
|
||||
didAccumulate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (didAccumulate) {
|
||||
this.captureTurnMeta(updateRecord);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pending rewrite promises — all must settle before session exits */
|
||||
|
|
@ -108,6 +122,8 @@ export class MessageRewriteMiddleware {
|
|||
*/
|
||||
async flushTurn(signal?: AbortSignal): Promise<void> {
|
||||
const content = this.turnBuffer.flush();
|
||||
const turnMeta = this.turnMeta;
|
||||
this.turnMeta = undefined;
|
||||
if (!content) return;
|
||||
|
||||
this.turnIndex++;
|
||||
|
|
@ -137,6 +153,7 @@ export class MessageRewriteMiddleware {
|
|||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: rewritten },
|
||||
_meta: {
|
||||
...turnMeta,
|
||||
rewritten: true,
|
||||
turnIndex: turnIdx,
|
||||
},
|
||||
|
|
@ -150,6 +167,26 @@ export class MessageRewriteMiddleware {
|
|||
);
|
||||
}
|
||||
|
||||
private captureTurnMeta(update: Record<string, unknown>): void {
|
||||
const meta = update['_meta'];
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeMeta = Object.fromEntries(
|
||||
Object.entries(meta as Record<string, unknown>).filter(
|
||||
([key]) => !REWRITE_META_EXCLUDED_KEYS.has(key),
|
||||
),
|
||||
);
|
||||
|
||||
if (Object.keys(safeMeta).length === 0) return;
|
||||
|
||||
this.turnMeta = {
|
||||
...this.turnMeta,
|
||||
...safeMeta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending rewrites to complete.
|
||||
* Call this before session ends to ensure all rewrites are flushed.
|
||||
|
|
|
|||
|
|
@ -43,6 +43,56 @@ describe('extensionConsentString', () => {
|
|||
expect(result).toContain('Installing extension "test-extension".');
|
||||
});
|
||||
|
||||
it('should include description when present', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
description: 'A helpful test extension',
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
expect(result).toContain('A helpful test extension');
|
||||
});
|
||||
|
||||
it('should strip ANSI escape codes from description', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
description: '\x1b[31mMalicious\x1b[0m description',
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
expect(result).toContain('Malicious description');
|
||||
expect(result).not.toContain('\x1b[31m');
|
||||
});
|
||||
|
||||
it('should handle non-string description gracefully', () => {
|
||||
const config = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
description: 123,
|
||||
} as unknown as ExtensionConfig;
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
expect(result).not.toContain('123');
|
||||
});
|
||||
|
||||
it('should not include description when absent', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const result = extensionConsentString(config);
|
||||
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toContain('Installing extension "test-extension".');
|
||||
expect(lines[1]).toContain('Extensions may introduce unexpected behavior');
|
||||
});
|
||||
|
||||
it('should include warning message', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-extension',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||
import chalk from 'chalk';
|
||||
import prompts from 'prompts';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
|
||||
|
||||
|
|
@ -164,6 +165,9 @@ export function extensionConsentString(
|
|||
output.push(
|
||||
t('Installing extension "{{name}}".', { name: extensionConfig.name }),
|
||||
);
|
||||
if (typeof extensionConfig.description === 'string' && extensionConfig.description) {
|
||||
output.push(stripAnsi(extensionConfig.description));
|
||||
}
|
||||
output.push(
|
||||
t(
|
||||
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"name": "agent-example",
|
||||
"description": "Example extension that provides a custom subagent",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"name": "commands-example",
|
||||
"description": "Example extension that provides custom slash commands",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"name": "context-example",
|
||||
"description": "Example extension that provides additional context via QWEN.md",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "mcp-server-example",
|
||||
"description": "Example extension that provides an MCP server",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"name": "skills-example",
|
||||
"description": "Example extension that provides custom skills",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,70 @@ describe('extensionToOutputString', () => {
|
|||
expect(resultWithoutInline).toEqual(resultWithInlineFalse);
|
||||
});
|
||||
|
||||
it('should include description when present', () => {
|
||||
const extension = createMockExtension({
|
||||
config: {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
description: 'A helpful test extension',
|
||||
},
|
||||
});
|
||||
const result = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
);
|
||||
|
||||
expect(result).toContain('Description:');
|
||||
expect(result).toContain('A helpful test extension');
|
||||
});
|
||||
|
||||
it('should strip ANSI escape codes from description', () => {
|
||||
const extension = createMockExtension({
|
||||
config: {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
description: '\x1b[31mMalicious\x1b[0m description',
|
||||
},
|
||||
});
|
||||
const result = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
);
|
||||
|
||||
expect(result).toContain('Malicious description');
|
||||
expect(result).not.toContain('\x1b[31m');
|
||||
});
|
||||
|
||||
it('should handle non-string description gracefully', () => {
|
||||
const extension = createMockExtension({
|
||||
config: {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
description: 42,
|
||||
},
|
||||
});
|
||||
const result = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
);
|
||||
|
||||
expect(result).not.toContain('Description:');
|
||||
});
|
||||
|
||||
it('should not include description line when absent', () => {
|
||||
const extension = createMockExtension();
|
||||
const result = extensionToOutputString(
|
||||
extension,
|
||||
mockExtensionManager,
|
||||
'/workspace',
|
||||
);
|
||||
|
||||
expect(result).not.toContain('Description:');
|
||||
});
|
||||
|
||||
it('should redact URL credentials in install source output', () => {
|
||||
const extension = createMockExtension({
|
||||
installMetadata: {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||
import * as os from 'node:os';
|
||||
import chalk from 'chalk';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export async function getExtensionManager(): Promise<ExtensionManager> {
|
||||
|
|
@ -53,6 +54,9 @@ export function extensionToOutputString(
|
|||
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`;
|
||||
if (typeof extension.config.description === 'string' && extension.config.description) {
|
||||
output += `\n ${t('Description:')} ${stripAnsi(extension.config.description)}`;
|
||||
}
|
||||
output += `\n ${t('Path:')} ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n ${t('Source:')} ${redactUrlCredentials(extension.installMetadata.source)} (${t('Type:')} ${extension.installMetadata.type})`;
|
||||
|
|
|
|||
|
|
@ -29,10 +29,13 @@ vi.mock('fs/promises', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('os', () => {
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('os')>();
|
||||
const homedir = vi.fn(() => '/home/user');
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
homedir,
|
||||
},
|
||||
homedir,
|
||||
|
|
@ -247,7 +250,7 @@ describe('mcp add command', () => {
|
|||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as (code?: number) => never);
|
||||
}) as typeof process.exit);
|
||||
|
||||
await expect(
|
||||
parser.parseAsync(`add --scope project ${serverName} ${command}`),
|
||||
|
|
|
|||
|
|
@ -199,23 +199,6 @@ describe('Configuration Integration Tests', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Checkpointing Configuration', () => {
|
||||
it('should enable checkpointing when the setting is true', async () => {
|
||||
const configParams: ConfigParameters = {
|
||||
cwd: '/tmp',
|
||||
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
|
||||
embeddingModel: 'test-embedding-model',
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
checkpointing: true,
|
||||
};
|
||||
|
||||
const config = new Config(configParams);
|
||||
|
||||
expect(config.getCheckpointingEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extension Context Files', () => {
|
||||
it('should have an empty array for extension context files by default', () => {
|
||||
const configParams: ConfigParameters = {
|
||||
|
|
@ -415,3 +398,50 @@ describe('Configuration Integration Tests', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDisabledSkillNamesProvider', async () => {
|
||||
const { buildDisabledSkillNamesProvider } = await import('./config.js');
|
||||
|
||||
function fakeSettings(disabled: unknown) {
|
||||
return { merged: { skills: { disabled } } } as never;
|
||||
}
|
||||
|
||||
it('returns a normalized set from a normal array', () => {
|
||||
const provider = buildDisabledSkillNamesProvider(
|
||||
fakeSettings(['Foo', ' BAR ', 'baz']),
|
||||
);
|
||||
const result = provider();
|
||||
expect(result).toEqual(new Set(['foo', 'bar', 'baz']));
|
||||
});
|
||||
|
||||
it('returns empty set for non-array values (string)', () => {
|
||||
const provider = buildDisabledSkillNamesProvider(fakeSettings('all'));
|
||||
expect(provider()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('returns empty set for non-array values (number)', () => {
|
||||
const provider = buildDisabledSkillNamesProvider(fakeSettings(42));
|
||||
expect(provider()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('returns empty set for null/undefined', () => {
|
||||
const provider = buildDisabledSkillNamesProvider(fakeSettings(null));
|
||||
expect(provider()).toEqual(new Set());
|
||||
const provider2 = buildDisabledSkillNamesProvider(fakeSettings(undefined));
|
||||
expect(provider2()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('filters non-string elements from a mixed-type array', () => {
|
||||
const provider = buildDisabledSkillNamesProvider(
|
||||
fakeSettings([42, null, 'valid', undefined, true, ' TRIMMED ']),
|
||||
);
|
||||
expect(provider()).toEqual(new Set(['valid', 'trimmed']));
|
||||
});
|
||||
|
||||
it('excludes empty-after-trim strings', () => {
|
||||
const provider = buildDisabledSkillNamesProvider(
|
||||
fakeSettings([' ', '', 'keep']),
|
||||
);
|
||||
expect(provider()).toEqual(new Set(['keep']));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -792,6 +792,27 @@ describe('parseArguments', () => {
|
|||
expect(argv.approvalMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept desktop as a channel identifier', async () => {
|
||||
process.argv = ['node', 'script.js', '--channel', 'desktop'];
|
||||
const argv = await parseArguments();
|
||||
expect(argv.channel).toBe('desktop');
|
||||
});
|
||||
|
||||
it('should default ACP mode to the ACP channel when no channel is provided', async () => {
|
||||
process.argv = ['node', 'script.js', '--acp'];
|
||||
const argv = await parseArguments();
|
||||
expect(argv.channel).toBe('ACP');
|
||||
});
|
||||
|
||||
it('keeps an explicit --channel when combined with --acp (the desktop invocation)', async () => {
|
||||
process.argv = ['node', 'script.js', '--acp', '--channel', 'desktop'];
|
||||
const argv = await parseArguments();
|
||||
// The `!result['channel']` guard must not override an explicitly provided
|
||||
// channel with the ACP default.
|
||||
expect(argv.channel).toBe('desktop');
|
||||
expect(argv.acp).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid --approval-mode values', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
|
||||
|
||||
|
|
@ -927,6 +948,29 @@ describe('loadCliConfig', () => {
|
|||
expect(config.getIncludePartialMessages()).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable runtime sleep prevention by default', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv);
|
||||
|
||||
expect(config.getPreventSystemSleepEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should propagate runtime sleep prevention setting', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
general: {
|
||||
preventSystemSleep: false,
|
||||
},
|
||||
},
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getPreventSystemSleepEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should fork and load a new session when --resume is combined with --fork-session', async () => {
|
||||
const sourceSessionId = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const sourceData = {
|
||||
|
|
@ -2070,7 +2114,7 @@ describe('loadCliConfig with --mcp-config', () => {
|
|||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(baseSettings, argv);
|
||||
|
||||
const mcpServers = config.getMcpServers();
|
||||
const mcpServers = config.getMcpServers()!;
|
||||
expect(mcpServers['cli-server']).toEqual({
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
|
|
@ -2089,7 +2133,7 @@ describe('loadCliConfig with --mcp-config', () => {
|
|||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(baseSettings, argv);
|
||||
|
||||
expect(config.getMcpServers()['direct-server']).toEqual({
|
||||
expect(config.getMcpServers()!['direct-server']).toEqual({
|
||||
url: 'http://localhost:8080',
|
||||
});
|
||||
});
|
||||
|
|
@ -2103,7 +2147,7 @@ describe('loadCliConfig with --mcp-config', () => {
|
|||
const config = await loadCliConfig(baseSettings, argv);
|
||||
|
||||
// CLI config should override settings
|
||||
expect(config.getMcpServers()['settings-server']).toEqual({
|
||||
expect(config.getMcpServers()!['settings-server']).toEqual({
|
||||
url: 'http://localhost:8888',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
FileDiscoveryService,
|
||||
getAllGeminiMdFilenames,
|
||||
loadServerHierarchicalMemory,
|
||||
type LoadServerHierarchicalMemoryOptions,
|
||||
type LoadServerHierarchicalMemoryResponse,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
resolveTelemetrySettings,
|
||||
|
|
@ -37,7 +38,7 @@ import {
|
|||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import { normalizeDisabledToolList } from './normalizeDisabledTools.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import type { LoadedSettings, Settings } from './settings.js';
|
||||
import { loadSettings, SettingScope } from './settings.js';
|
||||
import {
|
||||
resolveCliGenerationConfig,
|
||||
|
|
@ -134,7 +135,6 @@ export interface CliArgs {
|
|||
bare: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
telemetry: boolean | undefined;
|
||||
checkpointing: boolean | undefined;
|
||||
telemetryTarget: string | undefined;
|
||||
telemetryOtlpEndpoint: string | undefined;
|
||||
telemetryOtlpProtocol: string | undefined;
|
||||
|
|
@ -667,11 +667,6 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
description:
|
||||
'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), auto (LLM classifier auto-approves safe actions, blocks risky ones), yolo (auto-approve all tools)',
|
||||
})
|
||||
.option('checkpointing', {
|
||||
type: 'boolean',
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
})
|
||||
.option('acp', {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
|
|
@ -696,8 +691,8 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
description: 'Channel identifier (VSCode, ACP, SDK, CI)',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI', 'desktop'],
|
||||
description: 'Channel identifier (VSCode, ACP, SDK, CI, desktop)',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
|
|
@ -912,10 +907,6 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
'sandbox-image',
|
||||
'Use the "tools.sandboxImage" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'checkpointing',
|
||||
'Use the "general.checkpointing.enabled" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'prompt',
|
||||
'Use the positional prompt instead. This flag will be removed in a future version.',
|
||||
|
|
@ -1131,6 +1122,7 @@ export async function loadHierarchicalGeminiMemory(
|
|||
folderTrust: boolean,
|
||||
memoryImportFormat: 'flat' | 'tree' = 'tree',
|
||||
contextRuleExcludes: string[] = [],
|
||||
options: LoadServerHierarchicalMemoryOptions = {},
|
||||
): Promise<LoadServerHierarchicalMemoryResponse> {
|
||||
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
|
||||
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
|
||||
|
|
@ -1150,6 +1142,7 @@ export async function loadHierarchicalGeminiMemory(
|
|||
folderTrust,
|
||||
memoryImportFormat,
|
||||
contextRuleExcludes,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1302,6 +1295,45 @@ function parseMcpConfig(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the live-read closure for `Config.getDisabledSkillNames()`.
|
||||
*
|
||||
* The returned function reads through `loadedSettings.merged` on every
|
||||
* call, so `LoadedSettings.setValue('skills.disabled', ...)` invocations
|
||||
* are reflected without rebuilding `Config`. The closure is over the
|
||||
* `LoadedSettings` instance, NOT over its `.merged` snapshot — that
|
||||
* distinction matters because `LoadedSettings.setValue` replaces the
|
||||
* internal `_merged` object on every call. A closure over `.merged` would
|
||||
* stay frozen at construction time.
|
||||
*
|
||||
* Use this from every `loadCliConfig` call site (interactive entry, ACP
|
||||
* session start, etc.) so all surfaces — `<available_skills>` in the
|
||||
* model description, `/skill-name` slash commands, `/skills` listing and
|
||||
* completion — agree on which skills are currently disabled.
|
||||
*/
|
||||
export function buildDisabledSkillNamesProvider(
|
||||
loadedSettings: LoadedSettings,
|
||||
): () => ReadonlySet<string> {
|
||||
return () => {
|
||||
// Defensive: settings.json is user-editable, so the `disabled` slot
|
||||
// could be a non-array (e.g. `"disabled": "all"` or `"disabled": 42`)
|
||||
// OR an array containing non-strings (e.g. `[42, null]`). The `??`
|
||||
// fallback only catches `null`/`undefined`, so we MUST also guard
|
||||
// against non-array values before `.filter()` — otherwise calling
|
||||
// `"all".filter` throws `TypeError: list.filter is not a function`
|
||||
// and bricks every skill invocation (validateToolParams + execute
|
||||
// both call this provider without a try/catch).
|
||||
const raw = loadedSettings.merged.skills?.disabled;
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
return new Set(
|
||||
list
|
||||
.filter((n): n is string => typeof n === 'string')
|
||||
.map((n) => n.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
settings: Settings,
|
||||
argv: CliArgs,
|
||||
|
|
@ -1315,6 +1347,21 @@ export async function loadCliConfig(
|
|||
userHooks?: Record<string, unknown>;
|
||||
projectHooks?: Record<string, unknown>;
|
||||
},
|
||||
/**
|
||||
* Live-read provider for the set of disabled skill names. Forwarded to
|
||||
* `ConfigParameters` so that `Config.getDisabledSkillNames()` reflects
|
||||
* `LoadedSettings.merged.skills?.disabled` even after `setValue`
|
||||
* mutations within the same process.
|
||||
*
|
||||
* Callers MUST close over the live `LoadedSettings` instance, NOT over
|
||||
* the `settings: Settings` snapshot passed as the first argument here —
|
||||
* `LoadedSettings.setValue` replaces `_merged`, so any closure over a
|
||||
* snapshot would only see cold data and the dialog/subcommand toggles
|
||||
* would not take effect on the model side. Use
|
||||
* `buildDisabledSkillNamesProvider(loadedSettings)` to construct it
|
||||
* correctly.
|
||||
*/
|
||||
disabledSkillNamesProvider?: () => ReadonlySet<string>,
|
||||
): Promise<Config> {
|
||||
const debugMode = isDebugMode(argv);
|
||||
const bareMode = isBareMode(argv.bare);
|
||||
|
|
@ -1754,6 +1801,7 @@ export async function loadCliConfig(
|
|||
excludeTools: mergedDeny,
|
||||
disabledSlashCommands:
|
||||
disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined,
|
||||
disabledSkillNamesProvider,
|
||||
disabledTools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
// New unified permissions (PermissionManager source of truth).
|
||||
permissions: {
|
||||
|
|
@ -1804,8 +1852,6 @@ export async function loadCliConfig(
|
|||
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
|
||||
clearContextOnIdle: settings.context?.clearContextOnIdle,
|
||||
fileFiltering: settings.context?.fileFiltering,
|
||||
checkpointing:
|
||||
argv.checkpointing || settings.general?.checkpointing?.enabled,
|
||||
plansDirectory: settings.plansDirectory,
|
||||
proxy:
|
||||
argv.proxy ||
|
||||
|
|
@ -1852,6 +1898,7 @@ export async function loadCliConfig(
|
|||
useRipgrep: settings.tools?.useRipgrep,
|
||||
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
|
||||
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
preventSystemSleep: settings.general?.preventSystemSleep ?? true,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? true,
|
||||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
|
|
|
|||
|
|
@ -3271,6 +3271,38 @@ describe('Settings Loading and Merging', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('strips a runtime snapshot prefix before persisting model.name', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(() => '{}');
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'model.name',
|
||||
'$runtime|openai|qwen3.6-27b-autoround',
|
||||
);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.at(-1);
|
||||
const writtenContent = JSON.parse(String(writeCall?.[1]));
|
||||
expect(writtenContent.model.name).toBe('qwen3.6-27b-autoround');
|
||||
});
|
||||
|
||||
it('collapses stacked runtime snapshot prefixes before persisting model.name', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(() => '{}');
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'model.name',
|
||||
'$runtime|openai|$runtime|openai|qwen3.6-27b-autoround',
|
||||
);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.at(-1);
|
||||
const writtenContent = JSON.parse(String(writeCall?.[1]));
|
||||
expect(writtenContent.model.name).toBe('qwen3.6-27b-autoround');
|
||||
});
|
||||
|
||||
it('persists removed MCP servers when replacing the top-level mcpServers object', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
getErrorMessage,
|
||||
Storage,
|
||||
createDebugLogger,
|
||||
stripRuntimeSnapshotPrefix,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
|
|
@ -464,6 +465,10 @@ export class LoadedSettings {
|
|||
}
|
||||
|
||||
setValue(scope: SettingScope, key: string, value: unknown): void {
|
||||
// Never persist a runtime snapshot ID to model.name (it re-wraps on restart).
|
||||
if (key === 'model.name' && typeof value === 'string') {
|
||||
value = stripRuntimeSnapshotPrefix(value);
|
||||
}
|
||||
const settingsFile = this.forScope(scope);
|
||||
setNestedPropertySafe(settingsFile.settings, key, value);
|
||||
setNestedPropertySafe(settingsFile.originalSettings, key, value);
|
||||
|
|
|
|||
|
|
@ -84,17 +84,6 @@ describe('SettingsSchema', () => {
|
|||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have checkpointing nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.checkpointing.properties
|
||||
?.enabled,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.checkpointing.properties
|
||||
?.enabled.type,
|
||||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have fileFiltering nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
|
|
@ -218,9 +207,6 @@ describe('SettingsSchema', () => {
|
|||
expect(getSettingsSchema().ui.properties.customThemes.showInDialog).toBe(
|
||||
false,
|
||||
); // Managed via theme editor
|
||||
expect(
|
||||
getSettingsSchema().general.properties.checkpointing.showInDialog,
|
||||
).toBe(false); // Experimental feature
|
||||
expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe(
|
||||
false,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -453,26 +453,6 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
},
|
||||
checkpointing: {
|
||||
type: 'object',
|
||||
label: 'Checkpointing',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Session checkpointing settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Checkpointing',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable session checkpointing for recovery',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
debugKeystrokeLogging: {
|
||||
type: 'boolean',
|
||||
label: 'Debug Keystroke Logging',
|
||||
|
|
@ -527,6 +507,19 @@ const SETTINGS_SCHEMA = {
|
|||
'Play terminal bell sound when response completes or needs approval.',
|
||||
showInDialog: true,
|
||||
},
|
||||
preventSystemSleep: {
|
||||
type: 'boolean',
|
||||
label: 'Prevent System Sleep While Running',
|
||||
category: 'General',
|
||||
// Read once at startup via Config.preventSystemSleep (a readonly field
|
||||
// captured in loadCliConfig), so a runtime toggle only takes effect
|
||||
// after restart.
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Prevent the system from sleeping while Qwen Code is streaming a model response or executing tools. Idle prompt time and permission prompts do not inhibit sleep.',
|
||||
showInDialog: true,
|
||||
},
|
||||
chatRecording: {
|
||||
type: 'boolean',
|
||||
label: 'Chat Recording',
|
||||
|
|
@ -857,6 +850,16 @@ const SETTINGS_SCHEMA = {
|
|||
'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).',
|
||||
showInDialog: true,
|
||||
},
|
||||
compactInline: {
|
||||
type: 'boolean',
|
||||
label: 'Compact Inline',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Compact tool display within each group instead of merging across groups. Requires compactMode to be enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useTerminalBuffer: {
|
||||
type: 'boolean',
|
||||
label: 'Virtualized History (reduces flicker on long sessions)',
|
||||
|
|
@ -1078,7 +1081,7 @@ const SETTINGS_SCHEMA = {
|
|||
properties: {
|
||||
propagateTraceContext: {
|
||||
description:
|
||||
"Requires `telemetry.enabled: true`. Inject W3C `traceparent` header on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...). Default: false — trace context stays internal to the operator's OTLP collector and is NOT written onto third-party request streams. Set true only when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope). Client HTTP spans are still emitted in either case; this flag only governs the wire `traceparent` header.",
|
||||
"Requires `telemetry.enabled: true`. Inject W3C `traceparent` on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...) AND as a `TRACEPARENT` environment variable in shell child processes (Bash tool, hooks, monitor). When enabled, any existing `TRACEPARENT` in the parent environment is overwritten with qwen-code's own trace context. Default: false — trace context stays internal to the operator's OTLP collector. Set true when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope) or need shell scripts / CLI tools to participate in distributed tracing.",
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
|
|
@ -1513,6 +1516,35 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
type: 'object',
|
||||
label: 'Skills',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description:
|
||||
'Configuration for skills (SKILL.md-based capabilities) exposed to ' +
|
||||
'the model.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Skills',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Skill names to hide. Matched case-insensitively against the skill ' +
|
||||
'name. Hidden skills do not appear in <available_skills> or as ' +
|
||||
'/<name> slash commands. UNION-merged across systemDefaults/user/' +
|
||||
'workspace/system scopes — workspace cannot remove entries defined ' +
|
||||
'in higher scopes.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
permissions: {
|
||||
type: 'object',
|
||||
label: 'Permissions',
|
||||
|
|
@ -1568,6 +1600,72 @@ const SETTINGS_SCHEMA = {
|
|||
description: 'Settings consumed by the AUTO approval mode classifier.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
classifier: {
|
||||
type: 'object',
|
||||
label: 'Auto Mode Classifier',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Runtime controls for the AUTO approval mode classifier.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
timeouts: {
|
||||
type: 'object',
|
||||
label: 'Auto Mode Classifier Timeouts',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Timeouts for the two AUTO classifier stages, in milliseconds.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
stage1Ms: {
|
||||
type: 'number',
|
||||
label: 'Auto Mode Stage 1 Timeout',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as number | undefined,
|
||||
description:
|
||||
'Timeout in milliseconds for the fast stage-1 AUTO classifier.',
|
||||
showInDialog: false,
|
||||
},
|
||||
stage2Ms: {
|
||||
type: 'number',
|
||||
label: 'Auto Mode Stage 2 Timeout',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as number | undefined,
|
||||
description:
|
||||
'Timeout in milliseconds for the stage-2 AUTO classifier review.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
thinking: {
|
||||
type: 'object',
|
||||
label: 'Auto Mode Classifier Thinking',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Provider/API-level thinking controls for the AUTO classifier.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
stage2Enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Mode Stage 2 Thinking',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Whether stage 2 may use provider/API-level thinking. Stage 1 always keeps thinking disabled.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
hints: {
|
||||
type: 'object',
|
||||
label: 'Classifier Hints',
|
||||
|
|
@ -1589,14 +1687,45 @@ const SETTINGS_SCHEMA = {
|
|||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
deny: {
|
||||
softDeny: {
|
||||
type: 'array',
|
||||
label: 'Auto Mode Deny Hints',
|
||||
label: 'Auto Mode Soft-Deny Hints',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Natural-language descriptions of actions AUTO mode should block.',
|
||||
'Natural-language descriptions of destructive / irreversible ' +
|
||||
'actions AUTO mode should block unless the user explicitly ' +
|
||||
'authorised that exact action and scope.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
hardDeny: {
|
||||
type: 'array',
|
||||
label: 'Auto Mode Hard-Deny Hints',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Natural-language descriptions of security-boundary actions ' +
|
||||
'the AUTO classifier must block even when an autoMode ' +
|
||||
'allow hint or recent user request would normally ' +
|
||||
'authorise them. Does not override permissions.allow; use ' +
|
||||
'permissions.deny for deterministic hard permission rules.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
deny: {
|
||||
type: 'array',
|
||||
label: 'Auto Mode Deny Hints (legacy)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Deprecated alias for `softDeny`. Entries here are merged ' +
|
||||
'into the SOFT BLOCK user section so existing settings keep ' +
|
||||
'working; new configurations should use `softDeny` or ' +
|
||||
'`hardDeny` instead.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
|
|
@ -2281,6 +2410,18 @@ const SETTINGS_SCHEMA = {
|
|||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
items: HOOK_DEFINITION_ITEMS,
|
||||
},
|
||||
UserPromptExpansion: {
|
||||
type: 'array',
|
||||
label: 'Prompt Expansion Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute when a slash command expands into a prompt.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
items: HOOK_DEFINITION_ITEMS,
|
||||
},
|
||||
Stop: {
|
||||
type: 'array',
|
||||
label: 'After Agent Hooks',
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ vi.mock('./config/config.js', () => ({
|
|||
} as unknown as Config),
|
||||
parseArguments: vi.fn().mockResolvedValue({}),
|
||||
isDebugMode: vi.fn(() => false),
|
||||
buildDisabledSkillNamesProvider: vi.fn(() => () => new Set<string>()),
|
||||
}));
|
||||
|
||||
vi.mock('read-package-up', () => ({
|
||||
|
|
@ -360,6 +361,7 @@ describe('gemini.tsx main function', () => {
|
|||
userHooks: undefined,
|
||||
projectHooks: undefined,
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -797,7 +799,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
bare: undefined,
|
||||
approvalMode: undefined,
|
||||
telemetry: undefined,
|
||||
checkpointing: undefined,
|
||||
telemetryTarget: undefined,
|
||||
telemetryOtlpEndpoint: undefined,
|
||||
telemetryOtlpProtocol: undefined,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ import v8 from 'node:v8';
|
|||
import React from 'react';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import {
|
||||
buildDisabledSkillNamesProvider,
|
||||
loadCliConfig,
|
||||
parseArguments,
|
||||
} from './config/config.js';
|
||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
ENV_CORRUPTED_PATH,
|
||||
|
|
@ -529,6 +533,7 @@ export async function main() {
|
|||
userHooks: settings.getUserHooks(),
|
||||
projectHooks: settings.getProjectHooks(),
|
||||
},
|
||||
buildDisabledSkillNamesProvider(settings),
|
||||
);
|
||||
|
||||
if (!settings.merged.security?.auth?.useExternal) {
|
||||
|
|
@ -780,6 +785,7 @@ export async function main() {
|
|||
userHooks: settings.getUserHooks(),
|
||||
projectHooks: settings.getProjectHooks(),
|
||||
},
|
||||
buildDisabledSkillNamesProvider(settings),
|
||||
);
|
||||
profileCheckpoint('after_load_cli_config');
|
||||
|
||||
|
|
@ -1044,6 +1050,7 @@ export async function main() {
|
|||
profileCheckpoint('config_initialize_start');
|
||||
await config.initialize();
|
||||
profileCheckpoint('config_initialize_end');
|
||||
|
||||
// Non-interactive paths feed a prompt to the model immediately after
|
||||
// init. Under PR-A's progressive MCP availability,
|
||||
// `config.initialize()` returns BEFORE MCP servers settle, so
|
||||
|
|
|
|||
|
|
@ -109,7 +109,42 @@ export default {
|
|||
'Analitza el projecte i crea un fitxer QWEN.md personalitzat.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Llistar les eines disponibles de Qwen Code. Ús: /tools [desc]',
|
||||
'List available skills.': 'Llistar les habilitats disponibles.',
|
||||
'Open the skills panel (browse, search, toggle, pick).':
|
||||
"Obrir el panell d'habilitats (explorar, cercar, activar, triar).",
|
||||
'Manage Skills': 'Gestionar habilitats',
|
||||
'Skills configuration saved.': "Configuració d'habilitats desada.",
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.':
|
||||
"Configuració d'habilitats desada, però l'actualització ha fallat: {{error}}. Reinicia per assegurar-te que el nou estat s'apliqui.",
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.':
|
||||
"L'espai de treball no és de confiança; els paràmetres de l'espai de treball s'ignoren a la configuració fusionada. Executa /trust primer, o edita ~/.qwen/settings.json directament per gestionar habilitats a l'àmbit d'usuari.",
|
||||
'SkillManager not available.': 'SkillManager no disponible.',
|
||||
'Loading skills…': 'Carregant habilitats…',
|
||||
'Failed to load skills: {{error}}':
|
||||
'No s’han pogut carregar les habilitats: {{error}}',
|
||||
'Failed to save skills configuration: {{error}}':
|
||||
"No s'ha pogut desar la configuració d'habilitats: {{error}}",
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.':
|
||||
'Totes les habilitats disponibles estan desactivades. Edita ~/.qwen/settings.json o .qwen/settings.json (skills.disabled) per tornar-les a activar.',
|
||||
'Press esc to close.': 'Prem Esc per tancar.',
|
||||
'{{count}} skills · ': '{{count}} habilitats · ',
|
||||
'{{matched}} / {{total}} skills · ': '{{matched}} / {{total}} habilitats · ',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope':
|
||||
"Espai alternar · Enter triar (omple l'entrada) · Esc desar i sortir · àmbit d'espai de treball",
|
||||
'Search:': 'Cerca:',
|
||||
'type to filter…': 'escriu per filtrar…',
|
||||
'No skills are currently available.':
|
||||
'No hi ha habilitats disponibles actualment.',
|
||||
'All available skills are locked at a higher scope (see below).':
|
||||
'Totes les habilitats disponibles estan bloquejades en un àmbit superior (veure a sota).',
|
||||
'No skills match the search.': 'Cap habilitat coincideix amb la cerca.',
|
||||
'Locked by higher-scope settings (cannot toggle here):':
|
||||
"Bloquejades per paràmetres d'àmbit superior (aquí no es poden commutar):",
|
||||
'higher scope': 'àmbit superior',
|
||||
' {{name}} {{description}} [locked: {{scope}}]':
|
||||
' {{name}} {{description}} [bloquejada: {{scope}}]',
|
||||
'↑/↓ navigate · backspace edits search':
|
||||
'↑/↓ navega · Retrocés edita la cerca',
|
||||
Bundled: 'Integrada',
|
||||
'Available Qwen Code CLI tools:': 'Eines del CLI de Qwen Code disponibles:',
|
||||
'No tools available': 'No hi ha eines disponibles',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -192,8 +227,8 @@ export default {
|
|||
'obrir la documentació completa de Qwen Code al navegador',
|
||||
'Configuration not available.': 'Configuració no disponible.',
|
||||
'Connect an LLM provider': 'Connectar un proveïdor LLM',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
"Copiar l'últim resultat o fragment de codi al porta-retalls",
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)':
|
||||
"Copia l'última resposta de la IA al porta-retalls (/copy N per a l'N-èsima)",
|
||||
|
||||
// ============================================================================
|
||||
// Ordres - Agents
|
||||
|
|
@ -715,6 +750,8 @@ export default {
|
|||
'After tool execution fails': "Quan falla l'execució de l'eina",
|
||||
'When notifications are sent': "Quan s'envien notificacions",
|
||||
'When the user submits a prompt': "Quan l'usuari envia un missatge",
|
||||
'When a slash command expands into a prompt':
|
||||
"Quan una ordre de barra s'expandeix en un missatge",
|
||||
'When a new session is started': "Quan s'inicia una nova sessió",
|
||||
'Right before Qwen Code concludes its response':
|
||||
'Immediatament abans que Qwen Code conclou la seva resposta',
|
||||
|
|
@ -736,6 +773,8 @@ export default {
|
|||
"L'entrada a l'ordre és JSON amb el missatge de notificació i el tipus.",
|
||||
'Input to command is JSON with original user prompt text.':
|
||||
"L'entrada a l'ordre és JSON amb el text original del missatge de l'usuari.",
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
|
||||
"L'entrada a l'ordre és JSON amb command_name, command_args i el text del missatge expandit.",
|
||||
'Input to command is JSON with session start source.':
|
||||
"L'entrada a l'ordre és JSON amb la font d'inici de sessió.",
|
||||
'Input to command is JSON with session end reason.':
|
||||
|
|
@ -759,6 +798,8 @@ export default {
|
|||
"mostrar stderr només a l'usuari però continuar amb la crida a l'eina",
|
||||
'block processing, erase original prompt, and show stderr to user only':
|
||||
"blocar el processament, esborrar el missatge original i mostrar stderr només a l'usuari",
|
||||
'block expanded prompt submission and show stderr to user only':
|
||||
"blocar l'enviament del missatge expandit i mostrar stderr només a l'usuari",
|
||||
'stdout shown to Qwen': 'stdout mostrat a Qwen',
|
||||
'show stderr to user only (blocking errors ignored)':
|
||||
"mostrar stderr només a l'usuari (errors de bloqueig ignorats)",
|
||||
|
|
@ -798,6 +839,24 @@ export default {
|
|||
'Resume a previous session': 'Reprendre una sessió anterior',
|
||||
'Fork the current conversation into a new session':
|
||||
'Bifurca la conversa actual en una sessió nova',
|
||||
'Spawn a background agent that inherits the full conversation':
|
||||
'Inicia un agent en segon pla que hereta tota la conversa',
|
||||
'Please provide a directive. Usage: /fork <directive>':
|
||||
'Proporcioneu una directiva. Ús: /fork <directiva>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
"No es pot crear una bifurcació mentre hi ha una resposta o una crida a una eina en curs. Espereu que acabi o resolgueu la crida a l'eina pendent.",
|
||||
'Cannot fork before the first conversation turn.':
|
||||
'No es pot crear una bifurcació abans del primer torn de conversa.',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.':
|
||||
'L’ordre /fork requereix el feature gate de fork. Definiu QWEN_CODE_ENABLE_FORK_SUBAGENT=1 per activar-lo.',
|
||||
'The agent tool is unavailable; cannot fork.':
|
||||
"L'eina d'agent no està disponible; no es pot crear una bifurcació.",
|
||||
'Failed to launch fork: {{error}}':
|
||||
'No s’ha pogut iniciar la bifurcació: {{error}}',
|
||||
'User launched a background fork via /fork: {{directive}}':
|
||||
"L'usuari ha iniciat una bifurcació en segon pla amb /fork: {{directive}}",
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.':
|
||||
"S'ha bifurcat a un agent en segon pla. Hereta aquesta conversa i s'executa sense bloquejar — feu-ne el seguiment al tauler de tasques en segon pla; informarà quan acabi.",
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
"No es pot bifurcar mentre hi ha una resposta o una crida a una eina en curs. Espereu que acabi o resolgueu la crida a l'eina pendent.",
|
||||
'No conversation to branch.': 'No hi ha cap conversa per bifurcar.',
|
||||
|
|
@ -846,13 +905,14 @@ export default {
|
|||
// Ordres - Mode d'aprovació
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': "Mode d'aprovació d'eines",
|
||||
'{{mode}} mode': 'Mode {{mode}}',
|
||||
'Analyze only, do not modify files or execute commands':
|
||||
'Analitzar només, sense modificar fitxers ni executar ordres',
|
||||
'Require approval for file edits or shell commands':
|
||||
'Requerir aprovació per a edicions de fitxers o ordres shell',
|
||||
'Automatically approve file edits':
|
||||
'Aprovar automàticament les edicions de fitxers',
|
||||
'Use classifier to automatically approve safe tool calls':
|
||||
'Utilitzar el classificador per aprovar automàticament les crides segures a eines',
|
||||
'Automatically approve all tools': 'Aprovar automàticament totes les eines',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
"Existeix un mode d'aprovació de l'espai de treball i té prioritat. El canvi a nivell d'usuari no tindrà cap efecte.",
|
||||
|
|
|
|||
|
|
@ -91,7 +91,41 @@ export default {
|
|||
'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]',
|
||||
'List available skills.': 'Verfügbare Skills auflisten.',
|
||||
'Open the skills panel (browse, search, toggle, pick).':
|
||||
'Skills-Panel öffnen (durchsuchen, suchen, ein/aus, auswählen).',
|
||||
'Manage Skills': 'Skills verwalten',
|
||||
'Skills configuration saved.': 'Skills-Konfiguration gespeichert.',
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.':
|
||||
'Skills-Konfiguration gespeichert, aber Aktualisierung fehlgeschlagen: {{error}}. Bitte neu starten, um den neuen Zustand zu übernehmen.',
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.':
|
||||
'Arbeitsbereich ist nicht vertrauenswürdig; Arbeitsbereichseinstellungen werden in der zusammengeführten Konfiguration ignoriert. Führe zuerst /trust aus oder bearbeite ~/.qwen/settings.json direkt, um Skills auf Benutzerebene zu verwalten.',
|
||||
'SkillManager not available.': 'SkillManager nicht verfügbar.',
|
||||
'Loading skills…': 'Skills werden geladen…',
|
||||
'Failed to load skills: {{error}}':
|
||||
'Skills konnten nicht geladen werden: {{error}}',
|
||||
'Failed to save skills configuration: {{error}}':
|
||||
'Speichern der Skill-Konfiguration fehlgeschlagen: {{error}}',
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.':
|
||||
'Alle verfügbaren Skills sind deaktiviert. Bearbeite ~/.qwen/settings.json oder .qwen/settings.json (skills.disabled), um sie wieder zu aktivieren.',
|
||||
'Press esc to close.': 'Esc drücken, um zu schließen.',
|
||||
'{{count}} skills · ': '{{count}} Skills · ',
|
||||
'{{matched}} / {{total}} skills · ': '{{matched}} / {{total}} Skills · ',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope':
|
||||
'Leertaste umschalten · Enter auswählen (in Eingabe) · Esc speichern & beenden · Arbeitsbereich',
|
||||
'Search:': 'Suche:',
|
||||
'type to filter…': 'Tippen zum Filtern…',
|
||||
'No skills are currently available.': 'Derzeit sind keine Skills verfügbar.',
|
||||
'All available skills are locked at a higher scope (see below).':
|
||||
'Alle verfügbaren Skills sind in einer höheren Ebene gesperrt (siehe unten).',
|
||||
'No skills match the search.': 'Keine Skills passen zur Suche.',
|
||||
'Locked by higher-scope settings (cannot toggle here):':
|
||||
'Gesperrt durch Einstellungen einer höheren Ebene (kann hier nicht umgeschaltet werden):',
|
||||
'higher scope': 'höhere Ebene',
|
||||
' {{name}} {{description}} [locked: {{scope}}]':
|
||||
' {{name}} {{description}} [gesperrt: {{scope}}]',
|
||||
'↑/↓ navigate · backspace edits search':
|
||||
'↑/↓ navigieren · Rücktaste bearbeitet Suche',
|
||||
Bundled: 'Mitgeliefert',
|
||||
'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:',
|
||||
'No tools available': 'Keine Werkzeuge verfügbar',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -171,8 +205,8 @@ export default {
|
|||
'Vollständige Qwen Code Dokumentation im Browser öffnen',
|
||||
'Configuration not available.': 'Konfiguration nicht verfügbar.',
|
||||
'Connect an LLM provider': 'LLM-Anbieter verbinden',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Letztes Ergebnis oder Codeausschnitt in die Zwischenablage kopieren',
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)':
|
||||
'Letzte KI-Antwort in die Zwischenablage kopieren (/copy N für die N-letzte)',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Agents
|
||||
|
|
@ -654,6 +688,8 @@ export default {
|
|||
'After tool execution fails': 'Wenn die Tool-Ausführung fehlschlägt',
|
||||
'When notifications are sent': 'Wenn Benachrichtigungen gesendet werden',
|
||||
'When the user submits a prompt': 'Wenn der Benutzer einen Prompt absendet',
|
||||
'When a slash command expands into a prompt':
|
||||
'Wenn ein Slash-Befehl zu einem Prompt erweitert wird',
|
||||
'When a new session is started': 'Wenn eine neue Sitzung gestartet wird',
|
||||
'Right before Qwen Code concludes its response':
|
||||
'Direkt bevor Qwen Code seine Antwort abschließt',
|
||||
|
|
@ -680,6 +716,8 @@ export default {
|
|||
'Die Eingabe an den Befehl ist JSON mit Benachrichtigungsnachricht und -typ.',
|
||||
'Input to command is JSON with original user prompt text.':
|
||||
'Die Eingabe an den Befehl ist JSON mit dem ursprünglichen Benutzer-Prompt-Text.',
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
|
||||
'Die Eingabe an den Befehl ist JSON mit command_name, command_args und erweitertem Prompt-Text.',
|
||||
'Input to command is JSON with session start source.':
|
||||
'Die Eingabe an den Befehl ist JSON mit der Sitzungsstart-Quelle.',
|
||||
'Input to command is JSON with session end reason.':
|
||||
|
|
@ -708,6 +746,8 @@ export default {
|
|||
'stderr nur dem Benutzer anzeigen, aber mit Tool-Aufruf fortfahren',
|
||||
'block processing, erase original prompt, and show stderr to user only':
|
||||
'Verarbeitung blockieren, ursprünglichen Prompt löschen und stderr nur dem Benutzer anzeigen',
|
||||
'block expanded prompt submission and show stderr to user only':
|
||||
'Einreichen des erweiterten Prompts blockieren und stderr nur dem Benutzer anzeigen',
|
||||
'stdout shown to Qwen': 'stdout dem Qwen anzeigen',
|
||||
'show stderr to user only (blocking errors ignored)':
|
||||
'stderr nur dem Benutzer anzeigen (Blockierungsfehler ignoriert)',
|
||||
|
|
@ -756,6 +796,24 @@ export default {
|
|||
'Resume a previous session': 'Eine vorherige Sitzung fortsetzen',
|
||||
'Fork the current conversation into a new session':
|
||||
'Die aktuelle Unterhaltung in eine neue Sitzung verzweigen',
|
||||
'Spawn a background agent that inherits the full conversation':
|
||||
'Einen Hintergrund-Agenten starten, der die gesamte Unterhaltung übernimmt',
|
||||
'Please provide a directive. Usage: /fork <directive>':
|
||||
'Bitte geben Sie eine Anweisung an. Verwendung: /fork <Anweisung>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'Während eine Antwort oder ein Tool-Aufruf läuft, kann kein Hintergrund-Fork erstellt werden. Warten Sie, bis der Vorgang abgeschlossen ist, oder bearbeiten Sie den ausstehenden Tool-Aufruf.',
|
||||
'Cannot fork before the first conversation turn.':
|
||||
'Vor der ersten Gesprächsrunde kann kein Fork erstellt werden.',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.':
|
||||
'Der Befehl /fork erfordert das Fork-Feature-Gate. Setzen Sie QWEN_CODE_ENABLE_FORK_SUBAGENT=1, um es zu aktivieren.',
|
||||
'The agent tool is unavailable; cannot fork.':
|
||||
'Das Agent-Tool ist nicht verfügbar; Fork kann nicht gestartet werden.',
|
||||
'Failed to launch fork: {{error}}':
|
||||
'Fork konnte nicht gestartet werden: {{error}}',
|
||||
'User launched a background fork via /fork: {{directive}}':
|
||||
'Benutzer hat über /fork einen Hintergrund-Fork gestartet: {{directive}}',
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.':
|
||||
'In einen Hintergrund-Agenten verzweigt. Er übernimmt diese Unterhaltung und läuft ohne zu blockieren — verfolgen Sie ihn im Hintergrundaufgaben-Panel; er meldet sich nach Abschluss zurück.',
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'Während eine Antwort oder ein Tool-Aufruf läuft, kann keine Verzweigung erstellt werden. Warten Sie, bis der Vorgang abgeschlossen ist, oder bearbeiten Sie den ausstehenden Tool-Aufruf.',
|
||||
'No conversation to branch.': 'Keine Unterhaltung zum Verzweigen vorhanden.',
|
||||
|
|
@ -803,13 +861,14 @@ export default {
|
|||
// Commands - Approval Mode
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': 'Werkzeug-Genehmigungsmodus',
|
||||
'{{mode}} mode': '{{mode}}-Modus',
|
||||
'Analyze only, do not modify files or execute commands':
|
||||
'Nur analysieren, keine Dateien ändern oder Befehle ausführen',
|
||||
'Require approval for file edits or shell commands':
|
||||
'Genehmigung für Dateibearbeitungen oder Shell-Befehle erforderlich',
|
||||
'Automatically approve file edits':
|
||||
'Dateibearbeitungen automatisch genehmigen',
|
||||
'Use classifier to automatically approve safe tool calls':
|
||||
'Klassifikator verwenden, um sichere Werkzeugaufrufe automatisch zu genehmigen',
|
||||
'Automatically approve all tools': 'Alle Werkzeuge automatisch genehmigen',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'Arbeitsbereich-Genehmigungsmodus existiert und hat Vorrang. Benutzerebene-Änderung hat keine Wirkung.',
|
||||
|
|
|
|||
|
|
@ -113,7 +113,41 @@ export default {
|
|||
'Analyzes the project and creates a tailored QWEN.md file.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]',
|
||||
'List available skills.': 'List available skills.',
|
||||
'Open the skills panel (browse, search, toggle, pick).':
|
||||
'Open the skills panel (browse, search, toggle, pick).',
|
||||
// SkillsManagerDialog (the panel `/skills` opens)
|
||||
'Manage Skills': 'Manage Skills',
|
||||
'Skills configuration saved.': 'Skills configuration saved.',
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.':
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.',
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.':
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.',
|
||||
'SkillManager not available.': 'SkillManager not available.',
|
||||
'Loading skills…': 'Loading skills…',
|
||||
'Failed to load skills: {{error}}': 'Failed to load skills: {{error}}',
|
||||
'Failed to save skills configuration: {{error}}':
|
||||
'Failed to save skills configuration: {{error}}',
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.':
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.',
|
||||
'Press esc to close.': 'Press esc to close.',
|
||||
'{{count}} skills · ': '{{count}} skills · ',
|
||||
'{{matched}} / {{total}} skills · ': '{{matched}} / {{total}} skills · ',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope':
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope',
|
||||
'Search:': 'Search:',
|
||||
'type to filter…': 'type to filter…',
|
||||
'No skills are currently available.': 'No skills are currently available.',
|
||||
'All available skills are locked at a higher scope (see below).':
|
||||
'All available skills are locked at a higher scope (see below).',
|
||||
'No skills match the search.': 'No skills match the search.',
|
||||
'Locked by higher-scope settings (cannot toggle here):':
|
||||
'Locked by higher-scope settings (cannot toggle here):',
|
||||
'higher scope': 'higher scope',
|
||||
' {{name}} {{description}} [locked: {{scope}}]':
|
||||
' {{name}} {{description}} [locked: {{scope}}]',
|
||||
'↑/↓ navigate · backspace edits search':
|
||||
'↑/↓ navigate · backspace edits search',
|
||||
Bundled: 'Bundled',
|
||||
'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:',
|
||||
'No tools available': 'No tools available',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -194,8 +228,8 @@ export default {
|
|||
'open full Qwen Code documentation in your browser',
|
||||
'Configuration not available.': 'Configuration not available.',
|
||||
'Connect an LLM provider': 'Connect an LLM provider',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Copy the last result or code snippet to clipboard',
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)':
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)',
|
||||
'Show working-tree change stats versus HEAD':
|
||||
'Show working-tree change stats versus HEAD',
|
||||
'Could not determine current working directory.':
|
||||
|
|
@ -743,6 +777,8 @@ export default {
|
|||
'After tool execution fails': 'After tool execution fails',
|
||||
'When notifications are sent': 'When notifications are sent',
|
||||
'When the user submits a prompt': 'When the user submits a prompt',
|
||||
'When a slash command expands into a prompt':
|
||||
'When a slash command expands into a prompt',
|
||||
'When a new session is started': 'When a new session is started',
|
||||
'Right before Qwen Code concludes its response':
|
||||
'Right before Qwen Code concludes its response',
|
||||
|
|
@ -768,6 +804,8 @@ export default {
|
|||
'Input to command is JSON with notification message and type.',
|
||||
'Input to command is JSON with original user prompt text.':
|
||||
'Input to command is JSON with original user prompt text.',
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.',
|
||||
'Input to command is JSON with session start source.':
|
||||
'Input to command is JSON with session start source.',
|
||||
'Input to command is JSON with session end reason.':
|
||||
|
|
@ -796,6 +834,8 @@ export default {
|
|||
'show stderr to user only but continue with tool call',
|
||||
'block processing, erase original prompt, and show stderr to user only':
|
||||
'block processing, erase original prompt, and show stderr to user only',
|
||||
'block expanded prompt submission and show stderr to user only':
|
||||
'block expanded prompt submission and show stderr to user only',
|
||||
'stdout shown to Qwen': 'stdout shown to Qwen',
|
||||
'show stderr to user only (blocking errors ignored)':
|
||||
'show stderr to user only (blocking errors ignored)',
|
||||
|
|
@ -842,6 +882,25 @@ export default {
|
|||
'Resume a previous session': 'Resume a previous session',
|
||||
'Fork the current conversation into a new session':
|
||||
'Fork the current conversation into a new session',
|
||||
'Spawn a background agent that inherits the full conversation':
|
||||
'Spawn a background agent that inherits the full conversation',
|
||||
'Please provide a directive. Usage: /fork <directive>':
|
||||
'Please provide a directive. Usage: /fork <directive>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.',
|
||||
'Cannot fork before the first conversation turn.':
|
||||
'Cannot fork before the first conversation turn.',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.':
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.',
|
||||
'The agent tool is unavailable; cannot fork.':
|
||||
'The agent tool is unavailable; cannot fork.',
|
||||
'Failed to launch fork: {{error}}': 'Failed to launch fork: {{error}}',
|
||||
'the background agent could not be started.':
|
||||
'the background agent could not be started.',
|
||||
'User launched a background fork via /fork: {{directive}}':
|
||||
'User launched a background fork via /fork: {{directive}}',
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.':
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.',
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.',
|
||||
'No conversation to branch.': 'No conversation to branch.',
|
||||
|
|
@ -887,12 +946,13 @@ export default {
|
|||
// Commands - Approval Mode
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': 'Tool Approval Mode',
|
||||
'{{mode}} mode': '{{mode}} mode',
|
||||
'Analyze only, do not modify files or execute commands':
|
||||
'Analyze only, do not modify files or execute commands',
|
||||
'Require approval for file edits or shell commands':
|
||||
'Require approval for file edits or shell commands',
|
||||
'Automatically approve file edits': 'Automatically approve file edits',
|
||||
'Use classifier to automatically approve safe tool calls':
|
||||
'Use classifier to automatically approve safe tool calls',
|
||||
'Automatically approve all tools': 'Automatically approve all tools',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
|
||||
|
|
@ -1901,6 +1961,17 @@ export default {
|
|||
'Show current process memory diagnostics',
|
||||
'Record a CPU profile for Chrome DevTools analysis':
|
||||
'Record a CPU profile for Chrome DevTools analysis',
|
||||
'Roll back a standalone update to the previous version':
|
||||
'Roll back a standalone update to the previous version',
|
||||
'Rollback is not available in ACP mode.':
|
||||
'Rollback is not available in ACP mode.',
|
||||
'Rollback is only available for standalone installations.':
|
||||
'Rollback is only available for standalone installations.',
|
||||
'Rollback successful. Restart your terminal to use the previous version.':
|
||||
'Rollback successful. Restart your terminal to use the previous version.',
|
||||
'Rollback failed:': 'Rollback failed:',
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.':
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.',
|
||||
'Save a durable memory to the memory system.':
|
||||
'Save a durable memory to the memory system.',
|
||||
'Ask a quick side question without affecting the main conversation':
|
||||
|
|
|
|||
|
|
@ -107,7 +107,43 @@ export default {
|
|||
'Analyse le projet et crée un fichier QWEN.md personnalisé.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Lister les outils Qwen Code disponibles. Utilisation : /tools [desc]',
|
||||
'List available skills.': 'Lister les compétences disponibles.',
|
||||
'Open the skills panel (browse, search, toggle, pick).':
|
||||
'Ouvrir le panneau des compétences (parcourir, rechercher, activer, choisir).',
|
||||
'Manage Skills': 'Gérer les compétences',
|
||||
'Skills configuration saved.': 'Configuration des compétences enregistrée.',
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.':
|
||||
'Configuration des compétences enregistrée, mais le rafraîchissement a échoué : {{error}}. Redémarrez pour garantir l’application du nouvel état.',
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.':
|
||||
'L’espace de travail n’est pas approuvé ; les paramètres de l’espace de travail sont ignorés par la configuration fusionnée. Exécutez d’abord /trust, ou modifiez directement ~/.qwen/settings.json pour gérer les compétences au niveau utilisateur.',
|
||||
'SkillManager not available.': 'SkillManager non disponible.',
|
||||
'Loading skills…': 'Chargement des compétences…',
|
||||
'Failed to load skills: {{error}}':
|
||||
'Échec du chargement des compétences : {{error}}',
|
||||
'Failed to save skills configuration: {{error}}':
|
||||
"Échec de l'enregistrement de la configuration des compétences : {{error}}",
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.':
|
||||
'Toutes les compétences disponibles sont désactivées. Modifiez ~/.qwen/settings.json ou .qwen/settings.json (skills.disabled) pour les réactiver.',
|
||||
'Press esc to close.': 'Appuyez sur Échap pour fermer.',
|
||||
'{{count}} skills · ': '{{count}} compétences · ',
|
||||
'{{matched}} / {{total}} skills · ': '{{matched}} / {{total}} compétences · ',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope':
|
||||
'Espace bascule · Entrée choisir (remplit l’entrée) · Échap enregistrer & quitter · portée espace de travail',
|
||||
'Search:': 'Recherche :',
|
||||
'type to filter…': 'tapez pour filtrer…',
|
||||
'No skills are currently available.':
|
||||
'Aucune compétence n’est actuellement disponible.',
|
||||
'All available skills are locked at a higher scope (see below).':
|
||||
'Toutes les compétences disponibles sont verrouillées à une portée supérieure (voir ci-dessous).',
|
||||
'No skills match the search.':
|
||||
'Aucune compétence ne correspond à la recherche.',
|
||||
'Locked by higher-scope settings (cannot toggle here):':
|
||||
'Verrouillées par des paramètres de portée supérieure (impossible de basculer ici) :',
|
||||
'higher scope': 'portée supérieure',
|
||||
' {{name}} {{description}} [locked: {{scope}}]':
|
||||
' {{name}} {{description}} [verrouillée : {{scope}}]',
|
||||
'↑/↓ navigate · backspace edits search':
|
||||
'↑/↓ naviguer · Retour modifie la recherche',
|
||||
Bundled: 'Intégrée',
|
||||
'Available Qwen Code CLI tools:': 'Outils Qwen Code CLI disponibles :',
|
||||
'No tools available': 'Aucun outil disponible',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -192,8 +228,8 @@ export default {
|
|||
'ouvrir la documentation complète de Qwen Code dans votre navigateur',
|
||||
'Configuration not available.': 'Configuration non disponible.',
|
||||
'Connect an LLM provider': 'Se connecter à un fournisseur LLM',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Copier le dernier résultat ou extrait de code dans le presse-papiers',
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)':
|
||||
'Copier la dernière réponse IA dans le presse-papiers (/copy N pour la Nième)',
|
||||
|
||||
// ============================================================================
|
||||
// Commandes - Agents
|
||||
|
|
@ -722,6 +758,8 @@ export default {
|
|||
'After tool execution fails': "Après l'échec de l'exécution de l'outil",
|
||||
'When notifications are sent': 'Quand des notifications sont envoyées',
|
||||
'When the user submits a prompt': "Quand l'utilisateur soumet une invite",
|
||||
'When a slash command expands into a prompt':
|
||||
'Quand une commande slash se développe en invite',
|
||||
'When a new session is started': 'Quand une nouvelle session est démarrée',
|
||||
'Right before Qwen Code concludes its response':
|
||||
'Juste avant que Qwen Code conclue sa réponse',
|
||||
|
|
@ -746,6 +784,8 @@ export default {
|
|||
"L'entrée de la commande est du JSON avec le message et le type de notification.",
|
||||
'Input to command is JSON with original user prompt text.':
|
||||
"L'entrée de la commande est du JSON avec le texte d'invite original de l'utilisateur.",
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
|
||||
"L'entrée de la commande est du JSON avec command_name, command_args et le texte d'invite développé.",
|
||||
'Input to command is JSON with session start source.':
|
||||
"L'entrée de la commande est du JSON avec la source de démarrage de session.",
|
||||
'Input to command is JSON with session end reason.':
|
||||
|
|
@ -773,6 +813,8 @@ export default {
|
|||
"afficher stderr à l'utilisateur uniquement mais continuer l'appel d'outil",
|
||||
'block processing, erase original prompt, and show stderr to user only':
|
||||
"bloquer le traitement, effacer l'invite originale et afficher stderr à l'utilisateur uniquement",
|
||||
'block expanded prompt submission and show stderr to user only':
|
||||
"bloquer l'envoi de l'invite développée et afficher stderr uniquement à l'utilisateur",
|
||||
'stdout shown to Qwen': 'stdout affiché à Qwen',
|
||||
'show stderr to user only (blocking errors ignored)':
|
||||
"afficher stderr à l'utilisateur uniquement (erreurs bloquantes ignorées)",
|
||||
|
|
@ -818,6 +860,24 @@ export default {
|
|||
'Resume a previous session': 'Reprendre une session précédente',
|
||||
'Fork the current conversation into a new session':
|
||||
'Créer une branche de la conversation actuelle dans une nouvelle session',
|
||||
'Spawn a background agent that inherits the full conversation':
|
||||
'Lancer un agent en arrière-plan qui hérite de toute la conversation',
|
||||
'Please provide a directive. Usage: /fork <directive>':
|
||||
'Veuillez fournir une directive. Utilisation : /fork <directive>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
"Impossible de créer un fork pendant qu'une réponse ou un appel d'outil est en cours. Attendez la fin ou traitez l'appel d'outil en attente.",
|
||||
'Cannot fork before the first conversation turn.':
|
||||
'Impossible de créer un fork avant le premier tour de conversation.',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.':
|
||||
'La commande /fork nécessite le feature gate fork. Définissez QWEN_CODE_ENABLE_FORK_SUBAGENT=1 pour l’activer.',
|
||||
'The agent tool is unavailable; cannot fork.':
|
||||
"L'outil agent est indisponible ; impossible de créer un fork.",
|
||||
'Failed to launch fork: {{error}}':
|
||||
'Échec du lancement du fork : {{error}}',
|
||||
'User launched a background fork via /fork: {{directive}}':
|
||||
"L'utilisateur a lancé un fork en arrière-plan via /fork : {{directive}}",
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.':
|
||||
"Fork lancé dans un agent en arrière-plan. Il hérite de cette conversation et s'exécute sans bloquer — suivez-le dans le panneau des tâches en arrière-plan ; il fera un rapport une fois terminé.",
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
"Impossible de créer une branche pendant qu'une réponse ou un appel d'outil est en cours. Attendez la fin ou traitez l'appel d'outil en attente.",
|
||||
'No conversation to branch.':
|
||||
|
|
@ -869,13 +929,14 @@ export default {
|
|||
// Commandes - Mode d'approbation
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': "Mode d'approbation des outils",
|
||||
'{{mode}} mode': 'Mode {{mode}}',
|
||||
'Analyze only, do not modify files or execute commands':
|
||||
'Analyser uniquement, ne pas modifier les fichiers ni exécuter des commandes',
|
||||
'Require approval for file edits or shell commands':
|
||||
"Demander l'approbation pour les modifications de fichiers ou les commandes shell",
|
||||
'Automatically approve file edits':
|
||||
'Approuver automatiquement les modifications de fichiers',
|
||||
'Use classifier to automatically approve safe tool calls':
|
||||
'Utiliser le classificateur pour approuver automatiquement les appels d’outils sûrs',
|
||||
'Automatically approve all tools':
|
||||
'Approuver automatiquement tous les outils',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
|
|
|
|||
|
|
@ -74,7 +74,39 @@ export default {
|
|||
'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]',
|
||||
'List available skills.': '利用可能なスキルを一覧表示する。',
|
||||
'Open the skills panel (browse, search, toggle, pick).':
|
||||
'スキルパネルを開く(一覧・検索・有効化/無効化・選択)。',
|
||||
'Manage Skills': 'スキルを管理',
|
||||
'Skills configuration saved.': 'スキル設定を保存しました。',
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.':
|
||||
'スキル設定を保存しましたが、更新に失敗しました:{{error}}。再起動して新しい状態が反映されることを確認してください。',
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.':
|
||||
'ワークスペースが信頼されていないため、ワークスペース設定はマージ設定で無視されます。先に /trust を実行するか、~/.qwen/settings.json を直接編集してユーザースコープでスキルを管理してください。',
|
||||
'SkillManager not available.': 'SkillManager は利用できません。',
|
||||
'Loading skills…': 'スキルを読み込み中…',
|
||||
'Failed to load skills: {{error}}': 'スキルの読み込みに失敗:{{error}}',
|
||||
'Failed to save skills configuration: {{error}}':
|
||||
'スキル設定の保存に失敗しました:{{error}}',
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.':
|
||||
'すべての利用可能なスキルが無効化されています。~/.qwen/settings.json または .qwen/settings.json (skills.disabled) を編集して再有効化してください。',
|
||||
'Press esc to close.': 'Esc で閉じる。',
|
||||
'{{count}} skills · ': '{{count}} スキル · ',
|
||||
'{{matched}} / {{total}} skills · ': '{{matched}} / {{total}} スキル · ',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope':
|
||||
'スペース 切替 · Enter 選択(入力欄に挿入) · Esc 保存して終了 · ワークスペーススコープ',
|
||||
'Search:': '検索:',
|
||||
'type to filter…': 'フィルタを入力…',
|
||||
'No skills are currently available.': '利用可能なスキルはありません。',
|
||||
'All available skills are locked at a higher scope (see below).':
|
||||
'すべての利用可能なスキルは上位スコープでロックされています(下記参照)。',
|
||||
'No skills match the search.': '検索に一致するスキルはありません。',
|
||||
'Locked by higher-scope settings (cannot toggle here):':
|
||||
'上位スコープ設定によってロックされています(ここでは切替不可):',
|
||||
'higher scope': '上位スコープ',
|
||||
' {{name}} {{description}} [locked: {{scope}}]':
|
||||
' {{name}} {{description}} [ロック中:{{scope}}]',
|
||||
'↑/↓ navigate · backspace edits search': '↑/↓ 移動 · Backspace 検索編集',
|
||||
Bundled: '組み込み',
|
||||
'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:',
|
||||
'No tools available': '利用可能なツールはありません',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -149,8 +181,8 @@ export default {
|
|||
'ブラウザで Qwen Code のドキュメントを開く',
|
||||
'Configuration not available.': '設定が利用できません',
|
||||
'Connect an LLM provider': 'LLM プロバイダーに接続',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'最後の結果またはコードスニペットをクリップボードにコピー',
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)':
|
||||
'最新のAI応答をクリップボードにコピー(/copy N で新しい方からN番目)',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Agents
|
||||
|
|
@ -447,6 +479,8 @@ export default {
|
|||
'After tool execution fails': 'ツール実行失敗時',
|
||||
'When notifications are sent': '通知送信時',
|
||||
'When the user submits a prompt': 'ユーザーがプロンプトを送信した時',
|
||||
'When a slash command expands into a prompt':
|
||||
'スラッシュコマンドがプロンプトに展開された時',
|
||||
'When a new session is started': '新しいセッションが開始された時',
|
||||
'Right before Qwen Code concludes its response':
|
||||
'Qwen Code が応答を終了する直前',
|
||||
|
|
@ -470,6 +504,8 @@ export default {
|
|||
'コマンドへの入力は通知メッセージとタイプを持つ JSON です。',
|
||||
'Input to command is JSON with original user prompt text.':
|
||||
'コマンドへの入力は元のユーザープロンプトテキストを持つ JSON です。',
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
|
||||
'コマンドへの入力は command_name、command_args、展開後のプロンプトテキストを持つ JSON です。',
|
||||
'Input to command is JSON with session start source.':
|
||||
'コマンドへの入力はセッション開始ソースを持つ JSON です。',
|
||||
'Input to command is JSON with session end reason.':
|
||||
|
|
@ -498,6 +534,8 @@ export default {
|
|||
'stderr をユーザーのみに表示し、ツール呼び出しを続ける',
|
||||
'block processing, erase original prompt, and show stderr to user only':
|
||||
'処理をブロックし、元のプロンプトを消去し、stderr をユーザーのみに表示',
|
||||
'block expanded prompt submission and show stderr to user only':
|
||||
'展開後のプロンプト送信をブロックし、stderr をユーザーのみに表示',
|
||||
'stdout shown to Qwen': 'stdout を Qwen に表示',
|
||||
'show stderr to user only (blocking errors ignored)':
|
||||
'stderr をユーザーのみに表示(ブロッキングエラーは無視)',
|
||||
|
|
@ -545,6 +583,24 @@ export default {
|
|||
'Resume a previous session': '前のセッションを再開する',
|
||||
'Fork the current conversation into a new session':
|
||||
'現在の会話を新しいセッションに分岐する',
|
||||
'Spawn a background agent that inherits the full conversation':
|
||||
'会話全体を引き継ぐバックグラウンドエージェントを起動する',
|
||||
'Please provide a directive. Usage: /fork <directive>':
|
||||
'指示を入力してください。使用法: /fork <指示>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'応答またはツール呼び出しの処理中はフォークできません。完了するか、保留中のツール呼び出しを解決してください。',
|
||||
'Cannot fork before the first conversation turn.':
|
||||
'最初の会話ターンの前にはフォークできません。',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.':
|
||||
'/fork コマンドには fork フィーチャーゲートが必要です。有効にするには QWEN_CODE_ENABLE_FORK_SUBAGENT=1 を設定してください。',
|
||||
'The agent tool is unavailable; cannot fork.':
|
||||
'エージェントツールを利用できないため、フォークできません。',
|
||||
'Failed to launch fork: {{error}}':
|
||||
'フォークの起動に失敗しました: {{error}}',
|
||||
'User launched a background fork via /fork: {{directive}}':
|
||||
'ユーザーが /fork でバックグラウンドフォークを起動しました: {{directive}}',
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.':
|
||||
'バックグラウンドエージェントにフォークしました。この会話を引き継ぎ、ブロックせずに実行されます — バックグラウンドタスクパネルで追跡でき、完了時に報告します。',
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'応答またはツール呼び出しの処理中は分岐できません。完了するか、保留中のツール呼び出しを解決してください。',
|
||||
'No conversation to branch.': '分岐できる会話がありません。',
|
||||
|
|
@ -580,12 +636,13 @@ export default {
|
|||
'追加のUI言語パックをリクエストするには、GitHub で Issue を作成してください',
|
||||
'Available options:': '使用可能なオプション:',
|
||||
'Set UI language to {{name}}': 'UI言語を {{name}} に設定',
|
||||
'{{mode}} mode': '{{mode}}モード',
|
||||
'Analyze only, do not modify files or execute commands':
|
||||
'分析のみ、ファイルの変更やコマンドの実行はしません',
|
||||
'Require approval for file edits or shell commands':
|
||||
'ファイル編集やシェルコマンドには承認が必要',
|
||||
'Automatically approve file edits': 'ファイル編集を自動承認',
|
||||
'Use classifier to automatically approve safe tool calls':
|
||||
'分類器を使用して安全なツール呼び出しを自動承認',
|
||||
'Automatically approve all tools': 'すべてのツールを自動承認',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'ワークスペースの承認モードが存在し、優先されます。ユーザーレベルの変更は効果がありません',
|
||||
|
|
|
|||
|
|
@ -102,7 +102,42 @@ export default {
|
|||
'Analisa o projeto e cria um arquivo QWEN.md personalizado.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
|
||||
'List available skills.': 'Listar habilidades disponíveis.',
|
||||
'Open the skills panel (browse, search, toggle, pick).':
|
||||
'Abrir o painel de habilidades (explorar, pesquisar, ativar, selecionar).',
|
||||
'Manage Skills': 'Gerenciar Habilidades',
|
||||
'Skills configuration saved.': 'Configuração de habilidades salva.',
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.':
|
||||
'Configuração de habilidades salva, mas a atualização falhou: {{error}}. Reinicie para garantir que o novo estado seja aplicado.',
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.':
|
||||
'O espaço de trabalho não é confiável; as configurações do espaço de trabalho são ignoradas pela configuração combinada. Execute /trust primeiro, ou edite ~/.qwen/settings.json diretamente para gerenciar habilidades no escopo do usuário.',
|
||||
'SkillManager not available.': 'SkillManager indisponível.',
|
||||
'Loading skills…': 'Carregando habilidades…',
|
||||
'Failed to load skills: {{error}}':
|
||||
'Falha ao carregar habilidades: {{error}}',
|
||||
'Failed to save skills configuration: {{error}}':
|
||||
'Falha ao salvar a configuração de habilidades: {{error}}',
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.':
|
||||
'Todas as habilidades disponíveis estão desativadas. Edite ~/.qwen/settings.json ou .qwen/settings.json (skills.disabled) para reativá-las.',
|
||||
'Press esc to close.': 'Pressione Esc para fechar.',
|
||||
'{{count}} skills · ': '{{count}} habilidades · ',
|
||||
'{{matched}} / {{total}} skills · ': '{{matched}} / {{total}} habilidades · ',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope':
|
||||
'Espaço alternar · Enter selecionar (preencher entrada) · Esc salvar & sair · escopo do espaço de trabalho',
|
||||
'Search:': 'Pesquisar:',
|
||||
'type to filter…': 'digite para filtrar…',
|
||||
'No skills are currently available.':
|
||||
'Nenhuma habilidade está disponível no momento.',
|
||||
'All available skills are locked at a higher scope (see below).':
|
||||
'Todas as habilidades disponíveis estão bloqueadas em um escopo superior (veja abaixo).',
|
||||
'No skills match the search.': 'Nenhuma habilidade corresponde à pesquisa.',
|
||||
'Locked by higher-scope settings (cannot toggle here):':
|
||||
'Bloqueado por configurações de escopo superior (não é possível alternar aqui):',
|
||||
'higher scope': 'escopo superior',
|
||||
' {{name}} {{description}} [locked: {{scope}}]':
|
||||
' {{name}} {{description}} [bloqueado: {{scope}}]',
|
||||
'↑/↓ navigate · backspace edits search':
|
||||
'↑/↓ navegar · Backspace edita a pesquisa',
|
||||
Bundled: 'Integrada',
|
||||
'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:',
|
||||
'No tools available': 'Nenhuma ferramenta disponível',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -185,8 +220,8 @@ export default {
|
|||
'abrir documentação completa do Qwen Code no seu navegador',
|
||||
'Configuration not available.': 'Configuração não disponível.',
|
||||
'Connect an LLM provider': 'Conectar a um provedor LLM',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Copiar o último resultado ou trecho de código para a área de transferência',
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)':
|
||||
'Copiar a última resposta da IA para a área de transferência (/copy N para a N-ésima)',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Agents
|
||||
|
|
@ -660,6 +695,8 @@ export default {
|
|||
'After tool execution fails': 'Após a falha da execução da ferramenta',
|
||||
'When notifications are sent': 'Quando notificações são enviadas',
|
||||
'When the user submits a prompt': 'Quando o usuário envia um prompt',
|
||||
'When a slash command expands into a prompt':
|
||||
'Quando um comando slash se expande em um prompt',
|
||||
'When a new session is started': 'Quando uma nova sessão é iniciada',
|
||||
'Right before Qwen Code concludes its response':
|
||||
'Logo antes do Qwen Code concluir sua resposta',
|
||||
|
|
@ -685,6 +722,8 @@ export default {
|
|||
'A entrada para o comando é JSON com mensagem e tipo de notificação.',
|
||||
'Input to command is JSON with original user prompt text.':
|
||||
'A entrada para o comando é JSON com o texto original do prompt do usuário.',
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
|
||||
'A entrada para o comando é JSON com command_name, command_args e o texto do prompt expandido.',
|
||||
'Input to command is JSON with session start source.':
|
||||
'A entrada para o comando é JSON com a fonte de início da sessão.',
|
||||
'Input to command is JSON with session end reason.':
|
||||
|
|
@ -713,6 +752,8 @@ export default {
|
|||
'mostrar stderr apenas ao usuário mas continuar com chamada de ferramenta',
|
||||
'block processing, erase original prompt, and show stderr to user only':
|
||||
'bloquear processamento, apagar prompt original e mostrar stderr apenas ao usuário',
|
||||
'block expanded prompt submission and show stderr to user only':
|
||||
'bloquear envio do prompt expandido e mostrar stderr apenas ao usuário',
|
||||
'stdout shown to Qwen': 'stdout mostrado ao Qwen',
|
||||
'show stderr to user only (blocking errors ignored)':
|
||||
'mostrar stderr apenas ao usuário (erros de bloqueio ignorados)',
|
||||
|
|
@ -760,6 +801,24 @@ export default {
|
|||
'Resume a previous session': 'Retomar uma sessão anterior',
|
||||
'Fork the current conversation into a new session':
|
||||
'Ramificar a conversa atual em uma nova sessão',
|
||||
'Spawn a background agent that inherits the full conversation':
|
||||
'Iniciar um agente em segundo plano que herda toda a conversa',
|
||||
'Please provide a directive. Usage: /fork <directive>':
|
||||
'Forneça uma diretiva. Uso: /fork <diretiva>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'Não é possível criar um fork enquanto uma resposta ou chamada de ferramenta está em andamento. Aguarde a conclusão ou resolva a chamada de ferramenta pendente.',
|
||||
'Cannot fork before the first conversation turn.':
|
||||
'Não é possível criar um fork antes da primeira rodada da conversa.',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.':
|
||||
'O comando /fork requer o feature gate de fork. Defina QWEN_CODE_ENABLE_FORK_SUBAGENT=1 para ativá-lo.',
|
||||
'The agent tool is unavailable; cannot fork.':
|
||||
'A ferramenta de agente está indisponível; não é possível criar um fork.',
|
||||
'Failed to launch fork: {{error}}':
|
||||
'Falha ao iniciar o fork: {{error}}',
|
||||
'User launched a background fork via /fork: {{directive}}':
|
||||
'O usuário iniciou um fork em segundo plano via /fork: {{directive}}',
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.':
|
||||
'Fork criado em um agente em segundo plano. Ele herda esta conversa e roda sem bloquear — acompanhe no painel de tarefas em segundo plano; ele informará quando terminar.',
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'Não é possível ramificar enquanto uma resposta ou chamada de ferramenta está em andamento. Aguarde a conclusão ou resolva a chamada de ferramenta pendente.',
|
||||
'No conversation to branch.': 'Não há conversa para ramificar.',
|
||||
|
|
@ -807,13 +866,14 @@ export default {
|
|||
// Commands - Approval Mode
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': 'Modo de Aprovação de Ferramenta',
|
||||
'{{mode}} mode': 'Modo {{mode}}',
|
||||
'Analyze only, do not modify files or execute commands':
|
||||
'Apenas analisar, não modificar arquivos nem executar comandos',
|
||||
'Require approval for file edits or shell commands':
|
||||
'Exigir aprovação para edições de arquivos ou comandos shell',
|
||||
'Automatically approve file edits':
|
||||
'Aprovar automaticamente edições de arquivos',
|
||||
'Use classifier to automatically approve safe tool calls':
|
||||
'Usar o classificador para aprovar automaticamente chamadas seguras de ferramentas',
|
||||
'Automatically approve all tools':
|
||||
'Aprovar automaticamente todas as ferramentas',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
|
|
|
|||
|
|
@ -111,7 +111,40 @@ export default {
|
|||
'Анализ проекта и создание адаптированного файла QWEN.md',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]',
|
||||
'List available skills.': 'Показать доступные навыки.',
|
||||
'Open the skills panel (browse, search, toggle, pick).':
|
||||
'Открыть панель навыков (обзор, поиск, вкл/выкл, выбор).',
|
||||
'Manage Skills': 'Управление навыками',
|
||||
'Skills configuration saved.': 'Конфигурация навыков сохранена.',
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.':
|
||||
'Конфигурация навыков сохранена, но обновление не удалось: {{error}}. Перезапустите, чтобы применить новое состояние.',
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.':
|
||||
'Рабочая область не является доверенной; настройки рабочей области игнорируются объединённой конфигурацией. Сначала выполните /trust или отредактируйте ~/.qwen/settings.json напрямую, чтобы управлять навыками на уровне пользователя.',
|
||||
'SkillManager not available.': 'SkillManager недоступен.',
|
||||
'Loading skills…': 'Загрузка навыков…',
|
||||
'Failed to load skills: {{error}}': 'Не удалось загрузить навыки: {{error}}',
|
||||
'Failed to save skills configuration: {{error}}':
|
||||
'Не удалось сохранить конфигурацию навыков: {{error}}',
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.':
|
||||
'Все доступные навыки отключены. Отредактируйте ~/.qwen/settings.json или .qwen/settings.json (skills.disabled), чтобы снова их включить.',
|
||||
'Press esc to close.': 'Нажмите Esc, чтобы закрыть.',
|
||||
'{{count}} skills · ': '{{count}} навыков · ',
|
||||
'{{matched}} / {{total}} skills · ': '{{matched}} / {{total}} навыков · ',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope':
|
||||
'Пробел переключить · Enter выбрать (вставить в ввод) · Esc сохранить и выйти · область рабочей области',
|
||||
'Search:': 'Поиск:',
|
||||
'type to filter…': 'введите для фильтрации…',
|
||||
'No skills are currently available.': 'Сейчас навыков нет.',
|
||||
'All available skills are locked at a higher scope (see below).':
|
||||
'Все доступные навыки заблокированы на более высоком уровне (см. ниже).',
|
||||
'No skills match the search.': 'Нет навыков, соответствующих поиску.',
|
||||
'Locked by higher-scope settings (cannot toggle here):':
|
||||
'Заблокированы настройками более высокого уровня (здесь переключить нельзя):',
|
||||
'higher scope': 'более высокий уровень',
|
||||
' {{name}} {{description}} [locked: {{scope}}]':
|
||||
' {{name}} {{description}} [заблокировано: {{scope}}]',
|
||||
'↑/↓ navigate · backspace edits search':
|
||||
'↑/↓ навигация · Backspace редактирует поиск',
|
||||
Bundled: 'Встроенный',
|
||||
'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:',
|
||||
'No tools available': 'Нет доступных инструментов',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -194,8 +227,8 @@ export default {
|
|||
'Открытие полной документации Qwen Code в браузере',
|
||||
'Configuration not available.': 'Конфигурация недоступна.',
|
||||
'Connect an LLM provider': 'Подключить провайдера LLM',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Копирование последнего результата или фрагмента кода в буфер обмена',
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)':
|
||||
'Копировать последний ответ ИИ в буфер обмена (/copy N для N-го с конца)',
|
||||
|
||||
// ============================================================================
|
||||
// Команды - Агенты
|
||||
|
|
@ -669,6 +702,8 @@ export default {
|
|||
'After tool execution fails': 'При неудачном выполнении инструмента',
|
||||
'When notifications are sent': 'При отправке уведомлений',
|
||||
'When the user submits a prompt': 'Когда пользователь отправляет промпт',
|
||||
'When a slash command expands into a prompt':
|
||||
'Когда slash-команда разворачивается в промпт',
|
||||
'When a new session is started': 'При запуске новой сессии',
|
||||
'Right before Qwen Code concludes its response':
|
||||
'Непосредственно перед завершением ответа Qwen Code',
|
||||
|
|
@ -693,6 +728,8 @@ export default {
|
|||
'Ввод в команду — это JSON с сообщением уведомления и типом.',
|
||||
'Input to command is JSON with original user prompt text.':
|
||||
'Ввод в команду — это JSON с исходным текстом промпта пользователя.',
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
|
||||
'Ввод в команду — это JSON с command_name, command_args и развернутым текстом промпта.',
|
||||
'Input to command is JSON with session start source.':
|
||||
'Ввод в команду — это JSON с источником запуска сессии.',
|
||||
'Input to command is JSON with session end reason.':
|
||||
|
|
@ -721,6 +758,8 @@ export default {
|
|||
'показать stderr только пользователю, но продолжить вызов инструмента',
|
||||
'block processing, erase original prompt, and show stderr to user only':
|
||||
'заблокировать обработку, стереть исходный промпт и показать stderr только пользователю',
|
||||
'block expanded prompt submission and show stderr to user only':
|
||||
'заблокировать отправку развернутого промпта и показать stderr только пользователю',
|
||||
'stdout shown to Qwen': 'stdout показан Qwen',
|
||||
'show stderr to user only (blocking errors ignored)':
|
||||
'показать stderr только пользователю (блокирующие ошибки игнорируются)',
|
||||
|
|
@ -769,6 +808,24 @@ export default {
|
|||
'Resume a previous session': 'Продолжить предыдущую сессию',
|
||||
'Fork the current conversation into a new session':
|
||||
'Создать ветку текущего разговора в новой сессии',
|
||||
'Spawn a background agent that inherits the full conversation':
|
||||
'Запустить фонового агента, который наследует весь разговор',
|
||||
'Please provide a directive. Usage: /fork <directive>':
|
||||
'Укажите инструкцию. Использование: /fork <инструкция>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'Нельзя создать fork, пока выполняется ответ или вызов инструмента. Дождитесь завершения или обработайте ожидающий вызов инструмента.',
|
||||
'Cannot fork before the first conversation turn.':
|
||||
'Нельзя создать fork до первого сообщения в разговоре.',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.':
|
||||
'Команде /fork требуется feature gate fork. Установите QWEN_CODE_ENABLE_FORK_SUBAGENT=1, чтобы включить его.',
|
||||
'The agent tool is unavailable; cannot fork.':
|
||||
'Инструмент агента недоступен; fork создать нельзя.',
|
||||
'Failed to launch fork: {{error}}':
|
||||
'Не удалось запустить fork: {{error}}',
|
||||
'User launched a background fork via /fork: {{directive}}':
|
||||
'Пользователь запустил фоновый fork через /fork: {{directive}}',
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.':
|
||||
'Создан fork в фоновом агенте. Он наследует этот разговор и работает без блокировки — отслеживайте его на панели фоновых задач; он сообщит результат после завершения.',
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'Нельзя создать ветку, пока выполняется ответ или вызов инструмента. Дождитесь завершения или обработайте ожидающий вызов инструмента.',
|
||||
'No conversation to branch.': 'Нет разговора для создания ветки.',
|
||||
|
|
@ -816,13 +873,14 @@ export default {
|
|||
// Команды - Режим подтверждения
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': 'Режим подтверждения инструментов',
|
||||
'{{mode}} mode': 'Режим {{mode}}',
|
||||
'Analyze only, do not modify files or execute commands':
|
||||
'Только анализ, без изменения файлов или выполнения команд',
|
||||
'Require approval for file edits or shell commands':
|
||||
'Требуется подтверждение для редактирования файлов или команд терминала',
|
||||
'Automatically approve file edits':
|
||||
'Автоматически подтверждать изменения файлов',
|
||||
'Use classifier to automatically approve safe tool calls':
|
||||
'Использовать классификатор для автоматического подтверждения безопасных вызовов инструментов',
|
||||
'Automatically approve all tools':
|
||||
'Автоматически подтверждать все инструменты',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
|
|
|
|||
|
|
@ -98,7 +98,39 @@ export default {
|
|||
'分析項目並創建定製的 QWEN.md 檔案',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'列出可用的 Qwen Code 工具。用法:/tools [desc]',
|
||||
'List available skills.': '列出可用技能。',
|
||||
'Open the skills panel (browse, search, toggle, pick).':
|
||||
'開啟技能面板(瀏覽、搜尋、啟停、選擇)。',
|
||||
'Manage Skills': '管理技能',
|
||||
'Skills configuration saved.': '技能設定已儲存。',
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.':
|
||||
'技能設定已儲存,但重新整理失敗:{{error}}。請重新啟動以確保新狀態生效。',
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.':
|
||||
'目前工作區未受信任,工作區設定會被合併設定忽略。請先執行 /trust,或直接編輯 ~/.qwen/settings.json 在使用者範圍管理技能。',
|
||||
'SkillManager not available.': 'SkillManager 不可用。',
|
||||
'Loading skills…': '正在載入技能…',
|
||||
'Failed to load skills: {{error}}': '載入技能失敗:{{error}}',
|
||||
'Failed to save skills configuration: {{error}}':
|
||||
'儲存技能設定失敗:{{error}}',
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.':
|
||||
'所有可用技能皆已停用。請編輯 ~/.qwen/settings.json 或 .qwen/settings.json(skills.disabled)以重新啟用。',
|
||||
'Press esc to close.': '按 Esc 關閉。',
|
||||
'{{count}} skills · ': '{{count}} 個技能 · ',
|
||||
'{{matched}} / {{total}} skills · ': '{{matched}} / {{total}} 個技能 · ',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope':
|
||||
'空白鍵 啟停 · 回車 選取(填入輸入框) · Esc 儲存並離開 · 工作區範圍',
|
||||
'Search:': '搜尋:',
|
||||
'type to filter…': '輸入以篩選…',
|
||||
'No skills are currently available.': '目前沒有可用的技能。',
|
||||
'All available skills are locked at a higher scope (see below).':
|
||||
'所有可用技能都被更高範圍鎖定(詳見下方)。',
|
||||
'No skills match the search.': '沒有符合搜尋條件的技能。',
|
||||
'Locked by higher-scope settings (cannot toggle here):':
|
||||
'被更高範圍設定鎖定(此處無法切換):',
|
||||
'higher scope': '更高範圍',
|
||||
' {{name}} {{description}} [locked: {{scope}}]':
|
||||
' {{name}} {{description}} [已鎖定:{{scope}}]',
|
||||
'↑/↓ navigate · backspace edits search': '↑/↓ 導覽 · 倒退 編輯搜尋',
|
||||
Bundled: '內建',
|
||||
'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:',
|
||||
'No tools available': '沒有可用工具',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -174,8 +206,8 @@ export default {
|
|||
'在瀏覽器中打開完整的 Qwen Code 文檔',
|
||||
'Configuration not available.': '配置不可用',
|
||||
'Connect an LLM provider': '連接 LLM 提供商',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'將最後的結果或代碼片段複製到剪貼板',
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)':
|
||||
'將最近的 AI 回應複製到剪貼簿(/copy N 複製倒數第 N 則)',
|
||||
'Show working-tree change stats versus HEAD':
|
||||
'顯示工作區相對 HEAD 的變更統計',
|
||||
'Could not determine current working directory.': '無法確定當前工作目錄。',
|
||||
|
|
@ -652,6 +684,7 @@ export default {
|
|||
'After tool execution fails': '工具執行失敗後',
|
||||
'When notifications are sent': '發送通知時',
|
||||
'When the user submits a prompt': '用戶提交提示時',
|
||||
'When a slash command expands into a prompt': '斜線命令展開為提示時',
|
||||
'When a new session is started': '新會話開始時',
|
||||
'Right before Qwen Code concludes its response': 'Qwen Code 結束響應之前',
|
||||
'When a subagent (Agent tool call) is started':
|
||||
|
|
@ -670,6 +703,8 @@ export default {
|
|||
'命令輸入為包含通知消息和類型的 JSON。',
|
||||
'Input to command is JSON with original user prompt text.':
|
||||
'命令輸入為包含原始用戶提示文本的 JSON。',
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
|
||||
'命令輸入為包含 command_name、command_args 和展開後提示文本的 JSON。',
|
||||
'Input to command is JSON with session start source.':
|
||||
'命令輸入為包含會話啟動來源的 JSON。',
|
||||
'Input to command is JSON with session end reason.':
|
||||
|
|
@ -692,6 +727,8 @@ export default {
|
|||
'僅向用戶顯示 stderr 但繼續工具調用',
|
||||
'block processing, erase original prompt, and show stderr to user only':
|
||||
'阻止處理,擦除原始提示,僅向用戶顯示 stderr',
|
||||
'block expanded prompt submission and show stderr to user only':
|
||||
'阻止提交展開後的提示,並僅向用戶顯示 stderr',
|
||||
'stdout shown to Qwen': '向 Qwen 顯示 stdout',
|
||||
'show stderr to user only (blocking errors ignored)':
|
||||
'僅向用戶顯示 stderr(忽略阻塞錯誤)',
|
||||
|
|
@ -719,6 +756,24 @@ export default {
|
|||
'根據你的聊天記錄生成個性化編程洞察',
|
||||
'Resume a previous session': '恢復先前會話',
|
||||
'Fork the current conversation into a new session': '將目前對話分支到新會話',
|
||||
'Spawn a background agent that inherits the full conversation':
|
||||
'啟動繼承完整對話的背景智能體',
|
||||
'Please provide a directive. Usage: /fork <directive>':
|
||||
'請提供指令。用法:/fork <指令>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'回應或工具呼叫正在進行時無法分支。請等待其完成或處理待確認的工具呼叫。',
|
||||
'Cannot fork before the first conversation turn.':
|
||||
'首次對話輪次前無法分支。',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.':
|
||||
'/fork 命令需要啟用 fork 功能開關。設定 QWEN_CODE_ENABLE_FORK_SUBAGENT=1 以啟用。',
|
||||
'The agent tool is unavailable; cannot fork.':
|
||||
'Agent 工具不可用;無法分支。',
|
||||
'Failed to launch fork: {{error}}': '啟動分支失敗:{{error}}',
|
||||
'the background agent could not be started.': '背景智能體無法啟動。',
|
||||
'User launched a background fork via /fork: {{directive}}':
|
||||
'使用者透過 /fork 啟動了背景分支:{{directive}}',
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.':
|
||||
'已分支到背景智能體。它會繼承此對話並以非阻塞方式執行,可在背景任務面板中追蹤;完成後會回報結果。',
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'回應或工具呼叫正在進行時無法分支。請等待其完成或處理待確認的工具呼叫。',
|
||||
'No conversation to branch.': '沒有可分支的對話。',
|
||||
|
|
@ -754,12 +809,13 @@ export default {
|
|||
'Available options:': '可用選項:',
|
||||
'Set UI language to {{name}}': '將 UI 語言設置為 {{name}}',
|
||||
'Tool Approval Mode': '工具審批模式',
|
||||
'{{mode}} mode': '{{mode}} 模式',
|
||||
'Analyze only, do not modify files or execute commands':
|
||||
'僅分析,不修改檔案或執行命令',
|
||||
'Require approval for file edits or shell commands':
|
||||
'需要批准檔案編輯或 shell 命令',
|
||||
'Automatically approve file edits': '自動批准檔案編輯',
|
||||
'Use classifier to automatically approve safe tool calls':
|
||||
'使用分類器自動批准安全的工具調用',
|
||||
'Automatically approve all tools': '自動批准所有工具',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'工作區審批模式已存在並具有優先級。用戶級別的更改將無效。',
|
||||
|
|
@ -1488,6 +1544,16 @@ export default {
|
|||
'Show current process memory diagnostics': '顯示目前程序的內存診斷。',
|
||||
'Record a CPU profile for Chrome DevTools analysis':
|
||||
'錄製 CPU 效能分析檔案,用於 Chrome DevTools 分析',
|
||||
'Roll back a standalone update to the previous version':
|
||||
'將獨立安裝回滾到上一個版本',
|
||||
'Rollback is not available in ACP mode.': '回滾在 ACP 模式下不可用。',
|
||||
'Rollback is only available for standalone installations.':
|
||||
'回滾僅適用於獨立安裝。',
|
||||
'Rollback successful. Restart your terminal to use the previous version.':
|
||||
'回滾成功。請重啟終端以使用上一個版本。',
|
||||
'Rollback failed:': '回滾失敗:',
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.':
|
||||
'在 Windows 上回滾需要手動操作。請將安裝目錄中的 qwen-code.old 重新命名為 qwen-code。',
|
||||
'Save a durable memory to the memory system.': '將持久記憶保存到記憶系統。',
|
||||
'Ask a quick side question without affecting the main conversation':
|
||||
'在不影響主對話的情況下快速提問旁支問題',
|
||||
|
|
|
|||
|
|
@ -109,7 +109,43 @@ export default {
|
|||
'分析项目并创建定制的 QWEN.md 文件',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'列出可用的 Qwen Code 工具。用法:/tools [desc]',
|
||||
'List available skills.': '列出可用技能。',
|
||||
'Open the skills panel (browse, search, toggle, pick).':
|
||||
'打开技能面板(浏览、搜索、启停、选择)。',
|
||||
// SkillsManagerDialog (`/skills` 弹出的面板)
|
||||
'Manage Skills': '管理技能',
|
||||
'Skills configuration saved.': '技能配置已保存。',
|
||||
'Skills configuration saved, but refresh failed: {{error}}. Restart to ensure the new state is applied.':
|
||||
'技能配置已保存,但刷新失败:{{error}}。请重启以确保新状态生效。',
|
||||
'Workspace is untrusted; workspace settings are ignored by the merged config. Run /trust first to persist skills changes here, or edit ~/.qwen/settings.json directly to manage skills at user scope.':
|
||||
'当前工作区未受信任,工作区设置会被合并配置忽略。请先执行 /trust,或直接编辑 ~/.qwen/settings.json 在用户范围管理技能。',
|
||||
'SkillManager not available.': 'SkillManager 不可用。',
|
||||
'Loading skills…': '正在加载技能…',
|
||||
'Failed to load skills: {{error}}': '加载技能失败:{{error}}',
|
||||
'Failed to save skills configuration: {{error}}':
|
||||
'保存技能配置失败:{{error}}',
|
||||
'All available skills are disabled. Edit ~/.qwen/settings.json or .qwen/settings.json (skills.disabled) to re-enable.':
|
||||
'所有可用技能均已禁用。请编辑 ~/.qwen/settings.json 或 .qwen/settings.json(skills.disabled)以重新启用。',
|
||||
'Press esc to close.': '按 Esc 关闭。',
|
||||
'{{count}} skills · ': '{{count}} 个技能 · ',
|
||||
'{{matched}} / {{total}} skills · ': '{{matched}} / {{total}} 个技能 · ',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope':
|
||||
'空格 启停 · 回车 选中(填入输入框) · Esc 保存并退出 · 工作区范围',
|
||||
'Search:': '搜索:',
|
||||
'type to filter…': '输入以过滤…',
|
||||
'No skills are currently available.': '当前没有可用的技能。',
|
||||
'All available skills are locked at a higher scope (see below).':
|
||||
'所有可用技能都被更高范围锁定(详见下方)。',
|
||||
'No skills match the search.': '没有匹配搜索的技能。',
|
||||
'Locked by higher-scope settings (cannot toggle here):':
|
||||
'被更高范围设置锁定(此处无法切换):',
|
||||
'higher scope': '更高范围',
|
||||
' {{name}} {{description}} [locked: {{scope}}]':
|
||||
' {{name}} {{description}} [已锁定:{{scope}}]',
|
||||
'↑/↓ navigate · backspace edits search': '↑/↓ 导航 · 退格 编辑搜索',
|
||||
// Note: Project / User / Extension are already translated elsewhere in
|
||||
// this file. `Bundled` is new — only the SkillsManagerDialog uses it
|
||||
// as a level label so far.
|
||||
Bundled: '内置',
|
||||
'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:',
|
||||
'No tools available': '没有可用工具',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -185,8 +221,8 @@ export default {
|
|||
'在浏览器中打开完整的 Qwen Code 文档',
|
||||
'Configuration not available.': '配置不可用',
|
||||
'Connect an LLM provider': '连接 LLM 提供商',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'将最后的结果或代码片段复制到剪贴板',
|
||||
'Copy the last AI response to clipboard (/copy N for Nth-latest)':
|
||||
'将最近的 AI 回复复制到剪贴板(/copy N 复制倒数第 N 条)',
|
||||
'Show working-tree change stats versus HEAD':
|
||||
'显示工作区相对 HEAD 的变更统计',
|
||||
'Could not determine current working directory.': '无法确定当前工作目录。',
|
||||
|
|
@ -702,6 +738,7 @@ export default {
|
|||
'After tool execution fails': '工具执行失败后',
|
||||
'When notifications are sent': '发送通知时',
|
||||
'When the user submits a prompt': '用户提交提示时',
|
||||
'When a slash command expands into a prompt': '斜杠命令展开为提示时',
|
||||
'When a new session is started': '新会话开始时',
|
||||
'Right before Qwen Code concludes its response': 'Qwen Code 结束响应之前',
|
||||
'When a subagent (Agent tool call) is started':
|
||||
|
|
@ -723,6 +760,8 @@ export default {
|
|||
'命令输入为包含通知消息和类型的 JSON。',
|
||||
'Input to command is JSON with original user prompt text.':
|
||||
'命令输入为包含原始用户提示文本的 JSON。',
|
||||
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
|
||||
'命令输入为包含 command_name、command_args 和展开后提示文本的 JSON。',
|
||||
'Input to command is JSON with session start source.':
|
||||
'命令输入为包含会话启动来源的 JSON。',
|
||||
'Input to command is JSON with session end reason.':
|
||||
|
|
@ -750,6 +789,8 @@ export default {
|
|||
'仅向用户显示 stderr 但继续工具调用',
|
||||
'block processing, erase original prompt, and show stderr to user only':
|
||||
'阻止处理,擦除原始提示,仅向用户显示 stderr',
|
||||
'block expanded prompt submission and show stderr to user only':
|
||||
'阻止提交展开后的提示,并仅向用户显示 stderr',
|
||||
'stdout shown to Qwen': '向 Qwen 显示 stdout',
|
||||
'show stderr to user only (blocking errors ignored)':
|
||||
'仅向用户显示 stderr(忽略阻塞错误)',
|
||||
|
|
@ -795,6 +836,24 @@ export default {
|
|||
// ============================================================================
|
||||
'Resume a previous session': '恢复先前会话',
|
||||
'Fork the current conversation into a new session': '将当前对话分支到新会话',
|
||||
'Spawn a background agent that inherits the full conversation':
|
||||
'启动继承完整对话的后台智能体',
|
||||
'Please provide a directive. Usage: /fork <directive>':
|
||||
'请提供指令。用法:/fork <指令>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'响应或工具调用正在进行时无法分支。请等待其完成或处理待确认的工具调用。',
|
||||
'Cannot fork before the first conversation turn.':
|
||||
'首次对话轮次前无法分支。',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.':
|
||||
'/fork 命令需要启用 fork 功能开关。设置 QWEN_CODE_ENABLE_FORK_SUBAGENT=1 以启用。',
|
||||
'The agent tool is unavailable; cannot fork.':
|
||||
'Agent 工具不可用;无法分支。',
|
||||
'Failed to launch fork: {{error}}': '启动分支失败:{{error}}',
|
||||
'the background agent could not be started.': '后台智能体无法启动。',
|
||||
'User launched a background fork via /fork: {{directive}}':
|
||||
'用户通过 /fork 启动了后台分支:{{directive}}',
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.':
|
||||
'已分支到后台智能体。它会继承此对话并以非阻塞方式运行,可在后台任务面板中跟踪;完成后会回报结果。',
|
||||
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.':
|
||||
'响应或工具调用正在进行时无法分支。请等待其完成或处理待确认的工具调用。',
|
||||
'No conversation to branch.': '没有可分支的对话。',
|
||||
|
|
@ -838,12 +897,13 @@ export default {
|
|||
// Commands - Approval Mode
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': '工具审批模式',
|
||||
'{{mode}} mode': '{{mode}} 模式',
|
||||
'Analyze only, do not modify files or execute commands':
|
||||
'仅分析,不修改文件或执行命令',
|
||||
'Require approval for file edits or shell commands':
|
||||
'需要批准文件编辑或 shell 命令',
|
||||
'Automatically approve file edits': '自动批准文件编辑',
|
||||
'Use classifier to automatically approve safe tool calls':
|
||||
'使用分类器自动批准安全的工具调用',
|
||||
'Automatically approve all tools': '自动批准所有工具',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'工作区审批模式已存在并具有优先级。用户级别的更改将无效。',
|
||||
|
|
@ -1723,6 +1783,16 @@ export default {
|
|||
'Show current process memory diagnostics': '显示当前进程的内存诊断。',
|
||||
'Record a CPU profile for Chrome DevTools analysis':
|
||||
'录制 CPU 性能分析文件,用于 Chrome DevTools 分析',
|
||||
'Roll back a standalone update to the previous version':
|
||||
'将独立安装回滚到上一个版本',
|
||||
'Rollback is not available in ACP mode.': '回滚在 ACP 模式下不可用。',
|
||||
'Rollback is only available for standalone installations.':
|
||||
'回滚仅适用于独立安装。',
|
||||
'Rollback successful. Restart your terminal to use the previous version.':
|
||||
'回滚成功。请重启终端以使用上一个版本。',
|
||||
'Rollback failed:': '回滚失败:',
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.':
|
||||
'在 Windows 上回滚需要手动操作。请将安装目录中的 qwen-code.old 重命名为 qwen-code。',
|
||||
'Save a durable memory to the memory system.':
|
||||
'将一条持久记忆保存到记忆系统。',
|
||||
'Show per-item context usage breakdown.': '显示按项目划分的上下文使用详情。',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,18 @@ import { rememberCommand } from '../ui/commands/rememberCommand.js';
|
|||
import { statuslineCommand } from '../ui/commands/statuslineCommand.js';
|
||||
import type { SlashCommand } from '../ui/commands/types.js';
|
||||
|
||||
const FORK_COMMAND_REQUIRED_KEYS = [
|
||||
'Spawn a background agent that inherits the full conversation',
|
||||
'Please provide a directive. Usage: /fork <directive>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.',
|
||||
'Cannot fork before the first conversation turn.',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.',
|
||||
'The agent tool is unavailable; cannot fork.',
|
||||
'Failed to launch fork: {{error}}',
|
||||
'User launched a background fork via /fork: {{directive}}',
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.',
|
||||
] as const;
|
||||
|
||||
const NON_ENGLISH_LANGUAGES = SUPPORTED_LANGUAGES.filter(
|
||||
(language) => language.code !== 'en',
|
||||
);
|
||||
|
|
@ -83,6 +95,12 @@ describe('must-translate locale coverage', () => {
|
|||
expect(missingKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('requires translation coverage for /fork user-facing strings', () => {
|
||||
expect(MUST_TRANSLATE_KEYS).toEqual(
|
||||
expect.arrayContaining([...FORK_COMMAND_REQUIRED_KEYS]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(NON_ENGLISH_LANGUAGES)(
|
||||
'does not fall back to English for required keys in %s',
|
||||
async (language) => {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ export const MUST_TRANSLATE_KEYS = [
|
|||
'Generate a one-line session recap now',
|
||||
'Rename the current conversation. --auto lets the fast model pick a title.',
|
||||
'Rewind conversation to a previous turn',
|
||||
'Spawn a background agent that inherits the full conversation',
|
||||
'Please provide a directive. Usage: /fork <directive>',
|
||||
'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.',
|
||||
'Cannot fork before the first conversation turn.',
|
||||
'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.',
|
||||
'The agent tool is unavailable; cannot fork.',
|
||||
'Failed to launch fork: {{error}}',
|
||||
'User launched a background fork via /fork: {{directive}}',
|
||||
'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.',
|
||||
'Processing summary...',
|
||||
'Project summary generated and saved successfully!',
|
||||
'Saved to: {{filePath}}',
|
||||
|
|
@ -29,6 +38,10 @@ export const MUST_TRANSLATE_KEYS = [
|
|||
'To request additional UI language packs, please open an issue on GitHub.',
|
||||
'Open MCP management dialog',
|
||||
'Manage MCP servers',
|
||||
'Open the skills panel (browse, search, toggle, pick).',
|
||||
'Manage Skills',
|
||||
'Skills configuration saved.',
|
||||
'Space toggle · Enter pick (fill input) · Esc save & exit · workspace scope',
|
||||
'Tools',
|
||||
'prompts',
|
||||
'tools',
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ describe('runNonInteractive', () => {
|
|||
}),
|
||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getHookSystem: vi.fn().mockReturnValue(undefined),
|
||||
isCronEnabled: vi.fn().mockReturnValue(false),
|
||||
getCronScheduler: vi.fn().mockReturnValue(null),
|
||||
setModelInvocableCommandsProvider: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ describe('handleSlashCommand', () => {
|
|||
let mockConfig: Config;
|
||||
let mockSettings: LoadedSettings;
|
||||
let abortController: AbortController;
|
||||
let mockFireUserPromptExpansionEvent: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -52,6 +53,7 @@ describe('handleSlashCommand', () => {
|
|||
getCommandsForMode: mockGetCommandsForMode,
|
||||
getModelInvocableCommands: mockGetModelInvocableCommands,
|
||||
});
|
||||
mockFireUserPromptExpansionEvent = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockConfig = {
|
||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||
|
|
@ -62,9 +64,11 @@ describe('handleSlashCommand', () => {
|
|||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getDisableAllHooks: vi.fn().mockReturnValue(false),
|
||||
hasHooksForEvent: vi.fn().mockReturnValue(true),
|
||||
getHookSystem: vi.fn().mockReturnValue({
|
||||
addFunctionHook: vi.fn().mockReturnValue('goal-hook-id'),
|
||||
removeFunctionHook: vi.fn().mockReturnValue(true),
|
||||
fireUserPromptExpansionEvent: mockFireUserPromptExpansionEvent,
|
||||
}),
|
||||
setModelInvocableCommandsProvider: vi.fn(),
|
||||
setModelInvocableCommandsExecutor: vi.fn(),
|
||||
|
|
@ -351,6 +355,220 @@ describe('handleSlashCommand', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should fire UserPromptExpansion hooks for submit_prompt commands', async () => {
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Expanded prompt' }],
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/custom with args',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
expect(mockFireUserPromptExpansionEvent).toHaveBeenCalledWith(
|
||||
'custom',
|
||||
'with args',
|
||||
'Expanded prompt',
|
||||
abortController.signal,
|
||||
);
|
||||
});
|
||||
|
||||
it('should append UserPromptExpansion additional context for submit_prompt commands', async () => {
|
||||
mockFireUserPromptExpansionEvent.mockResolvedValue({
|
||||
getBlockingError: () => ({ blocked: false }),
|
||||
shouldStopExecution: () => false,
|
||||
getAdditionalContext: () => 'Hook context',
|
||||
});
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Expanded prompt' }],
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/custom with args',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
if (result.type === 'submit_prompt') {
|
||||
expect(result.content).toEqual([
|
||||
{ text: 'Expanded prompt' },
|
||||
{ text: '\n\nHook context' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not fire UserPromptExpansion hooks when hooks are disabled', async () => {
|
||||
vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(true);
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: 'Expanded prompt',
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/custom',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(mockFireUserPromptExpansionEvent).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: 'Expanded prompt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not fire UserPromptExpansion hooks when no hooks are configured', async () => {
|
||||
vi.mocked(mockConfig.hasHooksForEvent).mockReturnValue(false);
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: 'Expanded prompt',
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/custom',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(mockFireUserPromptExpansionEvent).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: 'Expanded prompt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not fire UserPromptExpansion hooks when hook system is unavailable', async () => {
|
||||
vi.mocked(mockConfig.getHookSystem).mockReturnValue(undefined);
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: 'Expanded prompt',
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/custom',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(mockFireUserPromptExpansionEvent).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: 'Expanded prompt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should block submit_prompt commands when UserPromptExpansion blocks', async () => {
|
||||
mockFireUserPromptExpansionEvent.mockResolvedValue({
|
||||
getBlockingError: () => ({
|
||||
blocked: true,
|
||||
reason: 'Blocked by policy',
|
||||
}),
|
||||
shouldStopExecution: () => false,
|
||||
});
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: 'Expanded prompt',
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/custom',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'UserPromptExpansion blocked: Blocked by policy',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the block reason for blocked model-invocable command execution', async () => {
|
||||
mockFireUserPromptExpansionEvent.mockResolvedValue({
|
||||
getBlockingError: () => ({
|
||||
blocked: true,
|
||||
reason: 'Blocked by policy',
|
||||
}),
|
||||
shouldStopExecution: () => false,
|
||||
getEffectiveReason: () => 'fallback reason',
|
||||
});
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
kind: CommandKind.FILE,
|
||||
modelInvocable: true,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: 'Expanded prompt',
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||
|
||||
await handleSlashCommand(
|
||||
'/custom',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
const executor = vi.mocked(mockConfig.setModelInvocableCommandsExecutor)
|
||||
.mock.calls[0]?.[0];
|
||||
expect(executor).toBeDefined();
|
||||
|
||||
const content = await executor?.('custom', 'with args');
|
||||
|
||||
expect(content).toEqual({
|
||||
error: 'UserPromptExpansion blocked: Blocked by policy',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return unsupported for other built-in commands like /quit', async () => {
|
||||
const mockQuitCommand = {
|
||||
name: 'quit',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js'
|
|||
import type { LoadedSettings } from './config/settings.js';
|
||||
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
||||
import { t } from './i18n/index.js';
|
||||
import {
|
||||
appendUserPromptExpansionAdditionalContext,
|
||||
formatUserPromptExpansionBlockedMessage,
|
||||
serializeUserPromptExpansionPrompt,
|
||||
} from './utils/userPromptExpansionHook.js';
|
||||
|
||||
const debugLogger = createDebugLogger('NON_INTERACTIVE_COMMANDS');
|
||||
|
||||
|
|
@ -168,6 +173,60 @@ function handleCommandResult(
|
|||
}
|
||||
}
|
||||
|
||||
async function fireUserPromptExpansionHook(
|
||||
config: Config,
|
||||
commandName: string,
|
||||
commandArgs: string,
|
||||
content: PartListUnion,
|
||||
signal: AbortSignal,
|
||||
): Promise<{
|
||||
blockedResult?: NonInteractiveSlashCommandResult;
|
||||
content: PartListUnion;
|
||||
}> {
|
||||
if (
|
||||
config.getDisableAllHooks?.() ||
|
||||
!(config.hasHooksForEvent?.('UserPromptExpansion') ?? false)
|
||||
) {
|
||||
return { content };
|
||||
}
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) {
|
||||
return { content };
|
||||
}
|
||||
|
||||
const output = await hookSystem.fireUserPromptExpansionEvent(
|
||||
commandName,
|
||||
commandArgs,
|
||||
serializeUserPromptExpansionPrompt(content),
|
||||
signal,
|
||||
);
|
||||
if (!output) {
|
||||
return { content };
|
||||
}
|
||||
|
||||
const blockingError = output.getBlockingError();
|
||||
if (blockingError.blocked || output.shouldStopExecution()) {
|
||||
return {
|
||||
blockedResult: {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: formatUserPromptExpansionBlockedMessage(
|
||||
blockingError.reason || output.getEffectiveReason(),
|
||||
),
|
||||
},
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: appendUserPromptExpansionAdditionalContext(
|
||||
content,
|
||||
output.getAdditionalContext(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a slash command in a non-interactive environment.
|
||||
*
|
||||
|
|
@ -248,11 +307,23 @@ export const handleSlashCommand = async (
|
|||
name,
|
||||
args,
|
||||
},
|
||||
services: { config, settings, git: undefined, logger: null },
|
||||
services: { config, settings, logger: null },
|
||||
} as unknown as CommandContext;
|
||||
const result = await cmd.action(minimalContext, args);
|
||||
if (!result || result.type !== 'submit_prompt') return null;
|
||||
const content = result.content;
|
||||
const hookResult = await fireUserPromptExpansionHook(
|
||||
config,
|
||||
name,
|
||||
args,
|
||||
result.content,
|
||||
abortController.signal,
|
||||
);
|
||||
if (hookResult.blockedResult) {
|
||||
return hookResult.blockedResult.type === 'message'
|
||||
? { error: hookResult.blockedResult.content }
|
||||
: null;
|
||||
}
|
||||
const content = hookResult.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
|
|
@ -331,7 +402,6 @@ export const handleSlashCommand = async (
|
|||
services: {
|
||||
config,
|
||||
settings,
|
||||
git: undefined,
|
||||
logger,
|
||||
},
|
||||
ui: createNonInteractiveUI(),
|
||||
|
|
@ -357,6 +427,20 @@ export const handleSlashCommand = async (
|
|||
};
|
||||
}
|
||||
|
||||
if (result.type === 'submit_prompt') {
|
||||
const hookResult = await fireUserPromptExpansionHook(
|
||||
config,
|
||||
commandToExecute.name,
|
||||
args,
|
||||
result.content,
|
||||
abortController.signal,
|
||||
);
|
||||
if (hookResult.blockedResult) {
|
||||
return hookResult.blockedResult;
|
||||
}
|
||||
return handleCommandResult({ ...result, content: hookResult.content });
|
||||
}
|
||||
|
||||
// Handle different result types
|
||||
return handleCommandResult(result);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -211,6 +211,14 @@ describe('BuiltinCommandLoader', () => {
|
|||
expect(modelCmd?.name).toBe('model');
|
||||
});
|
||||
|
||||
it('should always register the /fork command', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const forkCmd = commands.find((c) => c.name === 'fork');
|
||||
expect(forkCmd).toBeDefined();
|
||||
expect(forkCmd?.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should include lsp command only when LSP is enabled', async () => {
|
||||
const disabledLoader = new BuiltinCommandLoader(mockConfig);
|
||||
const disabledCommands = await disabledLoader.loadCommands(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { diffCommand } from '../ui/commands/diffCommand.js';
|
|||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { exportCommand } from '../ui/commands/exportCommand.js';
|
||||
import { forkCommand } from '../ui/commands/forkCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { goalCommand } from '../ui/commands/goalCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
|
|
@ -102,6 +103,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
authCommand,
|
||||
branchCommand,
|
||||
btwCommand,
|
||||
forkCommand,
|
||||
bugCommand,
|
||||
clearCommand,
|
||||
compressCommand,
|
||||
|
|
|
|||
|
|
@ -33,16 +33,25 @@ describe('BundledSkillLoader', () => {
|
|||
let mockSkillManager: {
|
||||
listSkills: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockAddSessionAllowRule: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSkillManager = {
|
||||
listSkills: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
mockAddSessionAllowRule = vi.fn();
|
||||
mockConfig = {
|
||||
getSkillManager: vi.fn().mockReturnValue(mockSkillManager),
|
||||
isCronEnabled: vi.fn().mockReturnValue(false),
|
||||
getModel: vi.fn().mockReturnValue(undefined),
|
||||
getPermissionManager: vi
|
||||
.fn()
|
||||
.mockReturnValue({ addSessionAllowRule: mockAddSessionAllowRule }),
|
||||
// BundledSkillLoader filters via this. Default empty so existing
|
||||
// assertions about bundled skills surfacing stay true; per-test
|
||||
// cases override.
|
||||
getDisabledSkillNames: vi.fn().mockReturnValue(new Set<string>()),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
|
|
@ -155,6 +164,38 @@ describe('BundledSkillLoader', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('allowedTools grant', () => {
|
||||
it('grants allowedTools as session allow rules when the command runs', async () => {
|
||||
const skill = makeSkill({ allowedTools: ['Bash(git *)', 'Edit'] });
|
||||
mockSkillManager.listSkills.mockResolvedValue([skill]);
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
await commands[0].action!(
|
||||
{ invocation: { raw: '/review', args: '' } } as never,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(mockAddSessionAllowRule).toHaveBeenCalledTimes(2);
|
||||
expect(mockAddSessionAllowRule).toHaveBeenNthCalledWith(1, 'Bash(git *)');
|
||||
expect(mockAddSessionAllowRule).toHaveBeenNthCalledWith(2, 'Edit');
|
||||
});
|
||||
|
||||
it('does not grant when the bundled skill declares no allowedTools', async () => {
|
||||
const skill = makeSkill(); // no allowedTools
|
||||
mockSkillManager.listSkills.mockResolvedValue([skill]);
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
await commands[0].action!(
|
||||
{ invocation: { raw: '/review', args: '' } } as never,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(mockAddSessionAllowRule).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when listSkills throws', async () => {
|
||||
mockSkillManager.listSkills.mockRejectedValue(new Error('load failed'));
|
||||
|
||||
|
|
@ -329,4 +370,45 @@ describe('BundledSkillLoader', () => {
|
|||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name).toBe('review');
|
||||
});
|
||||
|
||||
describe('skills.disabled filter', () => {
|
||||
it('omits disabled bundled skills (case-insensitive)', async () => {
|
||||
mockSkillManager.listSkills.mockResolvedValue([
|
||||
makeSkill({ name: 'review' }),
|
||||
makeSkill({ name: 'batch' }),
|
||||
]);
|
||||
(
|
||||
mockConfig.getDisabledSkillNames as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(new Set(['REVIEW'.toLowerCase()]));
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands.map((c) => c.name)).toEqual(['batch']);
|
||||
});
|
||||
|
||||
it('reflects provider mutations on each load (live read)', async () => {
|
||||
mockSkillManager.listSkills.mockResolvedValue([
|
||||
makeSkill({ name: 'review' }),
|
||||
]);
|
||||
let disabled = new Set<string>();
|
||||
(
|
||||
mockConfig.getDisabledSkillNames as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(() => disabled);
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
|
||||
expect((await loader.loadCommands(signal)).map((c) => c.name)).toEqual([
|
||||
'review',
|
||||
]);
|
||||
|
||||
disabled = new Set(['review']);
|
||||
expect(await loader.loadCommands(signal)).toEqual([]);
|
||||
|
||||
disabled = new Set<string>();
|
||||
expect((await loader.loadCommands(signal)).map((c) => c.name)).toEqual([
|
||||
'review',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
createDebugLogger,
|
||||
appendToLastTextPart,
|
||||
buildSkillLlmContent,
|
||||
applySkillAllowedTools,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { dirname } from 'node:path';
|
||||
import type { ICommandLoader } from './types.js';
|
||||
|
|
@ -45,7 +46,7 @@ export class BundledSkillLoader implements ICommandLoader {
|
|||
|
||||
// Hide skills whose allowedTools require cron when cron is disabled
|
||||
const cronEnabled = this.config?.isCronEnabled() ?? false;
|
||||
const skills = allSkills.filter((skill) => {
|
||||
const cronVisible = allSkills.filter((skill) => {
|
||||
if (
|
||||
!cronEnabled &&
|
||||
skill.allowedTools?.some((t) => t.startsWith('cron_'))
|
||||
|
|
@ -58,8 +59,18 @@ export class BundledSkillLoader implements ICommandLoader {
|
|||
return true;
|
||||
});
|
||||
|
||||
// Apply user-controlled `skills.disabled` filter HERE so disabling a
|
||||
// bundled skill cannot accidentally hide a same-named built-in
|
||||
// command or MCP prompt (which would happen if we routed this
|
||||
// through `CommandService`'s global denylist instead).
|
||||
const disabled =
|
||||
this.config?.getDisabledSkillNames() ?? new Set<string>();
|
||||
const skills = cronVisible.filter(
|
||||
(skill) => !disabled.has(skill.name.toLowerCase()),
|
||||
);
|
||||
|
||||
debugLogger.debug(
|
||||
`Loaded ${skills.length} bundled skill(s) as slash commands`,
|
||||
`Loaded ${skills.length} bundled skill(s) as slash commands; ${cronVisible.length - skills.length} hidden by skills.disabled`,
|
||||
);
|
||||
|
||||
return skills.map((skill) => ({
|
||||
|
|
@ -72,7 +83,19 @@ export class BundledSkillLoader implements ICommandLoader {
|
|||
modelInvocable: !skill.disableModelInvocation,
|
||||
argumentHint: skill.argumentHint,
|
||||
whenToUse: skill.whenToUse,
|
||||
skillDetail: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
body: skill.body,
|
||||
level: skill.level,
|
||||
},
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
// Auto-approve the skill's declared allowedTools before its body is submitted.
|
||||
applySkillAllowedTools(
|
||||
this.config?.getPermissionManager(),
|
||||
skill.allowedTools,
|
||||
);
|
||||
|
||||
// Resolve template variables in skill body
|
||||
let body = skill.body;
|
||||
const modelId = this.config?.getModel()?.trim() || '';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SkillCommandLoader } from './SkillCommandLoader.js';
|
||||
import { CommandKind } from '../ui/commands/types.js';
|
||||
import { CommandKind, type CommandContext } from '../ui/commands/types.js';
|
||||
import {
|
||||
buildSkillLlmContent,
|
||||
type Config,
|
||||
|
|
@ -31,15 +31,24 @@ function makeSkillPrompt(body: string): string {
|
|||
describe('SkillCommandLoader', () => {
|
||||
let mockConfig: Config;
|
||||
let mockSkillManager: { listSkills: ReturnType<typeof vi.fn> };
|
||||
let mockAddSessionAllowRule: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSkillManager = {
|
||||
listSkills: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
mockAddSessionAllowRule = vi.fn();
|
||||
mockConfig = {
|
||||
getSkillManager: vi.fn().mockReturnValue(mockSkillManager),
|
||||
getBareMode: vi.fn().mockReturnValue(false),
|
||||
getPermissionManager: vi
|
||||
.fn()
|
||||
.mockReturnValue({ addSessionAllowRule: mockAddSessionAllowRule }),
|
||||
// SkillCommandLoader filters via this. Default to empty so existing
|
||||
// assertions about "all skills surface" stay true; per-test cases
|
||||
// override to verify the filter behavior.
|
||||
getDisabledSkillNames: vi.fn().mockReturnValue(new Set<string>()),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
|
|
@ -331,4 +340,94 @@ describe('SkillCommandLoader', () => {
|
|||
'ext-skill',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('allowedTools grant', () => {
|
||||
it('grants allowedTools as session allow rules when the command runs', async () => {
|
||||
const skill = makeSkill({
|
||||
level: 'user',
|
||||
allowedTools: ['Bash(git *)', 'Edit'],
|
||||
});
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'user' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
await commands[0].action?.({} as CommandContext, '');
|
||||
|
||||
expect(mockAddSessionAllowRule).toHaveBeenCalledTimes(2);
|
||||
expect(mockAddSessionAllowRule).toHaveBeenNthCalledWith(1, 'Bash(git *)');
|
||||
expect(mockAddSessionAllowRule).toHaveBeenNthCalledWith(2, 'Edit');
|
||||
});
|
||||
|
||||
it('does not grant when the skill declares no allowedTools', async () => {
|
||||
const skill = makeSkill({ level: 'user' }); // no allowedTools
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
Promise.resolve(level === 'user' ? [skill] : []),
|
||||
);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
await commands[0].action?.({} as CommandContext, '');
|
||||
|
||||
expect(mockAddSessionAllowRule).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('skills.disabled filter', () => {
|
||||
it('omits disabled skills (case-insensitive) from the command list', async () => {
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) => {
|
||||
if (level === 'user')
|
||||
return Promise.resolve([
|
||||
makeSkill({ name: 'KeepMe', level: 'user' }),
|
||||
makeSkill({ name: 'HideMe', level: 'user' }),
|
||||
]);
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
);
|
||||
// Disabled set is lower-case (matches Config.getDisabledSkillNames
|
||||
// contract). Loader compares with `.toLowerCase()`.
|
||||
(
|
||||
mockConfig.getDisabledSkillNames as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(new Set(['hideme']));
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands.map((c) => c.name)).toEqual(['KeepMe']);
|
||||
});
|
||||
|
||||
it('reflects provider mutations on each load (live read)', async () => {
|
||||
// Regression: the provider must be called per-load, not cached, so
|
||||
// CommandService rebuilds (triggered by `reloadCommands`) pick up
|
||||
// the latest `skills.disabled`. A frozen-at-construction snapshot
|
||||
// would be a silent regression.
|
||||
mockSkillManager.listSkills.mockImplementation(
|
||||
({ level }: { level: string }) =>
|
||||
level === 'user'
|
||||
? Promise.resolve([makeSkill({ name: 'foo', level: 'user' })])
|
||||
: Promise.resolve([]),
|
||||
);
|
||||
let disabled = new Set<string>();
|
||||
(
|
||||
mockConfig.getDisabledSkillNames as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(() => disabled);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
|
||||
const first = await loader.loadCommands(signal);
|
||||
expect(first.map((c) => c.name)).toEqual(['foo']);
|
||||
|
||||
disabled = new Set(['foo']);
|
||||
const second = await loader.loadCommands(signal);
|
||||
expect(second).toEqual([]);
|
||||
|
||||
disabled = new Set<string>();
|
||||
const third = await loader.loadCommands(signal);
|
||||
expect(third.map((c) => c.name)).toEqual(['foo']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
createDebugLogger,
|
||||
appendToLastTextPart,
|
||||
buildSkillLlmContent,
|
||||
applySkillAllowedTools,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { dirname } from 'node:path';
|
||||
import type { ICommandLoader } from './types.js';
|
||||
|
|
@ -56,11 +57,22 @@ export class SkillCommandLoader implements ICommandLoader {
|
|||
|
||||
const allSkills = [...userSkills, ...projectSkills, ...extensionSkills];
|
||||
|
||||
debugLogger.debug(
|
||||
`Loaded ${userSkills.length} user + ${projectSkills.length} project + ${extensionSkills.length} extension skill(s) as slash commands`,
|
||||
// Apply user-controlled `skills.disabled` filter HERE (inside the
|
||||
// skill loader) rather than via `CommandService`'s global denylist —
|
||||
// a global filter would also hide a same-named built-in command or
|
||||
// MCP prompt. See `Config.getDisabledSkillNames` for why this is a
|
||||
// live-read provider rather than a frozen field.
|
||||
const disabled =
|
||||
this.config?.getDisabledSkillNames() ?? new Set<string>();
|
||||
const visibleSkills = allSkills.filter(
|
||||
(skill) => !disabled.has(skill.name.toLowerCase()),
|
||||
);
|
||||
|
||||
return allSkills.map((skill) => {
|
||||
debugLogger.debug(
|
||||
`Loaded ${userSkills.length} user + ${projectSkills.length} project + ${extensionSkills.length} extension skill(s) as slash commands; ${allSkills.length - visibleSkills.length} hidden by skills.disabled`,
|
||||
);
|
||||
|
||||
return visibleSkills.map((skill) => {
|
||||
const isExtension = skill.level === 'extension';
|
||||
|
||||
// Extension skills need explicit description or whenToUse to be
|
||||
|
|
@ -95,7 +107,19 @@ export class SkillCommandLoader implements ICommandLoader {
|
|||
modelInvocable,
|
||||
argumentHint: skill.argumentHint,
|
||||
whenToUse: skill.whenToUse,
|
||||
skillDetail: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
body: skill.body,
|
||||
level: skill.level,
|
||||
},
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
// Auto-approve the skill's declared allowedTools before its body is submitted.
|
||||
applySkillAllowedTools(
|
||||
this.config?.getPermissionManager(),
|
||||
skill.allowedTools,
|
||||
);
|
||||
|
||||
const body = buildSkillLlmContent(
|
||||
dirname(skill.filePath),
|
||||
skill.body,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
import { vi } from 'vitest';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import type { GitService } from '@qwen-code/qwen-code-core';
|
||||
import type { SessionStatsState } from '../ui/contexts/SessionContext.js';
|
||||
import { ToolCallDecision } from '../ui/contexts/SessionContext.js';
|
||||
|
||||
|
|
@ -41,7 +40,6 @@ export const createMockCommandContext = (
|
|||
merged: {},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -141,7 +141,11 @@ import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
|||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import {
|
||||
useVimMode,
|
||||
useVimModeActions,
|
||||
useVimModeState,
|
||||
} from './contexts/VimModeContext.js';
|
||||
import { useSessionStats } from './contexts/SessionContext.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
|
|
@ -171,6 +175,8 @@ describe('AppContainer State Management', () => {
|
|||
const mockedUseAutoAcceptIndicator = useAutoAcceptIndicator as Mock;
|
||||
const mockedUseGitBranchName = useGitBranchName as Mock;
|
||||
const mockedUseVimMode = useVimMode as Mock;
|
||||
const mockedUseVimModeActions = useVimModeActions as Mock;
|
||||
const mockedUseVimModeState = useVimModeState as Mock;
|
||||
const mockedUseSessionStats = useSessionStats as Mock;
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
const mockedUseLogger = useLogger as Mock;
|
||||
|
|
@ -309,6 +315,14 @@ describe('AppContainer State Management', () => {
|
|||
isVimEnabled: false,
|
||||
toggleVimEnabled: vi.fn(),
|
||||
});
|
||||
mockedUseVimModeActions.mockReturnValue({
|
||||
toggleVimEnabled: vi.fn(),
|
||||
setVimMode: vi.fn(),
|
||||
});
|
||||
mockedUseVimModeState.mockReturnValue({
|
||||
vimEnabled: false,
|
||||
vimMode: 'NORMAL',
|
||||
});
|
||||
mockedUseSessionStats.mockReturnValue({ stats: {} });
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
|
|
@ -770,6 +784,71 @@ describe('AppContainer State Management', () => {
|
|||
capturedOnCancelSubmit(info);
|
||||
};
|
||||
|
||||
it('does not fire outer cancel handler on Esc when vim is enabled in INSERT mode', async () => {
|
||||
mockedUseVimModeState.mockReturnValue({
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT',
|
||||
});
|
||||
const cancelSpy = vi.fn();
|
||||
installCancelCapture({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: cancelSpy,
|
||||
retryLastPrompt: vi.fn(),
|
||||
});
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
});
|
||||
mockedUseMessageQueue.mockReturnValue({
|
||||
messageQueue: [],
|
||||
addMessage: vi.fn(),
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const handleKeypress = mockedUseKeypress.mock.calls
|
||||
.map((call) => call[0])
|
||||
.reverse()
|
||||
.find(
|
||||
(handler): handler is (key: Key) => void =>
|
||||
typeof handler === 'function' &&
|
||||
handler.toString().includes('handleExit'),
|
||||
) as ((key: Key) => void) | undefined;
|
||||
expect(handleKeypress).toBeDefined();
|
||||
|
||||
const escKey: Key = {
|
||||
name: 'escape',
|
||||
sequence: '\u001b',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
};
|
||||
handleKeypress!(escKey);
|
||||
|
||||
// In vim INSERT mode, Esc must NOT trigger the outer cancel handler.
|
||||
expect(cancelSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not repopulate the buffer with the previous prompt on ESC cancel', async () => {
|
||||
const mockSetText = vi.fn();
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
getAllGeminiMdFilenames,
|
||||
ShellExecutionService,
|
||||
Storage,
|
||||
createInstructionsLoadedCallback,
|
||||
SessionEndReason,
|
||||
generatePromptSuggestion,
|
||||
logPromptSuggestion,
|
||||
|
|
@ -120,7 +121,10 @@ import {
|
|||
computeApiTruncationIndex,
|
||||
isRealUserTurn,
|
||||
} from './utils/historyMapping.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import {
|
||||
useVimModeState,
|
||||
useVimModeActions,
|
||||
} from './contexts/VimModeContext.js';
|
||||
import { CompactModeProvider } from './contexts/CompactModeContext.js';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import { calculatePromptWidths } from './components/InputPrompt.js';
|
||||
|
|
@ -187,6 +191,7 @@ import { useDialogClose } from './hooks/useDialogClose.js';
|
|||
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
|
||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||
import { useSkillsManagerDialog } from './hooks/useSkillsManagerDialog.js';
|
||||
import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js';
|
||||
import { useMcpDialog } from './hooks/useMcpDialog.js';
|
||||
import { useHooksDialog } from './hooks/useHooksDialog.js';
|
||||
|
|
@ -207,6 +212,7 @@ import {
|
|||
isSyntheticHistoryItem,
|
||||
itemsAfterAreOnlySynthetic,
|
||||
} from './utils/historyUtils.js';
|
||||
import { MAIN_CONTENT_HEIGHT_RESERVATION } from './utils/layoutUtils.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
const debugLogger = createDebugLogger('APP_CONTAINER');
|
||||
|
|
@ -1061,7 +1067,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const openHelpDialog = useCallback(() => setHelpDialogOpen(true), []);
|
||||
const closeHelpDialog = useCallback(() => setHelpDialogOpen(false), []);
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
const { vimEnabled, vimMode } = useVimModeState();
|
||||
const { toggleVimEnabled } = useVimModeActions();
|
||||
|
||||
const {
|
||||
isSubagentCreateDialogOpen,
|
||||
|
|
@ -1073,6 +1080,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openAgentsManagerDialog,
|
||||
closeAgentsManagerDialog,
|
||||
} = useAgentsManagerDialog();
|
||||
const {
|
||||
isSkillsManagerDialogOpen,
|
||||
openSkillsManagerDialog,
|
||||
closeSkillsManagerDialog,
|
||||
} = useSkillsManagerDialog();
|
||||
const {
|
||||
isExtensionsManagerDialogOpen,
|
||||
openExtensionsManagerDialog,
|
||||
|
|
@ -1124,6 +1136,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openSkillsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openHooksDialog,
|
||||
|
|
@ -1152,6 +1165,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openSkillsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openHooksDialog,
|
||||
|
|
@ -1175,6 +1189,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
reloadCommands,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
|
|
@ -1324,6 +1339,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
config.isTrustedFolder(),
|
||||
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
|
||||
config.getContextRuleExcludes(),
|
||||
{
|
||||
loadReason: 'refresh',
|
||||
onInstructionsLoaded: createInstructionsLoadedCallback(() =>
|
||||
config.getHookSystem(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
config.setUserMemory(memoryContent);
|
||||
|
|
@ -2227,6 +2248,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const [compactMode, setCompactMode] = useState<boolean>(
|
||||
settings.merged.ui?.compactMode ?? false,
|
||||
);
|
||||
const [compactInline] = useState<boolean>(
|
||||
settings.merged.ui?.compactInline ?? false,
|
||||
);
|
||||
const configuredRenderMode = settings.merged.ui?.renderMode;
|
||||
const [renderMode, setRenderMode] = useState<RenderMode>(
|
||||
configuredRenderMode === 'raw' ? 'raw' : 'render',
|
||||
|
|
@ -2310,6 +2334,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
showIdeRestartPrompt ||
|
||||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen ||
|
||||
isSkillsManagerDialogOpen ||
|
||||
isMcpDialogOpen ||
|
||||
isHooksDialogOpen ||
|
||||
isApprovalModeDialogOpen ||
|
||||
|
|
@ -2368,7 +2393,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0;
|
||||
const availableTerminalHeight = Math.max(
|
||||
0,
|
||||
terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight,
|
||||
terminalHeight -
|
||||
controlsHeight -
|
||||
staticExtraHeight -
|
||||
MAIN_CONTENT_HEIGHT_RESERVATION -
|
||||
tabBarHeight,
|
||||
);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
|
|
@ -2913,6 +2942,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||
return;
|
||||
} else if (keyMatchers[Command.ESCAPE](key)) {
|
||||
// In vim INSERT mode, let vim's own handler (in InputPrompt) consume
|
||||
// the Esc to switch to NORMAL mode. Without this guard, both handlers
|
||||
// fire on the same keypress — vim switches mode AND AppContainer
|
||||
// shows "Press Esc again to clear" or cancels the stream.
|
||||
if (vimEnabled && vimMode === 'INSERT') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss or cancel btw side-question on Escape,
|
||||
// but only when btw is actually visible (not hidden behind a dialog).
|
||||
if (btwItem && !dialogsVisibleRef.current) {
|
||||
|
|
@ -3134,6 +3171,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
setRenderMode,
|
||||
refreshStatic,
|
||||
handleDoubleEscRewind,
|
||||
vimEnabled,
|
||||
vimMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -3313,6 +3352,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Skills manager dialog (`/skills`)
|
||||
isSkillsManagerDialogOpen,
|
||||
// Extensions manager dialog
|
||||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
|
|
@ -3439,6 +3480,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Skills manager dialog (`/skills`)
|
||||
isSkillsManagerDialogOpen,
|
||||
// Extensions manager dialog
|
||||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
|
|
@ -3510,6 +3553,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Skills manager dialog (`/skills`)
|
||||
openSkillsManagerDialog,
|
||||
closeSkillsManagerDialog,
|
||||
reloadCommands,
|
||||
setInputBuffer: buffer.setText,
|
||||
// Extensions manager dialog
|
||||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
|
|
@ -3586,6 +3634,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Skills manager dialog (`/skills`)
|
||||
openSkillsManagerDialog,
|
||||
closeSkillsManagerDialog,
|
||||
reloadCommands,
|
||||
buffer.setText,
|
||||
// Extensions manager dialog
|
||||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
|
|
@ -3625,8 +3678,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
);
|
||||
|
||||
const compactModeValue = useMemo(
|
||||
() => ({ compactMode, setCompactMode }),
|
||||
[compactMode, setCompactMode],
|
||||
() => ({ compactMode, compactInline, setCompactMode }),
|
||||
[compactMode, compactInline, setCompactMode],
|
||||
);
|
||||
const renderModeValue = useMemo(
|
||||
() => ({ renderMode, setRenderMode }),
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ describe('approvalModeCommand', () => {
|
|||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('yolo');
|
||||
expect(result.content).toContain('YOLO');
|
||||
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
|
||||
});
|
||||
|
||||
|
|
@ -103,7 +103,8 @@ describe('approvalModeCommand', () => {
|
|||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('default');
|
||||
// "default" is displayed using its formatted name, not the raw enum value.
|
||||
expect(result.content).toContain('Ask permissions');
|
||||
expect(mockSetApprovalMode).toHaveBeenCalledWith('default');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
ApprovalMode as ApprovalModeEnum,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { emitAutoModeEntryNotices } from '../hooks/useAutoAcceptIndicator.js';
|
||||
import { formatApprovalModeName } from '../utils/approvalModeDisplay.js';
|
||||
|
||||
/**
|
||||
* Parses the argument string and returns the corresponding ApprovalMode if valid.
|
||||
|
|
@ -101,7 +102,9 @@ export const approvalModeCommand: SlashCommand = {
|
|||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Approval mode set to "{{mode}}"', { mode }),
|
||||
content: t('Approval mode set to "{{mode}}"', {
|
||||
mode: formatApprovalModeName(mode),
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function makeCtx(
|
|||
getSessionService: () => sessionService,
|
||||
} as unknown as NonNullable<CommandContext['services']['config']>);
|
||||
return {
|
||||
services: { config, settings: {} as never, git: undefined, logger: null },
|
||||
services: { config, settings: {} as never, logger: null },
|
||||
ui: {
|
||||
isIdleRef: { current: overrides.isIdle ?? true },
|
||||
} as unknown as CommandContext['ui'],
|
||||
|
|
@ -70,7 +70,7 @@ describe('branchCommand', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('exposes /fork as an alias', () => {
|
||||
expect(branchCommand.altNames).toContain('fork');
|
||||
it('no longer aliases /fork (now a separate background-fork command)', () => {
|
||||
expect(branchCommand.altNames ?? []).not.toContain('fork');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { t } from '../../i18n/index.js';
|
|||
|
||||
export const branchCommand: SlashCommand = {
|
||||
name: 'branch',
|
||||
altNames: ['fork'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
get description() {
|
||||
return t('Fork the current conversation into a new session');
|
||||
|
|
|
|||
|
|
@ -11,11 +11,15 @@ import {
|
|||
uiTelemetryService,
|
||||
SessionEndReason,
|
||||
ToolNames,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
hasBlockingBackgroundWork,
|
||||
resetBackgroundStateForSessionSwitch,
|
||||
} from '../utils/backgroundWorkUtils.js';
|
||||
import process from 'node:process';
|
||||
|
||||
const debugLogger = createDebugLogger('CLEAR_COMMAND');
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
name: 'clear',
|
||||
|
|
@ -28,6 +32,15 @@ export const clearCommand: SlashCommand = {
|
|||
action: async (context, _args) => {
|
||||
const { config } = context.services;
|
||||
|
||||
const memBefore = process.memoryUsage();
|
||||
if (debugLogger.isEnabled()) {
|
||||
debugLogger.debug(
|
||||
`[CLEAR_START] Starting clear command, ` +
|
||||
`heapUsed=${(memBefore.heapUsed / 1024 / 1024).toFixed(1)}MB, ` +
|
||||
`rss=${(memBefore.rss / 1024 / 1024).toFixed(1)}MB`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config) {
|
||||
if (hasBlockingBackgroundWork(config)) {
|
||||
const content =
|
||||
|
|
@ -95,6 +108,19 @@ export const clearCommand: SlashCommand = {
|
|||
context.ui.clear();
|
||||
}
|
||||
|
||||
const memAfter = process.memoryUsage();
|
||||
if (debugLogger.isEnabled()) {
|
||||
const heapDiff = (memAfter.heapUsed - memBefore.heapUsed) / 1024 / 1024;
|
||||
const rssDiff = (memAfter.rss - memBefore.rss) / 1024 / 1024;
|
||||
debugLogger.debug(
|
||||
`[CLEAR_END] Clear command completed, ` +
|
||||
`heapUsed=${(memAfter.heapUsed / 1024 / 1024).toFixed(1)}MB, ` +
|
||||
`rss=${(memAfter.rss / 1024 / 1024).toFixed(1)}MB, ` +
|
||||
`heapDiff=${heapDiff.toFixed(1)}MB, ` +
|
||||
`rssDiff=${rssDiff.toFixed(1)}MB`,
|
||||
);
|
||||
}
|
||||
|
||||
if (context.executionMode !== 'interactive') {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
|
|
|
|||
|
|
@ -160,6 +160,32 @@ describe('copyCommand', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should not copy thought parts from the last AI message', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithThoughtPart = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'internal reasoning', thought: true },
|
||||
{ text: 'Visible report' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue(historyWithThoughtPart);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Visible report');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out non-text parts', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
|
|
@ -707,6 +733,292 @@ describe('copyCommand', () => {
|
|||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should copy the Nth-last AI message with /copy N', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const history = [
|
||||
{ role: 'model', parts: [{ text: 'oldest AI reply' }] },
|
||||
{ role: 'user', parts: [{ text: 'user 1' }] },
|
||||
{ role: 'model', parts: [{ text: 'middle AI reply' }] },
|
||||
{ role: 'user', parts: [{ text: 'user 2' }] },
|
||||
{ role: 'model', parts: [{ text: 'newest AI reply' }] },
|
||||
];
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue(history);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '2');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('middle AI reply');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'AI message 2 copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should label the error with AI message N when /copy N has no text', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const history = [
|
||||
{ role: 'model', parts: [{ image: 'base64data' }] },
|
||||
{ role: 'user', parts: [{ text: 'user' }] },
|
||||
{ role: 'model', parts: [{ text: 'newest reply with text' }] },
|
||||
];
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue(history);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '2');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'AI message 2 contains no text to copy.',
|
||||
});
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should label the error with AI message N when /copy N <selector> misses', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const history = [
|
||||
{ role: 'model', parts: [{ text: 'no code blocks here' }] },
|
||||
{ role: 'user', parts: [{ text: 'user' }] },
|
||||
{ role: 'model', parts: [{ text: 'newest reply' }] },
|
||||
];
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue(history);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '2 code');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No matching code block found in AI message 2.',
|
||||
});
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should treat /copy 1 the same as /copy (last AI message)', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const history = [
|
||||
{ role: 'model', parts: [{ text: 'earlier reply' }] },
|
||||
{ role: 'model', parts: [{ text: 'latest reply' }] },
|
||||
];
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue(history);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '1');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('latest reply');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should combine /copy N with a code sub-selector', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const history = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: [
|
||||
'```python',
|
||||
'print("from older")',
|
||||
'```',
|
||||
'```js',
|
||||
'console.log("from older js")',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'newer prompt' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'newer reply (no code)' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue(history);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '2 code python');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('print("from older")');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'python code block 1 copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve /copy N code <lang> M to the Mth lang block in Nth-last message', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const history = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: [
|
||||
'```python',
|
||||
'first_in_oldest = 1',
|
||||
'```',
|
||||
'```python',
|
||||
'second_in_oldest = 2',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'next' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: ['```python', 'middle_only = 1', '```'].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'and then' }] },
|
||||
{ role: 'model', parts: [{ text: 'newest plain reply' }] },
|
||||
];
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue(history);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '3 code python 2');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('second_in_oldest = 2');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'python code block 2 copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should combine /copy N with a latex sub-selector', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const history = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: ['$$', '\\alpha + \\beta', '$$'].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'next prompt' }] },
|
||||
{ role: 'model', parts: [{ text: 'plain newer reply' }] },
|
||||
];
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue(history);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '2 latex');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('\\alpha + \\beta');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'LaTeX block 1 copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject /copy 0 with a friendly error', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue([
|
||||
{ role: 'model', parts: [{ text: 'reply' }] },
|
||||
]);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '0');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Message index must be a positive integer (1 = last AI message).',
|
||||
});
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should report when /copy N exceeds the AI message count', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue([
|
||||
{ role: 'model', parts: [{ text: 'only reply' }] },
|
||||
]);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '5');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Only 1 AI message in this session.',
|
||||
});
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pluralize the out-of-range message when multiple AI messages exist', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue([
|
||||
{ role: 'model', parts: [{ text: 'first' }] },
|
||||
{ role: 'model', parts: [{ text: 'second' }] },
|
||||
{ role: 'model', parts: [{ text: 'third' }] },
|
||||
]);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '99');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Only 3 AI messages in this session.',
|
||||
});
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve /copy code <lang> N as a code-block index, not message index', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockGetHistoryShallow.mockReturnValue([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: [
|
||||
'```python',
|
||||
'first = 1',
|
||||
'```',
|
||||
'```python',
|
||||
'second = 2',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, 'code python 2');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('second = 2');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'python code block 2 copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unavailable config service', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
|
|
|
|||
|
|
@ -337,50 +337,103 @@ function formatCodeBlockLabel(
|
|||
return `Code block ${block.index}`;
|
||||
}
|
||||
|
||||
function parseLeadingMessageIndex(args: string): {
|
||||
messageIndex: number | null;
|
||||
subArgs: string;
|
||||
} {
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed) return { messageIndex: null, subArgs: '' };
|
||||
|
||||
const firstWhitespace = trimmed.search(/\s/);
|
||||
const firstToken =
|
||||
firstWhitespace === -1 ? trimmed : trimmed.slice(0, firstWhitespace);
|
||||
|
||||
if (!/^\d+$/.test(firstToken)) {
|
||||
return { messageIndex: null, subArgs: args };
|
||||
}
|
||||
|
||||
return {
|
||||
messageIndex: Number(firstToken),
|
||||
subArgs: firstWhitespace === -1 ? '' : trimmed.slice(firstWhitespace + 1),
|
||||
};
|
||||
}
|
||||
|
||||
export const copyCommand: SlashCommand = {
|
||||
name: 'copy',
|
||||
get description() {
|
||||
return t('Copy the last result or code snippet to clipboard');
|
||||
return t('Copy the last AI response to clipboard (/copy N for Nth-latest)');
|
||||
},
|
||||
argumentHint: '[N]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive'] as const,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
|
||||
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
|
||||
const chat = await context.services.config?.getGeminiClient()?.getChat();
|
||||
const history = chat?.getHistoryShallow();
|
||||
const aiMessages = history?.filter((item) => item.role === 'model') ?? [];
|
||||
|
||||
// Get the last message from the AI (model role)
|
||||
const lastAiMessage = history
|
||||
? history.filter((item) => item.role === 'model').pop()
|
||||
: undefined;
|
||||
|
||||
if (!lastAiMessage) {
|
||||
if (aiMessages.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
};
|
||||
}
|
||||
|
||||
const { messageIndex, subArgs } = parseLeadingMessageIndex(args);
|
||||
|
||||
let selectedAiMessage;
|
||||
if (messageIndex !== null) {
|
||||
if (messageIndex < 1) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Message index must be a positive integer (1 = last AI message).',
|
||||
};
|
||||
}
|
||||
if (messageIndex > aiMessages.length) {
|
||||
const turnLabel =
|
||||
aiMessages.length === 1 ? 'AI message' : 'AI messages';
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Only ${aiMessages.length} ${turnLabel} in this session.`,
|
||||
};
|
||||
}
|
||||
selectedAiMessage = aiMessages[aiMessages.length - messageIndex];
|
||||
} else {
|
||||
selectedAiMessage = aiMessages[aiMessages.length - 1];
|
||||
}
|
||||
|
||||
const isIndexed = messageIndex !== null && messageIndex > 1;
|
||||
const sourceLabel = isIndexed
|
||||
? `AI message ${messageIndex}`
|
||||
: 'the last AI output';
|
||||
const sourceLabelCapitalized = isIndexed
|
||||
? `AI message ${messageIndex}`
|
||||
: 'Last AI output';
|
||||
|
||||
// Extract text from the parts
|
||||
const lastAiOutput = lastAiMessage.parts
|
||||
?.filter((part) => part.text)
|
||||
const aiOutput = selectedAiMessage.parts
|
||||
?.filter((part) => part.text && !part.thought)
|
||||
.map((part) => part.text)
|
||||
.join('');
|
||||
|
||||
if (lastAiOutput) {
|
||||
if (aiOutput) {
|
||||
try {
|
||||
const selectedLatexBlock = selectLatexBlock(lastAiOutput, _args);
|
||||
const selectedLatexBlock = selectLatexBlock(aiOutput, subArgs);
|
||||
if (selectedLatexBlock === null) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
_args
|
||||
subArgs
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.some((token) => token === 'inline') ||
|
||||
_args.trim().toLowerCase().startsWith('inline-latex')
|
||||
? 'No matching inline LaTeX expression found in the last AI output.'
|
||||
: 'No matching LaTeX block found in the last AI output.',
|
||||
subArgs.trim().toLowerCase().startsWith('inline-latex')
|
||||
? `No matching inline LaTeX expression found in ${sourceLabel}.`
|
||||
: `No matching LaTeX block found in ${sourceLabel}.`,
|
||||
};
|
||||
}
|
||||
if (selectedLatexBlock !== undefined) {
|
||||
|
|
@ -397,16 +450,16 @@ export const copyCommand: SlashCommand = {
|
|||
};
|
||||
}
|
||||
|
||||
const selectedCodeBlock = selectCodeBlock(lastAiOutput, _args);
|
||||
const selectedCodeBlock = selectCodeBlock(aiOutput, subArgs);
|
||||
if (selectedCodeBlock === null) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No matching code block found in the last AI output.',
|
||||
content: `No matching code block found in ${sourceLabel}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const copiedText = selectedCodeBlock?.block.content ?? lastAiOutput;
|
||||
const copiedText = selectedCodeBlock?.block.content ?? aiOutput;
|
||||
await copyToClipboard(copiedText);
|
||||
|
||||
return {
|
||||
|
|
@ -414,7 +467,9 @@ export const copyCommand: SlashCommand = {
|
|||
messageType: 'info',
|
||||
content: selectedCodeBlock
|
||||
? `${selectedCodeBlock.label} copied to the clipboard`
|
||||
: 'Last output copied to the clipboard',
|
||||
: isIndexed
|
||||
? `AI message ${messageIndex} copied to the clipboard`
|
||||
: 'Last output copied to the clipboard',
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -430,7 +485,7 @@ export const copyCommand: SlashCommand = {
|
|||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last AI output contains no text to copy.',
|
||||
content: `${sourceLabelCapitalized} contains no text to copy.`,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ describe('doctorCommand', () => {
|
|||
await expect(doctorCommand.completion!(mockContext, '')).resolves.toEqual([
|
||||
'memory',
|
||||
'cpu-profile',
|
||||
'rollback',
|
||||
]);
|
||||
await expect(
|
||||
doctorCommand.completion!(mockContext, 'mem'),
|
||||
|
|
@ -185,6 +186,9 @@ describe('doctorCommand', () => {
|
|||
await expect(
|
||||
doctorCommand.completion!(mockContext, 'cpu'),
|
||||
).resolves.toEqual(['cpu-profile']);
|
||||
await expect(
|
||||
doctorCommand.completion!(mockContext, 'roll'),
|
||||
).resolves.toEqual(['rollback']);
|
||||
await expect(doctorCommand.completion!(mockContext, 'x')).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
|
|
@ -1054,7 +1058,7 @@ describe('doctorCommand', () => {
|
|||
|
||||
it('should advertise the memory subcommand on the parent doctor argumentHint', () => {
|
||||
expect(doctorCommand.argumentHint).toBe(
|
||||
'[memory|cpu-profile] [--sample] [--snapshot] [--duration]',
|
||||
'[memory|cpu-profile|rollback] [--sample] [--snapshot] [--duration]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
startCpuProfile,
|
||||
stopCpuProfile,
|
||||
} from '../../utils/cpuProfiler.js';
|
||||
import { rollbackStandaloneUpdate } from '../../utils/standalone-update.js';
|
||||
import { getInstallationInfo } from '../../utils/installationInfo.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
collectMemoryDiagnostics,
|
||||
|
|
@ -30,7 +32,12 @@ import { formatMemoryUsage } from '../utils/formatters.js';
|
|||
|
||||
const MEMORY_SUBCOMMAND = 'memory';
|
||||
const CPU_PROFILE_SUBCOMMAND = 'cpu-profile';
|
||||
const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, CPU_PROFILE_SUBCOMMAND] as const;
|
||||
const ROLLBACK_SUBCOMMAND = 'rollback';
|
||||
const DOCTOR_SUBCOMMANDS = [
|
||||
MEMORY_SUBCOMMAND,
|
||||
CPU_PROFILE_SUBCOMMAND,
|
||||
ROLLBACK_SUBCOMMAND,
|
||||
] as const;
|
||||
function getHeapSnapshotSensitiveDataWarning(): string {
|
||||
return t(
|
||||
'Heap snapshot may contain prompts, file contents, tool results, and other sensitive data. Do not share it publicly without reviewing it first.',
|
||||
|
|
@ -55,7 +62,8 @@ export const doctorCommand: SlashCommand = {
|
|||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
argumentHint: '[memory|cpu-profile] [--sample] [--snapshot] [--duration]',
|
||||
argumentHint:
|
||||
'[memory|cpu-profile|rollback] [--sample] [--snapshot] [--duration]',
|
||||
examples: [
|
||||
'/doctor',
|
||||
'/doctor memory',
|
||||
|
|
@ -63,6 +71,7 @@ export const doctorCommand: SlashCommand = {
|
|||
'/doctor memory --snapshot',
|
||||
'/doctor cpu-profile',
|
||||
'/doctor cpu-profile --duration 10',
|
||||
'/doctor rollback',
|
||||
],
|
||||
completion: async (_context, partialArg) => {
|
||||
const trimmed = partialArg.trimStart();
|
||||
|
|
@ -79,6 +88,17 @@ export const doctorCommand: SlashCommand = {
|
|||
const shouldWriteHeapSnapshot = subCommandArgs.includes('--snapshot');
|
||||
const shouldSampleMemory = subCommandArgs.includes('--sample');
|
||||
|
||||
if (subCommand === ROLLBACK_SUBCOMMAND) {
|
||||
if (executionMode === 'acp') {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
content: t('Rollback is not available in ACP mode.'),
|
||||
};
|
||||
}
|
||||
return rollbackDoctorAction(context);
|
||||
}
|
||||
|
||||
if (subCommand === MEMORY_SUBCOMMAND) {
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
|
|
@ -255,6 +275,15 @@ export const doctorCommand: SlashCommand = {
|
|||
argumentHint: '[--duration <seconds>]',
|
||||
action: cpuProfileDoctorAction,
|
||||
},
|
||||
{
|
||||
name: 'rollback',
|
||||
get description() {
|
||||
return t('Roll back a standalone update to the previous version');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive', 'non_interactive'] as const,
|
||||
action: rollbackDoctorAction,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -580,3 +609,57 @@ async function cpuProfileDoctorAction(
|
|||
}
|
||||
return { type: 'message', messageType: 'info', content: successMsg };
|
||||
}
|
||||
|
||||
function rollbackDoctorAction(context: CommandContext) {
|
||||
const installInfo = getInstallationInfo(process.cwd(), false);
|
||||
if (!installInfo.isStandalone || !installInfo.standaloneDir) {
|
||||
const msg = t('Rollback is only available for standalone installations.');
|
||||
if (context.executionMode === 'interactive') {
|
||||
context.ui.addItem({ type: 'info', text: msg }, Date.now());
|
||||
return;
|
||||
}
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: msg,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const winMsg = t(
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.',
|
||||
);
|
||||
if (context.executionMode === 'interactive') {
|
||||
context.ui.addItem({ type: 'info', text: winMsg }, Date.now());
|
||||
return;
|
||||
}
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: winMsg,
|
||||
};
|
||||
}
|
||||
|
||||
const result = rollbackStandaloneUpdate(installInfo.standaloneDir);
|
||||
let msg: string;
|
||||
let messageType: 'info' | 'error';
|
||||
if (result.ok) {
|
||||
msg = t(
|
||||
'Rollback successful. Restart your terminal to use the previous version.',
|
||||
);
|
||||
messageType = 'info';
|
||||
} else {
|
||||
msg = `${t('Rollback failed:')} ${result.detail}`;
|
||||
messageType = 'error';
|
||||
}
|
||||
|
||||
if (context.executionMode === 'interactive') {
|
||||
context.ui.addItem({ type: messageType, text: msg }, Date.now());
|
||||
return;
|
||||
}
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: messageType as 'info' | 'error',
|
||||
content: msg,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue