mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
fix(ci): normalize dev launcher path assertions
This commit is contained in:
commit
0e104e179d
70 changed files with 9666 additions and 773 deletions
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"
|
||||
70
.github/workflows/qwen-code-pr-review.yml
vendored
70
.github/workflows/qwen-code-pr-review.yml
vendored
|
|
@ -44,6 +44,75 @@ concurrency:
|
|||
cancel-in-progress: "${{ github.event_name == 'pull_request_target' && github.event.action == 'synchronize' }}"
|
||||
|
||||
jobs:
|
||||
ack-review-request:
|
||||
# KEEP IN SYNC with review-pr.if (explicit-trigger branches).
|
||||
if: |-
|
||||
(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'))
|
||||
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:
|
||||
pull-requests: 'write'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- 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' &&
|
||||
|
|
@ -145,6 +214,7 @@ jobs:
|
|||
# - 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' ||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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,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',
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
2049
package-lock.json
generated
2049
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -74,6 +74,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"
|
||||
},
|
||||
|
|
|
|||
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: {},
|
||||
|
|
@ -113,6 +122,27 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
|
|||
SessionStartSource: { Startup: 'startup', Resume: 'resume' },
|
||||
SessionEndReason: { PromptInputExit: 'prompt_input_exit', Other: 'other' },
|
||||
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', () => ({
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ describe('Session', () => {
|
|||
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
|
||||
let mockChatRecordingService: {
|
||||
recordUserMessage: ReturnType<typeof vi.fn>;
|
||||
recordMidTurnUserMessage: ReturnType<typeof vi.fn>;
|
||||
recordUiTelemetryEvent: ReturnType<typeof vi.fn>;
|
||||
recordToolResult: ReturnType<typeof vi.fn>;
|
||||
recordSlashCommand: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -254,6 +255,7 @@ describe('Session', () => {
|
|||
|
||||
mockChatRecordingService = {
|
||||
recordUserMessage: vi.fn(),
|
||||
recordMidTurnUserMessage: vi.fn(),
|
||||
recordUiTelemetryEvent: vi.fn(),
|
||||
recordToolResult: vi.fn(),
|
||||
recordSlashCommand: vi.fn(),
|
||||
|
|
@ -860,12 +862,22 @@ describe('Session', () => {
|
|||
},
|
||||
]);
|
||||
mockConfig.getSkillManager = vi.fn().mockReturnValue({
|
||||
listSkills: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'code-review-expert' },
|
||||
{ name: 'verification-pack' },
|
||||
]),
|
||||
listSkills: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'code-review-expert',
|
||||
description: 'Review code changes',
|
||||
body: 'Review instructions',
|
||||
filePath: '/skills/code-review-expert/SKILL.md',
|
||||
level: 'user',
|
||||
},
|
||||
{
|
||||
name: 'verification-pack',
|
||||
description: 'Verify changes',
|
||||
body: 'Verification instructions',
|
||||
filePath: '/skills/verification-pack/SKILL.md',
|
||||
level: 'project',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
|
@ -892,11 +904,134 @@ describe('Session', () => {
|
|||
],
|
||||
_meta: {
|
||||
availableSkills: ['code-review-expert', 'verification-pack'],
|
||||
availableSkillDetails: [
|
||||
{
|
||||
name: 'code-review-expert',
|
||||
description: 'Review code changes',
|
||||
body: 'Review instructions',
|
||||
filePath: '/skills/code-review-expert/SKILL.md',
|
||||
level: 'user',
|
||||
modelInvocable: true,
|
||||
},
|
||||
{
|
||||
name: 'verification-pack',
|
||||
description: 'Verify changes',
|
||||
body: 'Verification instructions',
|
||||
filePath: '/skills/verification-pack/SKILL.md',
|
||||
level: 'project',
|
||||
modelInvocable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('derives skill details from skill slash commands', async () => {
|
||||
getAvailableCommandsSpy.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
kind: 'skill',
|
||||
argumentHint: '<operation> <file-pattern>',
|
||||
skillDetail: {
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
body: 'Batch instructions',
|
||||
level: 'bundled',
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockConfig.getSkillManager = vi.fn().mockReturnValue(null);
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
input: { hint: '<operation> <file-pattern>' },
|
||||
_meta: {
|
||||
argumentHint: '<operation> <file-pattern>',
|
||||
source: undefined,
|
||||
sourceLabel: undefined,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
subcommands: [],
|
||||
modelInvocable: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
_meta: {
|
||||
availableSkills: ['batch'],
|
||||
availableSkillDetails: [
|
||||
{
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
body: 'Batch instructions',
|
||||
level: 'bundled',
|
||||
modelInvocable: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('derives availableSkills from skillManager and skill slash commands combined', async () => {
|
||||
// Both sources contribute: a skillManager skill AND a bundled skill
|
||||
// slash-command. The unconditional derivation must list both and keep
|
||||
// availableSkills consistent with availableSkillDetails (the `??=` fix).
|
||||
getAvailableCommandsSpy.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
kind: 'skill',
|
||||
skillDetail: {
|
||||
name: 'batch',
|
||||
description: 'Run a batch operation',
|
||||
body: 'Batch instructions',
|
||||
level: 'bundled',
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockConfig.getSkillManager = vi.fn().mockReturnValue({
|
||||
listSkills: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'mgr-skill',
|
||||
description: 'From the skill manager',
|
||||
body: 'Manager instructions',
|
||||
filePath: '/skills/mgr-skill/SKILL.md',
|
||||
level: 'user',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
||||
const meta = (
|
||||
vi.mocked(mockClient.sessionUpdate).mock.calls.at(-1)![0] as {
|
||||
update: {
|
||||
_meta: {
|
||||
availableSkills: string[];
|
||||
availableSkillDetails: Array<{ name: string }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).update._meta;
|
||||
expect(meta.availableSkills).toEqual(
|
||||
expect.arrayContaining(['mgr-skill', 'batch']),
|
||||
);
|
||||
expect(meta.availableSkills).toHaveLength(2);
|
||||
// Name list stays in lockstep with the details list.
|
||||
expect([...meta.availableSkills].sort()).toEqual(
|
||||
meta.availableSkillDetails.map((detail) => detail.name).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows errors and does not throw', async () => {
|
||||
getAvailableCommandsSpy.mockRejectedValueOnce(
|
||||
new Error('Command discovery failed'),
|
||||
|
|
@ -2064,6 +2199,184 @@ describe('Session', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('injects drained mid-turn user messages with tool responses', async () => {
|
||||
const executeSpy = vi.fn().mockResolvedValue({
|
||||
llmContent: 'file contents',
|
||||
returnDisplay: 'file contents',
|
||||
});
|
||||
const tool = {
|
||||
name: 'read_file',
|
||||
kind: core.Kind.Read,
|
||||
build: vi.fn().mockReturnValue({
|
||||
params: { path: '/tmp/test.txt' },
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
getDescription: vi.fn().mockReturnValue('Read file'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
execute: executeSpy,
|
||||
}),
|
||||
};
|
||||
|
||||
mockToolRegistry.getTool.mockReturnValue(tool);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
|
||||
mockClient.extMethod = vi.fn().mockResolvedValue({
|
||||
messages: ['please also check tests'],
|
||||
});
|
||||
mockChat.sendMessageStream = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createStreamWithChunks([
|
||||
{
|
||||
type: core.StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test.txt' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(createEmptyStream());
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text', text: 'read file' }],
|
||||
});
|
||||
|
||||
expect(mockClient.extMethod).toHaveBeenCalledWith(
|
||||
'craft/drainMidTurnQueue',
|
||||
{ sessionId: 'test-session-id' },
|
||||
);
|
||||
const secondCall = vi.mocked(mockChat.sendMessageStream).mock.calls[1];
|
||||
const midTurnPart = {
|
||||
text: '\n[User message received during tool execution]: please also check tests',
|
||||
};
|
||||
expect(secondCall?.[1].message).toEqual(
|
||||
expect.arrayContaining([midTurnPart]),
|
||||
);
|
||||
expect(
|
||||
mockChatRecordingService.recordMidTurnUserMessage,
|
||||
).toHaveBeenCalledWith([midTurnPart], 'please also check tests');
|
||||
});
|
||||
|
||||
it('latches mid-turn drain off after a permanent (-32601) error', async () => {
|
||||
const tool = {
|
||||
name: 'read_file',
|
||||
kind: core.Kind.Read,
|
||||
build: vi.fn().mockReturnValue({
|
||||
params: { path: '/tmp/test.txt' },
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
getDescription: vi.fn().mockReturnValue('Read file'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
execute: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }),
|
||||
}),
|
||||
};
|
||||
mockToolRegistry.getTool.mockReturnValue(tool);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
|
||||
// The ACP SDK rejects with a raw JSON-RPC error object, not an Error.
|
||||
mockClient.extMethod = vi
|
||||
.fn()
|
||||
.mockRejectedValue({ code: -32601, message: 'Method not found' });
|
||||
|
||||
const toolCallStream = () =>
|
||||
createStreamWithChunks([
|
||||
{
|
||||
type: core.StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'c',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test.txt' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(toolCallStream())
|
||||
.mockResolvedValueOnce(createEmptyStream())
|
||||
.mockResolvedValueOnce(toolCallStream())
|
||||
.mockResolvedValueOnce(createEmptyStream());
|
||||
|
||||
const prompt = {
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text' as const, text: 'read file' }],
|
||||
};
|
||||
await session.prompt(prompt);
|
||||
await session.prompt(prompt);
|
||||
|
||||
// After the permanent error the latch trips, so the drain extMethod is
|
||||
// attempted only on the first tool batch, not the second.
|
||||
const drainCalls = vi
|
||||
.mocked(mockClient.extMethod)
|
||||
.mock.calls.filter((call) => call[0] === 'craft/drainMidTurnQueue');
|
||||
expect(drainCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps mid-turn drain enabled after a transient error', async () => {
|
||||
const tool = {
|
||||
name: 'read_file',
|
||||
kind: core.Kind.Read,
|
||||
build: vi.fn().mockReturnValue({
|
||||
params: { path: '/tmp/test.txt' },
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
getDescription: vi.fn().mockReturnValue('Read file'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
execute: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }),
|
||||
}),
|
||||
};
|
||||
mockToolRegistry.getTool.mockReturnValue(tool);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
|
||||
mockClient.extMethod = vi
|
||||
.fn()
|
||||
.mockRejectedValue({ code: -32000, message: 'temporary failure' });
|
||||
|
||||
const toolCallStream = () =>
|
||||
createStreamWithChunks([
|
||||
{
|
||||
type: core.StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [
|
||||
{
|
||||
id: 'c',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test.txt' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(toolCallStream())
|
||||
.mockResolvedValueOnce(createEmptyStream())
|
||||
.mockResolvedValueOnce(toolCallStream())
|
||||
.mockResolvedValueOnce(createEmptyStream());
|
||||
|
||||
const prompt = {
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text' as const, text: 'read file' }],
|
||||
};
|
||||
await session.prompt(prompt);
|
||||
await session.prompt(prompt);
|
||||
|
||||
// A transient error must NOT latch: the drain is retried on the second
|
||||
// tool batch.
|
||||
const drainCalls = vi
|
||||
.mocked(mockClient.extMethod)
|
||||
.mock.calls.filter((call) => call[0] === 'craft/drainMidTurnQueue');
|
||||
expect(drainCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('wraps tool execution with the sleep inhibitor (acquire before execute, release after)', async () => {
|
||||
const releaseSpy = vi.fn();
|
||||
const acquireSpy = vi
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ 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;
|
||||
|
|
@ -249,6 +251,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(
|
||||
|
|
@ -280,19 +290,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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +372,7 @@ 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.
|
||||
|
|
@ -936,7 +984,13 @@ export class Session implements SessionContext {
|
|||
promptId,
|
||||
functionCalls,
|
||||
);
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
nextMessage = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
...toolResponseParts,
|
||||
...(await this.#drainMidTurnUserMessages()),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1182,7 +1236,13 @@ export class Session implements SessionContext {
|
|||
promptId,
|
||||
functionCalls,
|
||||
);
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
nextMessage = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
...toolResponseParts,
|
||||
...(await this.#drainMidTurnUserMessages()),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1449,6 +1509,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
|
||||
|
|
@ -1617,7 +1739,13 @@ export class Session implements SessionContext {
|
|||
promptId,
|
||||
functionCalls,
|
||||
);
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
nextMessage = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
...toolResponseParts,
|
||||
...(await this.#drainMidTurnUserMessages()),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1972,7 +2100,7 @@ export class Session implements SessionContext {
|
|||
|
||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||
try {
|
||||
const { availableCommands, availableSkills } =
|
||||
const { availableCommands, availableSkills, availableSkillDetails } =
|
||||
await buildAvailableCommandsSnapshot(this.config);
|
||||
|
||||
const update: SessionUpdate = {
|
||||
|
|
@ -1982,6 +2110,7 @@ export class Session implements SessionContext {
|
|||
? {
|
||||
_meta: {
|
||||
availableSkills,
|
||||
...(availableSkillDetails ? { availableSkillDetails } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -719,6 +719,10 @@ describe('SubAgentTracker', () => {
|
|||
type: 'text',
|
||||
text: 'Hello, this is a response from the model.',
|
||||
},
|
||||
_meta: expect.objectContaining({
|
||||
parentToolCallId: 'parent-call-123',
|
||||
subagentType: 'test-subagent',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -276,6 +276,8 @@ export class SubAgentTracker {
|
|||
event.text,
|
||||
'assistant',
|
||||
event.thought ?? false,
|
||||
undefined,
|
||||
this.getSubagentMeta(),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -69,12 +69,17 @@ export class MessageEmitter extends BaseEmitter {
|
|||
async emitAgentThought(
|
||||
text: string,
|
||||
timestamp?: string | number,
|
||||
subagentMeta?: SubagentMeta,
|
||||
): Promise<void> {
|
||||
const epochMs = BaseEmitter.toEpochMs(timestamp);
|
||||
const meta = {
|
||||
...subagentMeta,
|
||||
...(epochMs != null && { timestamp: epochMs }),
|
||||
};
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
...(epochMs != null && { _meta: { timestamp: epochMs } }),
|
||||
...(Object.keys(meta).length > 0 && { _meta: meta }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -87,12 +92,17 @@ export class MessageEmitter extends BaseEmitter {
|
|||
async emitAgentMessage(
|
||||
text: string,
|
||||
timestamp?: string | number,
|
||||
subagentMeta?: SubagentMeta,
|
||||
): Promise<void> {
|
||||
const epochMs = BaseEmitter.toEpochMs(timestamp);
|
||||
const meta = {
|
||||
...subagentMeta,
|
||||
...(epochMs != null && { timestamp: epochMs }),
|
||||
};
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
...(epochMs != null && { _meta: { timestamp: epochMs } }),
|
||||
...(Object.keys(meta).length > 0 && { _meta: meta }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -139,12 +149,13 @@ export class MessageEmitter extends BaseEmitter {
|
|||
role: 'user' | 'assistant',
|
||||
isThought: boolean = false,
|
||||
timestamp?: string | number,
|
||||
subagentMeta?: SubagentMeta,
|
||||
): Promise<void> {
|
||||
if (role === 'user') {
|
||||
return this.emitUserMessage(text, timestamp);
|
||||
}
|
||||
return isThought
|
||||
? this.emitAgentThought(text, timestamp)
|
||||
: this.emitAgentMessage(text, timestamp);
|
||||
? this.emitAgentThought(text, timestamp, subagentMeta)
|
||||
: this.emitAgentMessage(text, timestamp, subagentMeta);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 @@ 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,12 @@ 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})`;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -134,7 +134,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 +666,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 +690,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 +906,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.',
|
||||
|
|
@ -1867,8 +1857,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 ||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -799,7 +799,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
bare: undefined,
|
||||
approvalMode: undefined,
|
||||
telemetry: undefined,
|
||||
checkpointing: undefined,
|
||||
telemetryTarget: undefined,
|
||||
telemetryOtlpEndpoint: undefined,
|
||||
telemetryOtlpProtocol: undefined,
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ 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;
|
||||
|
|
@ -402,7 +402,6 @@ export const handleSlashCommand = async (
|
|||
services: {
|
||||
config,
|
||||
settings,
|
||||
git: undefined,
|
||||
logger,
|
||||
},
|
||||
ui: createNonInteractiveUI(),
|
||||
|
|
|
|||
|
|
@ -83,6 +83,12 @@ 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(
|
||||
|
|
|
|||
|
|
@ -107,6 +107,12 @@ 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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ import * as path from 'node:path';
|
|||
import { restoreCommand } from './restoreCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { Config, GitService } from '@qwen-code/qwen-code-core';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('restoreCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockConfig: Config;
|
||||
let mockGitService: GitService;
|
||||
let mockSetHistory: ReturnType<typeof vi.fn>;
|
||||
let mockRewind: ReturnType<typeof vi.fn>;
|
||||
let testRootDir: string;
|
||||
let geminiTempDir: string;
|
||||
let checkpointsDir: string;
|
||||
|
|
@ -33,12 +33,13 @@ describe('restoreCommand', () => {
|
|||
await fs.mkdir(checkpointsDir, { recursive: true });
|
||||
|
||||
mockSetHistory = vi.fn().mockResolvedValue(undefined);
|
||||
mockGitService = {
|
||||
restoreProjectFromSnapshot: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as GitService;
|
||||
mockRewind = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ filesChanged: [], filesFailed: [] });
|
||||
|
||||
mockConfig = {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
getFileCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
getFileHistoryService: vi.fn().mockReturnValue({ rewind: mockRewind }),
|
||||
storage: {
|
||||
getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir),
|
||||
getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir),
|
||||
|
|
@ -51,7 +52,6 @@ describe('restoreCommand', () => {
|
|||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig,
|
||||
git: mockGitService,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -62,7 +62,7 @@ describe('restoreCommand', () => {
|
|||
});
|
||||
|
||||
it('should return null if checkpointing is not enabled', () => {
|
||||
vi.mocked(mockConfig.getCheckpointingEnabled).mockReturnValue(false);
|
||||
vi.mocked(mockConfig.getFileCheckpointingEnabled).mockReturnValue(false);
|
||||
|
||||
expect(restoreCommand(mockConfig)).toBeNull();
|
||||
});
|
||||
|
|
@ -153,7 +153,7 @@ describe('restoreCommand', () => {
|
|||
const toolCallData = {
|
||||
history: [{ type: 'user', text: 'do a thing' }],
|
||||
clientHistory: [{ role: 'user', parts: [{ text: 'do a thing' }] }],
|
||||
commitHash: 'abcdef123',
|
||||
promptId: 'prompt-abc123',
|
||||
toolCall: { name: 'run_shell_command', args: 'ls' },
|
||||
};
|
||||
await fs.writeFile(
|
||||
|
|
@ -171,13 +171,11 @@ describe('restoreCommand', () => {
|
|||
toolCallData.history,
|
||||
);
|
||||
expect(mockSetHistory).toHaveBeenCalledWith(toolCallData.clientHistory);
|
||||
expect(mockGitService.restoreProjectFromSnapshot).toHaveBeenCalledWith(
|
||||
toolCallData.commitHash,
|
||||
);
|
||||
expect(mockRewind).toHaveBeenCalledWith(toolCallData.promptId, true);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Restored project to the state before the tool call.',
|
||||
text: 'Restored project to the state at the start of this turn.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
|
@ -202,10 +200,83 @@ describe('restoreCommand', () => {
|
|||
|
||||
expect(mockContext.ui.loadHistory).not.toHaveBeenCalled();
|
||||
expect(mockSetHistory).not.toHaveBeenCalled();
|
||||
expect(mockGitService.restoreProjectFromSnapshot).not.toHaveBeenCalled();
|
||||
expect(mockRewind).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject legacy checkpoint format with commitHash', async () => {
|
||||
const toolCallData = {
|
||||
commitHash: 'abc123',
|
||||
toolCall: { name: 'run_shell_command', args: 'ls' },
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(checkpointsDir, 'legacy.json'),
|
||||
JSON.stringify(toolCallData),
|
||||
);
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.action?.(mockContext, 'legacy')).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('legacy format'),
|
||||
});
|
||||
expect(mockRewind).not.toHaveBeenCalled();
|
||||
expect(mockContext.ui.loadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should abort tool replay when rewind has partial failures', async () => {
|
||||
mockRewind.mockResolvedValue({
|
||||
filesChanged: ['a.ts'],
|
||||
filesFailed: ['b.ts'],
|
||||
});
|
||||
const toolCallData = {
|
||||
promptId: 'prompt-abc',
|
||||
toolCall: { name: 'edit', args: { file_path: 'a.ts' } },
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(checkpointsDir, 'partial.json'),
|
||||
JSON.stringify(toolCallData),
|
||||
);
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
const result = await command?.action?.(mockContext, 'partial');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'warning',
|
||||
text: expect.stringContaining('Partially restored'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.loadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should abort tool replay when rewind throws', async () => {
|
||||
mockRewind.mockRejectedValue(
|
||||
new Error('The selected snapshot was not found'),
|
||||
);
|
||||
const toolCallData = {
|
||||
promptId: 'prompt-missing',
|
||||
toolCall: { name: 'edit', args: { file_path: 'a.ts' } },
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(checkpointsDir, 'missing-snapshot.json'),
|
||||
JSON.stringify(toolCallData),
|
||||
);
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
const result = await command?.action?.(mockContext, 'missing-snapshot');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'warning',
|
||||
text: expect.stringContaining('Could not restore files'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.loadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return an error for a checkpoint file missing the toolCall property', async () => {
|
||||
const checkpointName = 'missing-toolcall';
|
||||
await fs.writeFile(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ async function restoreAction(
|
|||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
const { services, ui } = context;
|
||||
const { config, git: gitService } = services;
|
||||
const { config } = services;
|
||||
const { addItem, loadHistory } = ui;
|
||||
|
||||
const checkpointDir = config?.storage.getProjectTempCheckpointsDir();
|
||||
|
|
@ -77,9 +77,58 @@ async function restoreAction(
|
|||
const data = await fs.readFile(filePath, 'utf-8');
|
||||
const toolCallData = JSON.parse(data);
|
||||
|
||||
if (toolCallData.commitHash && !toolCallData.promptId) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'This checkpoint uses a legacy format that is no longer supported. Please create a new checkpoint.',
|
||||
};
|
||||
}
|
||||
|
||||
if (toolCallData.promptId) {
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration is not available.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await config
|
||||
.getFileHistoryService()
|
||||
.rewind(toolCallData.promptId, true);
|
||||
if (result.filesFailed.length > 0) {
|
||||
addItem(
|
||||
{
|
||||
type: 'warning',
|
||||
text: `Partially restored: ${result.filesChanged.length} file(s) reverted, ${result.filesFailed.length} file(s) failed. Aborting tool replay.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Restored project to the state at the start of this turn.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
addItem(
|
||||
{
|
||||
type: 'warning',
|
||||
text: `Could not restore files: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallData.history) {
|
||||
if (!loadHistory) {
|
||||
// This should not happen
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
|
|
@ -93,17 +142,6 @@ async function restoreAction(
|
|||
await config?.getGeminiClient()?.setHistory(toolCallData.clientHistory);
|
||||
}
|
||||
|
||||
if (toolCallData.commitHash) {
|
||||
await gitService?.restoreProjectFromSnapshot(toolCallData.commitHash);
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Restored project to the state before the tool call.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tool',
|
||||
toolName: toolCallData.toolCall.name,
|
||||
|
|
@ -139,7 +177,7 @@ async function completion(
|
|||
}
|
||||
|
||||
export const restoreCommand = (config: Config | null): SlashCommand | null => {
|
||||
if (!config?.getCheckpointingEnabled()) {
|
||||
if (!config?.getFileCheckpointingEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type { MutableRefObject, ReactNode } from 'react';
|
|||
import type { Content, PartListUnion } from '@google/genai';
|
||||
import type {
|
||||
Config,
|
||||
GitService,
|
||||
Logger,
|
||||
SessionListItem,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -50,7 +49,6 @@ export interface CommandContext {
|
|||
// TODO(abhipatel12): Ensure that config is never null.
|
||||
config: Config | null;
|
||||
settings: LoadedSettings;
|
||||
git: GitService | undefined;
|
||||
logger: Logger | null;
|
||||
};
|
||||
// UI state and history management
|
||||
|
|
@ -399,6 +397,15 @@ export interface SlashCommand {
|
|||
/** Usage examples shown in Help and completion. */
|
||||
examples?: string[];
|
||||
|
||||
/** Parsed skill metadata for skill-backed commands. Used by ACP clients. */
|
||||
skillDetail?: {
|
||||
name: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
filePath?: string;
|
||||
level?: string;
|
||||
};
|
||||
|
||||
// The action to run. Optional for parent commands that only group sub-commands.
|
||||
action?: (
|
||||
context: CommandContext,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import {
|
|||
type Logger,
|
||||
type Config,
|
||||
createDebugLogger,
|
||||
GitService,
|
||||
logSlashCommand,
|
||||
makeSlashCommandEvent,
|
||||
SlashCommandStatus,
|
||||
|
|
@ -207,13 +206,6 @@ export const useSlashCommandProcessor = (
|
|||
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
|
||||
new Set<string>(),
|
||||
);
|
||||
const gitService = useMemo(() => {
|
||||
if (!config?.getProjectRoot()) {
|
||||
return;
|
||||
}
|
||||
return new GitService(config.getProjectRoot(), config.storage);
|
||||
}, [config]);
|
||||
|
||||
const [pendingItem, setPendingItem] = useState<HistoryItemWithoutId | null>(
|
||||
null,
|
||||
);
|
||||
|
|
@ -326,7 +318,6 @@ export const useSlashCommandProcessor = (
|
|||
services: {
|
||||
config,
|
||||
settings,
|
||||
git: gitService,
|
||||
logger,
|
||||
},
|
||||
ui: {
|
||||
|
|
@ -366,7 +357,6 @@ export const useSlashCommandProcessor = (
|
|||
[
|
||||
config,
|
||||
settings,
|
||||
gitService,
|
||||
logger,
|
||||
loadHistory,
|
||||
addItem,
|
||||
|
|
@ -487,7 +477,7 @@ export const useSlashCommandProcessor = (
|
|||
name,
|
||||
args,
|
||||
},
|
||||
services: { config, settings, git: gitService, logger: null },
|
||||
services: { config, settings, logger: null },
|
||||
} as unknown as Parameters<typeof cmd.action>[0];
|
||||
const result = await cmd.action(minimalContext, args);
|
||||
if (!result || result.type !== 'submit_prompt') return null;
|
||||
|
|
@ -552,7 +542,6 @@ export const useSlashCommandProcessor = (
|
|||
reloadTrigger,
|
||||
isConfigInitialized,
|
||||
settings,
|
||||
gitService,
|
||||
resolveCommandReloads,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||
const actualCoreModule = (await importOriginal()) as any;
|
||||
return {
|
||||
...actualCoreModule,
|
||||
GitService: vi.fn(),
|
||||
GeminiClient: MockedGeminiClientClass,
|
||||
UserPromptEvent: MockedUserPromptEvent,
|
||||
ApiCancelEvent: MockedApiCancelEvent,
|
||||
|
|
@ -221,7 +220,7 @@ describe('useGeminiStream', () => {
|
|||
() => ({ getToolSchemaList: vi.fn(() => []) }) as any,
|
||||
),
|
||||
getProjectRoot: vi.fn(() => '/test/dir'),
|
||||
getCheckpointingEnabled: vi.fn(() => false),
|
||||
getFileCheckpointingEnabled: vi.fn(() => false),
|
||||
getGeminiClient: mockGetGeminiClient,
|
||||
getApprovalMode: () => ApprovalMode.DEFAULT,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
|
|
|
|||
|
|
@ -32,12 +32,12 @@ import {
|
|||
GeminiEventType as ServerGeminiEventType,
|
||||
SendMessageType,
|
||||
createDebugLogger,
|
||||
ToolNames,
|
||||
getErrorMessage,
|
||||
isNodeError,
|
||||
MessageSenderType,
|
||||
logUserPrompt,
|
||||
logUserRetry,
|
||||
GitService,
|
||||
UnauthorizedError,
|
||||
UserPromptEvent,
|
||||
UserRetryEvent,
|
||||
|
|
@ -237,7 +237,12 @@ enum StreamProcessingStatus {
|
|||
Error,
|
||||
}
|
||||
|
||||
const EDIT_TOOL_NAMES = new Set(['replace', 'write_file']);
|
||||
const EDIT_TOOL_NAMES = new Set([
|
||||
ToolNames.EDIT,
|
||||
'replace', // legacy alias, may still arrive from older providers
|
||||
ToolNames.WRITE_FILE,
|
||||
ToolNames.NOTEBOOK_EDIT,
|
||||
]);
|
||||
const STREAM_UPDATE_THROTTLE_MS = 60;
|
||||
|
||||
type BufferedStreamEvent =
|
||||
|
|
@ -387,12 +392,6 @@ export const useGeminiStream = (
|
|||
stats: sessionStates,
|
||||
} = useSessionStats();
|
||||
const storage = config.storage;
|
||||
const gitService = useMemo(() => {
|
||||
if (!config.getProjectRoot()) {
|
||||
return;
|
||||
}
|
||||
return new GitService(config.getProjectRoot(), storage);
|
||||
}, [config, storage]);
|
||||
|
||||
const [toolCalls, scheduleToolCalls, markToolsAsSubmitted] =
|
||||
useReactToolScheduler(
|
||||
|
|
@ -2045,7 +2044,7 @@ export const useGeminiStream = (
|
|||
call.status === 'awaiting_approval',
|
||||
);
|
||||
|
||||
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
|
||||
// For AUTO_EDIT mode, only approve edit tools (edit/replace, write_file, notebook_edit)
|
||||
if (newApprovalMode === ApprovalMode.AUTO_EDIT) {
|
||||
awaitingApprovalCalls = awaitingApprovalCalls.filter((call) =>
|
||||
EDIT_TOOL_NAMES.has(call.request.name),
|
||||
|
|
@ -2422,13 +2421,14 @@ export const useGeminiStream = (
|
|||
|
||||
useEffect(() => {
|
||||
const saveRestorableToolCalls = async () => {
|
||||
if (!config.getCheckpointingEnabled()) {
|
||||
if (!config.getFileCheckpointingEnabled()) {
|
||||
return;
|
||||
}
|
||||
const restorableToolCalls = toolCalls.filter(
|
||||
(toolCall) =>
|
||||
EDIT_TOOL_NAMES.has(toolCall.request.name) &&
|
||||
toolCall.status === 'awaiting_approval',
|
||||
toolCall.status === 'awaiting_approval' &&
|
||||
!toolCall.request.isClientInitiated,
|
||||
);
|
||||
|
||||
if (restorableToolCalls.length > 0) {
|
||||
|
|
@ -2450,7 +2450,8 @@ export const useGeminiStream = (
|
|||
}
|
||||
|
||||
for (const toolCall of restorableToolCalls) {
|
||||
const filePath = toolCall.request.args['file_path'] as string;
|
||||
const filePath = (toolCall.request.args['file_path'] ??
|
||||
toolCall.request.args['notebook_path']) as string;
|
||||
if (!filePath) {
|
||||
onDebugMessage(
|
||||
`Skipping restorable tool call due to missing file_path: ${toolCall.request.name}`,
|
||||
|
|
@ -2459,35 +2460,7 @@ export const useGeminiStream = (
|
|||
}
|
||||
|
||||
try {
|
||||
if (!gitService) {
|
||||
onDebugMessage(
|
||||
`Checkpointing is enabled but Git service is not available. Failed to create snapshot for ${filePath}. Ensure Git is installed and working properly.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let commitHash: string | undefined;
|
||||
try {
|
||||
commitHash = await gitService.createFileSnapshot(
|
||||
`Snapshot for ${toolCall.request.name}`,
|
||||
);
|
||||
} catch (error) {
|
||||
onDebugMessage(
|
||||
`Failed to create new snapshot: ${getErrorMessage(error)}. Attempting to use current commit.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!commitHash) {
|
||||
commitHash = await gitService.getCurrentCommitHash();
|
||||
}
|
||||
|
||||
if (!commitHash) {
|
||||
onDebugMessage(
|
||||
`Failed to create snapshot for ${filePath}. Checkpointing may not be working properly. Ensure Git is installed and the project directory is accessible.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const promptId = toolCall.request.prompt_id;
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/:/g, '-')
|
||||
|
|
@ -2511,7 +2484,7 @@ export const useGeminiStream = (
|
|||
name: toolCall.request.name,
|
||||
args: toolCall.request.args,
|
||||
},
|
||||
commitHash,
|
||||
promptId,
|
||||
filePath,
|
||||
},
|
||||
null,
|
||||
|
|
@ -2522,22 +2495,14 @@ export const useGeminiStream = (
|
|||
onDebugMessage(
|
||||
`Failed to create checkpoint for ${filePath}: ${getErrorMessage(
|
||||
error,
|
||||
)}. This may indicate a problem with Git or file system permissions.`,
|
||||
)}. This may indicate a problem with file system permissions.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
saveRestorableToolCalls();
|
||||
}, [
|
||||
toolCalls,
|
||||
config,
|
||||
onDebugMessage,
|
||||
gitService,
|
||||
history,
|
||||
geminiClient,
|
||||
storage,
|
||||
]);
|
||||
}, [toolCalls, config, onDebugMessage, history, geminiClient, storage]);
|
||||
|
||||
// ─── Unified notification queue (cron + background agents) ──────
|
||||
const notificationQueueRef = useRef<
|
||||
|
|
|
|||
|
|
@ -238,7 +238,6 @@ describe('runDoctorChecks', () => {
|
|||
getUseBuiltinRipgrep: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
settings: { merged: {} },
|
||||
git: undefined,
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
|
|
@ -265,7 +264,6 @@ describe('runDoctorChecks', () => {
|
|||
getUseBuiltinRipgrep: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
settings: { merged: {} },
|
||||
git: undefined,
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
|
|
|
|||
|
|
@ -341,16 +341,7 @@ async function checkRipgrep(
|
|||
}
|
||||
}
|
||||
|
||||
async function checkGit(context: CommandContext): Promise<DoctorCheckResult> {
|
||||
if (context.services.git) {
|
||||
return {
|
||||
category: t('Git'),
|
||||
name: t('Git'),
|
||||
status: 'pass',
|
||||
message: t('available'),
|
||||
};
|
||||
}
|
||||
// services.git is undefined in non-interactive mode — probe the binary directly
|
||||
async function checkGit(_context: CommandContext): Promise<DoctorCheckResult> {
|
||||
const version = await getGitVersion();
|
||||
if (version === 'unknown') {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import {
|
|||
resolveContentGeneratorConfigWithSources,
|
||||
} from '../core/contentGenerator.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
import { GitService } from '../services/gitService.js';
|
||||
import { ShellTool } from '../tools/shell.js';
|
||||
import { canUseRipgrep } from '../utils/ripgrepUtils.js';
|
||||
import { logRipgrepFallback } from '../telemetry/loggers.js';
|
||||
|
|
@ -261,12 +260,6 @@ vi.mock('../telemetry/loggers.js', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/gitService.js', () => {
|
||||
const GitServiceMock = vi.fn();
|
||||
GitServiceMock.prototype.initialize = vi.fn();
|
||||
return { GitService: GitServiceMock };
|
||||
});
|
||||
|
||||
vi.mock('../skills/skill-manager.js', () => {
|
||||
const SkillManagerMock = vi.fn();
|
||||
SkillManagerMock.prototype.startWatching = vi
|
||||
|
|
@ -791,34 +784,9 @@ describe('Server Config (config.ts)', () => {
|
|||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should throw an error if checkpointing is enabled and GitService fails', async () => {
|
||||
const gitError = new Error('Git is not installed');
|
||||
(GitService.prototype.initialize as Mock).mockRejectedValue(gitError);
|
||||
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
checkpointing: true,
|
||||
});
|
||||
|
||||
await expect(config.initialize()).rejects.toThrow(gitError);
|
||||
});
|
||||
|
||||
it('should not throw an error if checkpointing is disabled and GitService fails', async () => {
|
||||
const gitError = new Error('Git is not installed');
|
||||
(GitService.prototype.initialize as Mock).mockRejectedValue(gitError);
|
||||
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
checkpointing: false,
|
||||
});
|
||||
|
||||
await expect(config.initialize()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error if initialized more than once', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
checkpointing: false,
|
||||
});
|
||||
|
||||
await expect(config.initialize()).resolves.toBeUndefined();
|
||||
|
|
@ -834,7 +802,6 @@ describe('Server Config (config.ts)', () => {
|
|||
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
checkpointing: false,
|
||||
bareMode: true,
|
||||
});
|
||||
|
||||
|
|
@ -858,7 +825,7 @@ describe('Server Config (config.ts)', () => {
|
|||
});
|
||||
|
||||
it('skips inline MCP discovery by default (progressive availability)', async () => {
|
||||
const config = new Config({ ...baseParams, checkpointing: false });
|
||||
const config = new Config({ ...baseParams });
|
||||
await config.initialize();
|
||||
|
||||
// Default path passes `skipDiscovery: true` to createToolRegistry,
|
||||
|
|
@ -871,7 +838,7 @@ describe('Server Config (config.ts)', () => {
|
|||
const originalLegacy = process.env['QWEN_CODE_LEGACY_MCP_BLOCKING'];
|
||||
process.env['QWEN_CODE_LEGACY_MCP_BLOCKING'] = '1';
|
||||
try {
|
||||
const config = new Config({ ...baseParams, checkpointing: false });
|
||||
const config = new Config({ ...baseParams });
|
||||
await config.initialize();
|
||||
|
||||
// Legacy escape hatch must call back into the synchronous discover
|
||||
|
|
@ -892,7 +859,7 @@ describe('Server Config (config.ts)', () => {
|
|||
// No MCP servers + non-bare + default mode: startMcpDiscoveryInBackground
|
||||
// is called but the registry mock returns no manager, so the discovery
|
||||
// promise stays undefined and waitForMcpReady is a no-op.
|
||||
const config = new Config({ ...baseParams, checkpointing: false });
|
||||
const config = new Config({ ...baseParams });
|
||||
await config.initialize();
|
||||
await expect(config.waitForMcpReady()).resolves.toBeUndefined();
|
||||
});
|
||||
|
|
@ -902,7 +869,7 @@ describe('Server Config (config.ts)', () => {
|
|||
// failed to start" emission. Must be a no-op when there's nothing
|
||||
// to warn about, otherwise --prompt runs with no MCP config would
|
||||
// emit a spurious warning every time.
|
||||
const config = new Config({ ...baseParams, checkpointing: false });
|
||||
const config = new Config({ ...baseParams });
|
||||
expect(config.getFailedMcpServerNames()).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -913,7 +880,6 @@ describe('Server Config (config.ts)', () => {
|
|||
// `excludedMcpServers` (see `isMcpServerDisabled`).
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
checkpointing: false,
|
||||
mcpServers: { off: new MCPServerConfig() },
|
||||
excludedMcpServers: ['off'],
|
||||
} as ConfigParameters);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import {
|
|||
StandardFileSystemService,
|
||||
type FileEncodingType,
|
||||
} from '../services/fileSystemService.js';
|
||||
import { GitService } from '../services/gitService.js';
|
||||
import { GitWorktreeService } from '../services/gitWorktreeService.js';
|
||||
import { cleanupStaleAgentWorktrees } from '../services/worktreeCleanup.js';
|
||||
import { CronScheduler } from '../services/cronScheduler.js';
|
||||
|
|
@ -731,7 +730,6 @@ export interface ConfigParameters {
|
|||
enableRecursiveFileSearch?: boolean;
|
||||
enableFuzzySearch?: boolean;
|
||||
};
|
||||
checkpointing?: boolean;
|
||||
fileCheckpointingEnabled?: boolean;
|
||||
/** Directory where approved plan files are stored. Must resolve inside targetDir. */
|
||||
plansDirectory?: string;
|
||||
|
|
@ -1119,10 +1117,8 @@ export class Config {
|
|||
enableFuzzySearch: boolean;
|
||||
};
|
||||
private fileDiscoveryService: FileDiscoveryService | null = null;
|
||||
private gitService: GitService | undefined = undefined;
|
||||
private sessionService: SessionService | undefined = undefined;
|
||||
private chatRecordingService: ChatRecordingService | undefined = undefined;
|
||||
private readonly checkpointing: boolean;
|
||||
private readonly fileCheckpointingEnabled: boolean;
|
||||
private fileHistoryService: FileHistoryService | undefined;
|
||||
private readonly proxy: string | undefined;
|
||||
|
|
@ -1311,7 +1307,6 @@ export class Config {
|
|||
params.fileFiltering?.enableRecursiveFileSearch ?? true,
|
||||
enableFuzzySearch: params.fileFiltering?.enableFuzzySearch ?? true,
|
||||
};
|
||||
this.checkpointing = params.checkpointing ?? false;
|
||||
this.fileCheckpointingEnabled =
|
||||
params.fileCheckpointingEnabled ??
|
||||
(!params.sdkMode && (params.interactive ?? false));
|
||||
|
|
@ -1457,9 +1452,6 @@ export class Config {
|
|||
|
||||
// Initialize centralized FileDiscoveryService
|
||||
this.getFileService();
|
||||
if (this.getCheckpointingEnabled()) {
|
||||
await this.getGitService();
|
||||
}
|
||||
this.promptRegistry = new PromptRegistry();
|
||||
this.extensionManager.setConfig(this);
|
||||
const explicitExtensionNames = this.getExplicitExtensionNames();
|
||||
|
|
@ -3383,10 +3375,6 @@ export class Config {
|
|||
return [];
|
||||
}
|
||||
|
||||
getCheckpointingEnabled(): boolean {
|
||||
return this.checkpointing;
|
||||
}
|
||||
|
||||
getFileCheckpointingEnabled(): boolean {
|
||||
return this.fileCheckpointingEnabled;
|
||||
}
|
||||
|
|
@ -3806,14 +3794,6 @@ export class Config {
|
|||
return this.outputFormat;
|
||||
}
|
||||
|
||||
async getGitService(): Promise<GitService> {
|
||||
if (!this.gitService) {
|
||||
this.gitService = new GitService(this.targetDir, this.storage);
|
||||
await this.gitService.initialize();
|
||||
}
|
||||
return this.gitService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the chat recording service.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -395,13 +395,6 @@ describe('Storage – runtime path methods use getRuntimeBaseDir', () => {
|
|||
expect(storage.getProjectDir()).toContain(path.join(customDir, 'projects'));
|
||||
});
|
||||
|
||||
it('getHistoryDir uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
const storage = new Storage('/tmp/project');
|
||||
expect(storage.getHistoryDir()).toContain(path.join(customDir, 'history'));
|
||||
});
|
||||
|
||||
it('getProjectTempDir uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
|
|
|
|||
|
|
@ -332,13 +332,6 @@ export class Storage {
|
|||
return this.targetDir;
|
||||
}
|
||||
|
||||
getHistoryDir(): string {
|
||||
const hash = getProjectHash(this.getProjectRoot());
|
||||
const historyDir = path.join(Storage.getRuntimeBaseDir(), 'history');
|
||||
const targetDir = path.join(historyDir, hash);
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
getWorkspaceSettingsPath(): string {
|
||||
return path.join(this.getQwenDir(), 'settings.json');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,29 @@ describe('convertClaudeToQwenConfig', () => {
|
|||
expect(result.lspServers).toEqual(claudeConfig.lspServers);
|
||||
});
|
||||
|
||||
it('should preserve description field', () => {
|
||||
const claudeConfig: ClaudePluginConfig = {
|
||||
name: 'desc-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A plugin with a description',
|
||||
};
|
||||
|
||||
const result = convertClaudeToQwenConfig(claudeConfig);
|
||||
|
||||
expect(result.description).toBe('A plugin with a description');
|
||||
});
|
||||
|
||||
it('should leave description undefined when not provided', () => {
|
||||
const claudeConfig: ClaudePluginConfig = {
|
||||
name: 'no-desc-plugin',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const result = convertClaudeToQwenConfig(claudeConfig);
|
||||
|
||||
expect(result.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error for missing name', () => {
|
||||
const invalidConfig = {
|
||||
version: '1.0.0',
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ export function convertClaudeToQwenConfig(
|
|||
return {
|
||||
name: claudeConfig.name,
|
||||
version: claudeConfig.version,
|
||||
description: claudeConfig.description,
|
||||
mcpServers,
|
||||
lspServers: claudeConfig.lspServers,
|
||||
hooks, // Assign the properly typed hooks variable
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export interface Extension {
|
|||
export interface ExtensionConfig {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
lspServers?: string | Record<string, unknown>;
|
||||
contextFileName?: string | string[];
|
||||
|
|
|
|||
|
|
@ -151,7 +151,6 @@ export * from './services/fileHistoryService.js';
|
|||
export * from './services/fileReadCache.js';
|
||||
export * from './services/fileSystemService.js';
|
||||
export { decodeBufferWithEncodingInfo } from './utils/fileUtils.js';
|
||||
export * from './services/gitService.js';
|
||||
export * from './services/gitWorktreeService.js';
|
||||
export * from './services/sessionRecap.js';
|
||||
export * from './services/sessionService.js';
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
AuthType,
|
||||
alibabaStandardProvider,
|
||||
buildInstallPlan,
|
||||
getDefaultModelIds,
|
||||
resolveBaseUrl,
|
||||
providerMatchesCredentials,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -35,6 +36,17 @@ describe('alibabaStandardProvider', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('includes qwen3.7 models in default model IDs', () => {
|
||||
expect(getDefaultModelIds(alibabaStandardProvider)).toEqual([
|
||||
'qwen3.6-plus',
|
||||
'qwen3.7-plus',
|
||||
'qwen3.7-max',
|
||||
'glm-5.1',
|
||||
'deepseek-v4-pro',
|
||||
'deepseek-v4-flash',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves baseUrl for known region', () => {
|
||||
const url = resolveBaseUrl(
|
||||
alibabaStandardProvider,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export const alibabaStandardProvider: ProviderConfig = {
|
|||
envKey: 'DASHSCOPE_API_KEY',
|
||||
models: [
|
||||
{ id: 'qwen3.6-plus', contextWindowSize: 1000000, enableThinking: true },
|
||||
{ id: 'qwen3.7-plus', contextWindowSize: 1000000, enableThinking: true },
|
||||
{ id: 'qwen3.7-max', contextWindowSize: 1000000, enableThinking: true },
|
||||
{ id: 'glm-5.1', contextWindowSize: 202752, enableThinking: true },
|
||||
{
|
||||
id: 'deepseek-v4-pro',
|
||||
|
|
|
|||
|
|
@ -200,12 +200,12 @@ function autoTitleDisabledByEnv(): boolean {
|
|||
|
||||
/**
|
||||
* A single record stored in the JSONL file.
|
||||
* Forms a tree structure via uuid/parentUuid for future checkpointing support.
|
||||
* Forms a tree structure via uuid/parentUuid for future conversation branching support.
|
||||
*
|
||||
* Each record is self-contained with full metadata, enabling:
|
||||
* - Append-only writes (crash-safe)
|
||||
* - Tree reconstruction by following parentUuid chain
|
||||
* - Future checkpointing by branching from any historical record
|
||||
* - Future conversation branching by forking from any historical record
|
||||
*/
|
||||
export interface ChatRecord {
|
||||
/** Unique identifier for this logical message */
|
||||
|
|
@ -446,7 +446,7 @@ export interface RewindRecordPayload {
|
|||
* Each record has uuid/parentUuid fields enabling:
|
||||
* - Append-only writes (never rewrite the file)
|
||||
* - Linear history reconstruction
|
||||
* - Future checkpointing (branch from any historical point)
|
||||
* - Future conversation branching (fork from any historical point)
|
||||
*
|
||||
* File location: ~/.qwen/tmp/<project_id>/chats/
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,256 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { GitService } from './gitService.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import { getProjectHash, QWEN_DIR } from '../utils/paths.js';
|
||||
import { isCommandAvailable } from '../utils/shell-utils.js';
|
||||
|
||||
vi.mock('../utils/shell-utils.js', () => ({
|
||||
isCommandAvailable: vi.fn(),
|
||||
}));
|
||||
|
||||
const hoistedMockEnv = vi.hoisted(() => vi.fn());
|
||||
const hoistedMockSimpleGit = vi.hoisted(() => vi.fn());
|
||||
const hoistedMockCheckIsRepo = vi.hoisted(() => vi.fn());
|
||||
const hoistedMockInit = vi.hoisted(() => vi.fn());
|
||||
const hoistedMockRaw = vi.hoisted(() => vi.fn());
|
||||
const hoistedMockAdd = vi.hoisted(() => vi.fn());
|
||||
const hoistedMockCommit = vi.hoisted(() => vi.fn());
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: hoistedMockSimpleGit.mockImplementation(() => ({
|
||||
checkIsRepo: hoistedMockCheckIsRepo,
|
||||
init: hoistedMockInit,
|
||||
raw: hoistedMockRaw,
|
||||
add: hoistedMockAdd,
|
||||
commit: hoistedMockCommit,
|
||||
env: hoistedMockEnv,
|
||||
})),
|
||||
CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' },
|
||||
}));
|
||||
|
||||
const hoistedIsGitRepositoryMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../utils/gitUtils.js', () => ({
|
||||
isGitRepository: hoistedIsGitRepositoryMock,
|
||||
}));
|
||||
|
||||
const hoistedMockHomedir = vi.hoisted(() => vi.fn());
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof os>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: hoistedMockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
describe('GitService', () => {
|
||||
let testRootDir: string;
|
||||
let projectRoot: string;
|
||||
let homedir: string;
|
||||
let hash: string;
|
||||
let storage: Storage;
|
||||
|
||||
beforeEach(async () => {
|
||||
testRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-service-test-'));
|
||||
projectRoot = path.join(testRootDir, 'project');
|
||||
homedir = path.join(testRootDir, 'home');
|
||||
await fs.mkdir(projectRoot, { recursive: true });
|
||||
await fs.mkdir(homedir, { recursive: true });
|
||||
|
||||
hash = getProjectHash(projectRoot);
|
||||
|
||||
vi.clearAllMocks();
|
||||
hoistedIsGitRepositoryMock.mockReturnValue(true);
|
||||
(isCommandAvailable as Mock).mockReturnValue({ available: true });
|
||||
|
||||
hoistedMockHomedir.mockReturnValue(homedir);
|
||||
|
||||
hoistedMockEnv.mockImplementation(() => ({
|
||||
checkIsRepo: hoistedMockCheckIsRepo,
|
||||
init: hoistedMockInit,
|
||||
raw: hoistedMockRaw,
|
||||
add: hoistedMockAdd,
|
||||
commit: hoistedMockCommit,
|
||||
}));
|
||||
hoistedMockSimpleGit.mockImplementation(() => ({
|
||||
checkIsRepo: hoistedMockCheckIsRepo,
|
||||
init: hoistedMockInit,
|
||||
raw: hoistedMockRaw,
|
||||
add: hoistedMockAdd,
|
||||
commit: hoistedMockCommit,
|
||||
env: hoistedMockEnv,
|
||||
}));
|
||||
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||
hoistedMockInit.mockResolvedValue(undefined);
|
||||
hoistedMockRaw.mockResolvedValue('');
|
||||
hoistedMockAdd.mockResolvedValue(undefined);
|
||||
hoistedMockCommit.mockResolvedValue({
|
||||
commit: 'initial',
|
||||
});
|
||||
storage = new Storage(projectRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(testRootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should successfully create an instance', () => {
|
||||
expect(() => new GitService(projectRoot, storage)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should throw an error if Git is not available', async () => {
|
||||
(isCommandAvailable as Mock).mockReturnValue({ available: false });
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await expect(service.initialize()).rejects.toThrow(
|
||||
'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call setupShadowGitRepository if Git is available', async () => {
|
||||
const service = new GitService(projectRoot, storage);
|
||||
const setupSpy = vi
|
||||
.spyOn(service, 'setupShadowGitRepository')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await service.initialize();
|
||||
expect(setupSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupShadowGitRepository', () => {
|
||||
let repoDir: string;
|
||||
let gitConfigPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoDir = path.join(homedir, QWEN_DIR, 'history', hash);
|
||||
gitConfigPath = path.join(repoDir, '.gitconfig');
|
||||
});
|
||||
|
||||
it('should create history and repository directories', async () => {
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
const stats = await fs.stat(repoDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('should create a .gitconfig file with the correct content', async () => {
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
|
||||
const expectedConfigContent =
|
||||
'[user]\n name = Qwen Code\n email = qwen-code@qwen.ai\n[commit]\n gpgsign = false\n';
|
||||
const actualConfigContent = await fs.readFile(gitConfigPath, 'utf-8');
|
||||
expect(actualConfigContent).toBe(expectedConfigContent);
|
||||
});
|
||||
|
||||
it('should use the shadow git config during repository setup', async () => {
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
|
||||
expect(hoistedMockEnv).toHaveBeenCalledWith({
|
||||
HOME: repoDir,
|
||||
XDG_CONFIG_HOME: repoDir,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize git repo in historyDir if not already initialized', async () => {
|
||||
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
expect(hoistedMockSimpleGit).toHaveBeenCalledWith(repoDir);
|
||||
expect(hoistedMockInit).toHaveBeenCalledWith(false);
|
||||
expect(hoistedMockRaw).toHaveBeenCalledWith([
|
||||
'symbolic-ref',
|
||||
'HEAD',
|
||||
'refs/heads/main',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should initialize git repo when root repo check throws', async () => {
|
||||
hoistedMockCheckIsRepo.mockRejectedValueOnce(
|
||||
new Error('fatal: not a git repository'),
|
||||
);
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await expect(service.setupShadowGitRepository()).resolves.toBeUndefined();
|
||||
expect(hoistedMockInit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialize git repo if already initialized', async () => {
|
||||
hoistedMockCheckIsRepo.mockResolvedValue(true);
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
expect(hoistedMockInit).not.toHaveBeenCalled();
|
||||
expect(hoistedMockRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should copy .gitignore from projectRoot if it exists', async () => {
|
||||
const gitignoreContent = 'node_modules/\n.env';
|
||||
const visibleGitIgnorePath = path.join(projectRoot, '.gitignore');
|
||||
await fs.writeFile(visibleGitIgnorePath, gitignoreContent);
|
||||
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
|
||||
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
|
||||
const copiedContent = await fs.readFile(hiddenGitIgnorePath, 'utf-8');
|
||||
expect(copiedContent).toBe(gitignoreContent);
|
||||
});
|
||||
|
||||
it('should not create a .gitignore in shadow repo if project .gitignore does not exist', async () => {
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
|
||||
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
|
||||
// An empty string is written if the file doesn't exist.
|
||||
const content = await fs.readFile(hiddenGitIgnorePath, 'utf-8');
|
||||
expect(content).toBe('');
|
||||
});
|
||||
|
||||
it('should throw an error if reading projectRoot .gitignore fails with other errors', async () => {
|
||||
const visibleGitIgnorePath = path.join(projectRoot, '.gitignore');
|
||||
// Create a directory instead of a file to cause a read error
|
||||
await fs.mkdir(visibleGitIgnorePath);
|
||||
|
||||
const service = new GitService(projectRoot, storage);
|
||||
// EISDIR is the expected error code on Unix-like systems
|
||||
await expect(service.setupShadowGitRepository()).rejects.toThrow(
|
||||
/EISDIR: illegal operation on a directory, read|EBUSY: resource busy or locked, read/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should make an initial commit if no commits exist in history repo', async () => {
|
||||
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit', {
|
||||
'--allow-empty': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not make an initial commit if commits already exist', async () => {
|
||||
hoistedMockCheckIsRepo.mockResolvedValue(true);
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
expect(hoistedMockCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { isCommandAvailable } from '../utils/shell-utils.js';
|
||||
import type { SimpleGit } from 'simple-git';
|
||||
import { simpleGit, CheckRepoActions } from 'simple-git';
|
||||
import type { Storage } from '../config/storage.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
import { initRepositoryWithMainBranch } from './gitInit.js';
|
||||
|
||||
export class GitService {
|
||||
private projectRoot: string;
|
||||
private storage: Storage;
|
||||
|
||||
constructor(projectRoot: string, storage: Storage) {
|
||||
this.projectRoot = path.resolve(projectRoot);
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
private getHistoryDir(): string {
|
||||
return this.storage.getHistoryDir();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const { available: gitAvailable } = isCommandAvailable('git');
|
||||
if (!gitAvailable) {
|
||||
throw new Error(
|
||||
'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.setupShadowGitRepository();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to initialize checkpointing: ${error instanceof Error ? error.message : 'Unknown error'}. Please check that Git is working properly or disable checkpointing.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a hidden git repository in the project root.
|
||||
* The Git repository is used to support checkpointing.
|
||||
*/
|
||||
async setupShadowGitRepository() {
|
||||
const repoDir = this.getHistoryDir();
|
||||
const gitConfigPath = path.join(repoDir, '.gitconfig');
|
||||
|
||||
await fs.mkdir(repoDir, { recursive: true });
|
||||
|
||||
// We don't want to inherit the user's name, email, or gpg signing
|
||||
// preferences for the shadow repository, so we create a dedicated gitconfig.
|
||||
const gitConfigContent =
|
||||
'[user]\n name = Qwen Code\n email = qwen-code@qwen.ai\n[commit]\n gpgsign = false\n';
|
||||
await fs.writeFile(gitConfigPath, gitConfigContent);
|
||||
|
||||
const repo = simpleGit(repoDir).env({
|
||||
// Prevent git from using the user's global git config.
|
||||
HOME: repoDir,
|
||||
XDG_CONFIG_HOME: repoDir,
|
||||
});
|
||||
let isRepoDefined = false;
|
||||
try {
|
||||
isRepoDefined = await repo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
|
||||
} catch {
|
||||
// Some Git/simple-git combinations throw for non-repo directories
|
||||
// instead of returning false. Treat that as "not initialized yet".
|
||||
isRepoDefined = false;
|
||||
}
|
||||
|
||||
if (!isRepoDefined) {
|
||||
await initRepositoryWithMainBranch(repo);
|
||||
await repo.commit('Initial commit', { '--allow-empty': null });
|
||||
}
|
||||
|
||||
const userGitIgnorePath = path.join(this.projectRoot, '.gitignore');
|
||||
const shadowGitIgnorePath = path.join(repoDir, '.gitignore');
|
||||
|
||||
let userGitIgnoreContent = '';
|
||||
try {
|
||||
userGitIgnoreContent = await fs.readFile(userGitIgnorePath, 'utf-8');
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(shadowGitIgnorePath, userGitIgnoreContent);
|
||||
}
|
||||
|
||||
private get shadowGitRepository(): SimpleGit {
|
||||
const repoDir = this.getHistoryDir();
|
||||
return simpleGit(this.projectRoot).env({
|
||||
GIT_DIR: path.join(repoDir, '.git'),
|
||||
GIT_WORK_TREE: this.projectRoot,
|
||||
// Prevent git from using the user's global git config.
|
||||
HOME: repoDir,
|
||||
XDG_CONFIG_HOME: repoDir,
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrentCommitHash(): Promise<string> {
|
||||
const hash = await this.shadowGitRepository.raw('rev-parse', 'HEAD');
|
||||
return hash.trim();
|
||||
}
|
||||
|
||||
async createFileSnapshot(message: string): Promise<string> {
|
||||
try {
|
||||
const repo = this.shadowGitRepository;
|
||||
await repo.add('.');
|
||||
const commitResult = await repo.commit(message);
|
||||
return commitResult.commit;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create checkpoint snapshot: ${error instanceof Error ? error.message : 'Unknown error'}. Checkpointing may not be working properly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async restoreProjectFromSnapshot(commitHash: string): Promise<void> {
|
||||
const repo = this.shadowGitRepository;
|
||||
await repo.raw(['restore', '--source', commitHash, '.']);
|
||||
// Removes any untracked files that were introduced post snapshot.
|
||||
await repo.clean('f', ['-d']);
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +60,6 @@ Use this index to locate the right document for the user's question. Load only t
|
|||
| Slash commands | `docs/features/commands.md` |
|
||||
| Headless / non-interactive mode | `docs/features/headless.md` |
|
||||
| LSP integration | `docs/features/lsp.md` |
|
||||
| Checkpointing | `docs/features/checkpointing.md` |
|
||||
| Token caching | `docs/features/token-caching.md` |
|
||||
| Language / i18n | `docs/features/language.md` |
|
||||
| Arena mode | `docs/features/arena.md` |
|
||||
|
|
@ -121,7 +120,7 @@ When the user asks about configuration, the primary reference is `docs/configura
|
|||
| Permissions | `permissions.allow/ask/deny` | `docs/configuration/settings.md`, `docs/features/approval-mode.md` |
|
||||
| MCP Servers | `mcpServers.*`, `mcp.*` | `docs/configuration/settings.md`, `docs/features/mcp.md` |
|
||||
| Tool Approval | `tools.approvalMode` | `docs/configuration/settings.md`, `docs/features/approval-mode.md`, `docs/features/auto-mode.md` |
|
||||
| Hooks | `hooks.*` | `docs/features/hooks.md` |
|
||||
| Hooks | `hooks.*` | `docs/configuration/settings.md`, `docs/features/hooks.md` |
|
||||
| Model | `model.name`, `modelProviders` | `docs/configuration/settings.md`, `docs/configuration/model-providers.md` |
|
||||
| General/UI | `general.*`, `ui.*`, `ide.*`, `output.*` | `docs/configuration/settings.md` |
|
||||
| Context | `context.*` | `docs/configuration/settings.md` |
|
||||
|
|
|
|||
|
|
@ -799,6 +799,29 @@ describe('SkillTool', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns the executor error from the disabled-skill delegation path', async () => {
|
||||
// Disabled skill that shadows a same-named command whose executor fails:
|
||||
// the { error } result must surface as the tool result, not fall through
|
||||
// to the generic "skill is disabled" message.
|
||||
vi.mocked(config.getDisabledSkillNames).mockReturnValue(
|
||||
new Set(['blocked']),
|
||||
);
|
||||
const executor = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ error: 'command failed: boom' });
|
||||
vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue(
|
||||
executor,
|
||||
);
|
||||
|
||||
const invocation = (
|
||||
skillTool as SkillToolWithProtectedMethods
|
||||
).createInvocation({ skill: 'blocked' });
|
||||
const result = await invocation.execute();
|
||||
|
||||
expect(result.llmContent).toBe('command failed: boom');
|
||||
expect(result.returnDisplay).toBe('command failed: boom');
|
||||
});
|
||||
|
||||
it('propagates prompt_id through the not-found branch', async () => {
|
||||
// Both loadSkillForRuntime and commandExecutor return null → L399
|
||||
// branch in skill.ts logs a failed SkillLaunchEvent.
|
||||
|
|
|
|||
32
packages/core/src/utils/getPty.test.ts
Normal file
32
packages/core/src/utils/getPty.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getPty } from './getPty.js';
|
||||
|
||||
describe('getPty', () => {
|
||||
it('falls back when running under Bun', async () => {
|
||||
const original = Object.getOwnPropertyDescriptor(process.versions, 'bun');
|
||||
|
||||
Object.defineProperty(process.versions, 'bun', {
|
||||
value: '1.3.8',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(getPty()).resolves.toBeNull();
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(process.versions, 'bun', original);
|
||||
} else {
|
||||
const versions = process.versions as typeof process.versions & {
|
||||
bun?: string;
|
||||
};
|
||||
delete versions.bun;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -18,6 +18,11 @@ export interface PtyProcess {
|
|||
}
|
||||
|
||||
export const getPty = async (): Promise<PtyImplementation> => {
|
||||
// Bun can load @lydell/node-pty, but it hangs under Desktop's runtime.
|
||||
if ('bun' in process.versions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const lydell = '@lydell/node-pty';
|
||||
const module = await import(lydell);
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@
|
|||
"@types/vscode": "^1.85.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.9.2",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.25.1",
|
||||
|
|
|
|||
|
|
@ -91,17 +91,6 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"checkpointing": {
|
||||
"description": "Session checkpointing settings.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Enable session checkpointing for recovery",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"debugKeystrokeLogging": {
|
||||
"description": "Enable debug logging of keystrokes to the console.",
|
||||
"type": "boolean",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ export interface SessionUpdateMeta {
|
|||
durationMs?: number | null;
|
||||
timestamp?: number | null;
|
||||
availableSkills?: string[] | null;
|
||||
availableSkillDetails?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
filePath?: string;
|
||||
level?: string;
|
||||
modelInvocable?: boolean;
|
||||
}> | null;
|
||||
source?: string | null;
|
||||
qwenDiscreteMessage?: boolean | null;
|
||||
// Set on the summary emitted by MessageRewriteMiddleware so consumers can
|
||||
|
|
|
|||
839
scripts/desktop-openwork-sync.ts
Normal file
839
scripts/desktop-openwork-sync.ts
Normal file
|
|
@ -0,0 +1,839 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { spawn } from 'bun';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
type SyncMode = 'auto' | 'export' | 'import';
|
||||
type MigrationMode = Exclude<SyncMode, 'auto'>;
|
||||
|
||||
type RunOptions = {
|
||||
allowFailure?: boolean;
|
||||
capture?: boolean;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
mode: SyncMode;
|
||||
openworkDir: string;
|
||||
openworkRef: string;
|
||||
qwenBase: string;
|
||||
branch?: string;
|
||||
overlayPaths: string[];
|
||||
sourceBase?: string;
|
||||
allowDirtySource: boolean;
|
||||
};
|
||||
|
||||
const desktopPrefix = 'packages/desktop';
|
||||
const repoRoot = resolve(import.meta.dir, '..');
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString().replace(/[-:.TZ]/g, '');
|
||||
}
|
||||
|
||||
function normalizeGitPath(value: string): string {
|
||||
const path = value
|
||||
.trim()
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^\.\/+/, '');
|
||||
if (!path || path === '.') {
|
||||
throw new Error('Overlay path cannot be empty.');
|
||||
}
|
||||
if (path.startsWith('/') || path.split('/').includes('..')) {
|
||||
throw new Error(`Overlay path must be repository-relative: ${value}`);
|
||||
}
|
||||
return path.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function parsePathList(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((path) => path.trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizeGitPath);
|
||||
}
|
||||
|
||||
function parseMode(value: string): SyncMode {
|
||||
if (value === 'auto' || value === 'export' || value === 'import') {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Invalid mode: ${value}`);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function defaultBranch(mode: Exclude<SyncMode, 'auto'>): string {
|
||||
const verb = mode === 'export' ? 'sync' : 'import';
|
||||
return `chore/${verb}-openwork-desktop-${timestamp()}`;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage: bun run desktop-openwork-sync --openwork-dir /path/to/openwork [options]
|
||||
|
||||
Commit-migrate changes between qwen-code packages/desktop and OpenWork.
|
||||
|
||||
Modes:
|
||||
--mode auto Refuse if direction is ambiguous
|
||||
--mode export Apply qwen-code packages/desktop commits to OpenWork
|
||||
--mode import Apply OpenWork commits to qwen-code packages/desktop
|
||||
|
||||
Options:
|
||||
--openwork-dir <path> Path to a clean OpenWork checkout
|
||||
--openwork-ref <ref> OpenWork ref to read or branch from (default: main)
|
||||
--base <ref> Alias for --openwork-ref
|
||||
--qwen-base <ref> qwen-code base for import branches (default: HEAD)
|
||||
--source-base <ref> Source-side base ref for the commit range
|
||||
--branch <name> Target branch name in the repo being changed
|
||||
--overlay <path[,path]> Do not migrate these source paths (repeatable)
|
||||
--allow-dirty-source Allow uncommitted packages/desktop changes to be omitted during export
|
||||
--no-abort-on-conflict Accepted for compatibility; conflicts are left for resolution
|
||||
-h, --help Show this help
|
||||
|
||||
Environment:
|
||||
OPENWORK_DIR
|
||||
OPENWORK_REF
|
||||
OPENWORK_BASE_REF Alias for OPENWORK_REF
|
||||
QWEN_BASE_REF
|
||||
OPENWORK_SYNC_BRANCH
|
||||
OPENWORK_SYNC_SOURCE_BASE
|
||||
OPENWORK_OVERLAY_PATHS Comma-separated overlays (default: README.md)
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const overlayPaths = new Set(
|
||||
parsePathList(process.env.OPENWORK_OVERLAY_PATHS || 'README.md'),
|
||||
);
|
||||
let mode: SyncMode = 'auto';
|
||||
let openworkDir = process.env.OPENWORK_DIR?.trim();
|
||||
let openworkRef =
|
||||
process.env.OPENWORK_REF?.trim() ||
|
||||
process.env.OPENWORK_BASE_REF?.trim() ||
|
||||
'main';
|
||||
let qwenBase = process.env.QWEN_BASE_REF?.trim() || 'HEAD';
|
||||
let branch = process.env.OPENWORK_SYNC_BRANCH?.trim();
|
||||
let sourceBase = process.env.OPENWORK_SYNC_SOURCE_BASE?.trim();
|
||||
let allowDirtySource = false;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = (): string => {
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error(`Missing value for ${arg}`);
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case '--mode':
|
||||
mode = parseMode(next());
|
||||
break;
|
||||
case '--openwork-dir':
|
||||
openworkDir = next();
|
||||
break;
|
||||
case '--openwork-ref':
|
||||
case '--base':
|
||||
openworkRef = next();
|
||||
break;
|
||||
case '--qwen-base':
|
||||
qwenBase = next();
|
||||
break;
|
||||
case '--source-base':
|
||||
sourceBase = next();
|
||||
break;
|
||||
case '--branch':
|
||||
branch = next();
|
||||
break;
|
||||
case '--overlay':
|
||||
for (const path of parsePathList(next())) {
|
||||
overlayPaths.add(path);
|
||||
}
|
||||
break;
|
||||
case '--allow-dirty-source':
|
||||
allowDirtySource = true;
|
||||
break;
|
||||
case '--no-abort-on-conflict':
|
||||
break;
|
||||
case '-h':
|
||||
case '--help':
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!openworkDir) {
|
||||
throw new Error(
|
||||
'Missing OpenWork checkout. Pass --openwork-dir or set OPENWORK_DIR.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
openworkDir: resolve(openworkDir),
|
||||
openworkRef,
|
||||
qwenBase,
|
||||
branch,
|
||||
overlayPaths: [...overlayPaths],
|
||||
sourceBase,
|
||||
allowDirtySource,
|
||||
};
|
||||
}
|
||||
|
||||
async function run(
|
||||
cmd: string[],
|
||||
cwd: string,
|
||||
options: RunOptions = {},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const proc = spawn({
|
||||
cmd,
|
||||
cwd,
|
||||
stdin: 'inherit',
|
||||
stdout: options.capture ? 'pipe' : 'inherit',
|
||||
stderr: options.capture ? 'pipe' : 'inherit',
|
||||
});
|
||||
|
||||
const stdoutPromise =
|
||||
options.capture && proc.stdout
|
||||
? new Response(proc.stdout).text()
|
||||
: Promise.resolve('');
|
||||
const stderrPromise =
|
||||
options.capture && proc.stderr
|
||||
? new Response(proc.stderr).text()
|
||||
: Promise.resolve('');
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
stdoutPromise,
|
||||
stderrPromise,
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (exitCode !== 0 && !options.allowFailure) {
|
||||
const detail = stderr.trim() || stdout.trim();
|
||||
throw new Error(
|
||||
`${cmd.join(' ')} failed with exit code ${exitCode}${
|
||||
detail ? `\n${detail}` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
async function git(
|
||||
cwd: string,
|
||||
args: string[],
|
||||
options: RunOptions = {},
|
||||
): ReturnType<typeof run> {
|
||||
return run(['git', ...args], cwd, options);
|
||||
}
|
||||
|
||||
async function getRepoRoot(path: string): Promise<string> {
|
||||
const result = await git(path, ['rev-parse', '--show-toplevel'], {
|
||||
capture: true,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function revParse(cwd: string, ref: string): Promise<string> {
|
||||
const result = await git(cwd, ['rev-parse', '--verify', `${ref}^{commit}`], {
|
||||
capture: true,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function objectExists(cwd: string, ref: string): Promise<boolean> {
|
||||
const result = await git(cwd, ['cat-file', '-e', `${ref}^{commit}`], {
|
||||
allowFailure: true,
|
||||
capture: true,
|
||||
});
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
async function localBranchExists(
|
||||
cwd: string,
|
||||
branch: string,
|
||||
): Promise<boolean> {
|
||||
const result = await git(
|
||||
cwd,
|
||||
['show-ref', '--verify', `refs/heads/${branch}`],
|
||||
{ allowFailure: true, capture: true },
|
||||
);
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
async function switchTargetBranch(
|
||||
cwd: string,
|
||||
branch: string,
|
||||
baseRef: string,
|
||||
): Promise<void> {
|
||||
if (await localBranchExists(cwd, branch)) {
|
||||
await git(cwd, ['switch', branch]);
|
||||
return;
|
||||
}
|
||||
|
||||
await git(cwd, ['switch', '-c', branch, baseRef]);
|
||||
}
|
||||
|
||||
async function ensureCleanWorktree(
|
||||
cwd: string,
|
||||
label: string,
|
||||
paths: string[] = [],
|
||||
): Promise<void> {
|
||||
const args = ['status', '--porcelain'];
|
||||
if (paths.length > 0) {
|
||||
args.push('--', ...paths);
|
||||
}
|
||||
|
||||
const status = await git(cwd, args, { capture: true });
|
||||
if (status.stdout.trim()) {
|
||||
throw new Error(
|
||||
`${label} must be clean before syncing:\n${status.stdout.trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCommittedDesktopSource(
|
||||
allowDirtySource: boolean,
|
||||
): Promise<void> {
|
||||
if (allowDirtySource) return;
|
||||
await ensureCleanWorktree(repoRoot, desktopPrefix, [desktopPrefix]);
|
||||
}
|
||||
|
||||
async function findLatestTrailer(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
trailer: string,
|
||||
): Promise<string | undefined> {
|
||||
const result = await git(cwd, ['log', '--format=%B%x00', ref], {
|
||||
capture: true,
|
||||
});
|
||||
const pattern = new RegExp(
|
||||
`^${escapeRegExp(trailer)}:\\s*([^\\s]+)\\s*$`,
|
||||
'im',
|
||||
);
|
||||
return result.stdout.match(pattern)?.[1];
|
||||
}
|
||||
|
||||
async function getCommitBody(cwd: string, commit: string): Promise<string> {
|
||||
const result = await git(cwd, ['log', '-n', '1', '--format=%B', commit], {
|
||||
capture: true,
|
||||
});
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function findTrailer(body: string, trailer: string): string | undefined {
|
||||
const pattern = new RegExp(
|
||||
`^${escapeRegExp(trailer)}:\\s*([^\\s]+)\\s*$`,
|
||||
'im',
|
||||
);
|
||||
return body.match(pattern)?.[1];
|
||||
}
|
||||
|
||||
async function findTrailerValues(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
trailer: string,
|
||||
): Promise<Set<string>> {
|
||||
const result = await git(cwd, ['log', '--format=%B%x00', ref], {
|
||||
capture: true,
|
||||
});
|
||||
const pattern = new RegExp(
|
||||
`^${escapeRegExp(trailer)}:\\s*([^\\s]+)\\s*$`,
|
||||
'gim',
|
||||
);
|
||||
const values = new Set<string>();
|
||||
for (const match of result.stdout.matchAll(pattern)) {
|
||||
values.add(match[1]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
async function resolveSourceBase(
|
||||
sourceRepo: string,
|
||||
explicitBase: string | undefined,
|
||||
targetRepo: string,
|
||||
targetRef: string,
|
||||
trailer: string,
|
||||
): Promise<string> {
|
||||
const base =
|
||||
explicitBase ?? (await findLatestTrailer(targetRepo, targetRef, trailer));
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`Missing source base. Pass --source-base or create a prior sync commit ` +
|
||||
`with a ${trailer} trailer.`,
|
||||
);
|
||||
}
|
||||
if (!(await objectExists(sourceRepo, base))) {
|
||||
throw new Error(`Source base does not exist in source repo: ${base}`);
|
||||
}
|
||||
return revParse(sourceRepo, base);
|
||||
}
|
||||
|
||||
function exportPathspecs(overlayPaths: string[]): string[] {
|
||||
return [
|
||||
desktopPrefix,
|
||||
...overlayPaths.map((path) => `:!${desktopPrefix}/${path}`),
|
||||
];
|
||||
}
|
||||
|
||||
function importPathspecs(overlayPaths: string[]): string[] {
|
||||
return ['.', ...overlayPaths.map((path) => `:!${path}`)];
|
||||
}
|
||||
|
||||
async function createExportPatch(
|
||||
base: string,
|
||||
source: string,
|
||||
overlayPaths: string[],
|
||||
): Promise<string> {
|
||||
const result = await git(
|
||||
repoRoot,
|
||||
[
|
||||
'diff',
|
||||
'--binary',
|
||||
'--full-index',
|
||||
`--relative=${desktopPrefix}`,
|
||||
base,
|
||||
source,
|
||||
'--',
|
||||
...exportPathspecs(overlayPaths),
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function createImportPatch(
|
||||
openworkRoot: string,
|
||||
base: string,
|
||||
source: string,
|
||||
overlayPaths: string[],
|
||||
): Promise<string> {
|
||||
const result = await git(
|
||||
openworkRoot,
|
||||
[
|
||||
'diff',
|
||||
'--binary',
|
||||
'--full-index',
|
||||
`--src-prefix=a/${desktopPrefix}/`,
|
||||
`--dst-prefix=b/${desktopPrefix}/`,
|
||||
base,
|
||||
source,
|
||||
'--',
|
||||
...importPathspecs(overlayPaths),
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function getSourceCommits(
|
||||
cwd: string,
|
||||
base: string,
|
||||
source: string,
|
||||
pathspecs: string[],
|
||||
): Promise<string[]> {
|
||||
const result = await git(
|
||||
cwd,
|
||||
[
|
||||
'log',
|
||||
'--reverse',
|
||||
'--topo-order',
|
||||
'--format=%H',
|
||||
`${base}..${source}`,
|
||||
'--',
|
||||
...pathspecs,
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
return result.stdout.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
async function getMergeIntroducedCommits(
|
||||
cwd: string,
|
||||
firstParent: string,
|
||||
mergeCommit: string,
|
||||
pathspecs: string[],
|
||||
): Promise<string[]> {
|
||||
const result = await git(
|
||||
cwd,
|
||||
[
|
||||
'log',
|
||||
'--reverse',
|
||||
'--topo-order',
|
||||
'--no-merges',
|
||||
'--format=%H',
|
||||
`${firstParent}..${mergeCommit}`,
|
||||
'--',
|
||||
...pathspecs,
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
return result.stdout.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
async function getParents(cwd: string, commit: string): Promise<string[]> {
|
||||
const result = await git(cwd, ['rev-list', '--parents', '-n', '1', commit], {
|
||||
capture: true,
|
||||
});
|
||||
const [, ...parents] = result.stdout.trim().split(/\s+/);
|
||||
return parents;
|
||||
}
|
||||
|
||||
async function shouldSkipSyncedCommit(
|
||||
cwd: string,
|
||||
commit: string,
|
||||
mode: MigrationMode,
|
||||
): Promise<string | undefined> {
|
||||
const body = await getCommitBody(cwd, commit);
|
||||
const syncMode = findTrailer(body, 'OpenWork-Sync-Mode');
|
||||
|
||||
if (
|
||||
mode === 'import' &&
|
||||
(syncMode === 'export' || findTrailer(body, 'Qwen-Code-Commit'))
|
||||
) {
|
||||
return 'already came from qwen-code';
|
||||
}
|
||||
|
||||
if (
|
||||
mode === 'export' &&
|
||||
(syncMode === 'import' || findTrailer(body, 'OpenWork-Commit'))
|
||||
) {
|
||||
return 'already came from OpenWork';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function targetAlreadySyncedCommit(
|
||||
commit: string,
|
||||
targetTrailer: string,
|
||||
targetSyncedCommits: Set<string>,
|
||||
): string | undefined {
|
||||
if (targetSyncedCommits.has(commit)) {
|
||||
return `target already has ${targetTrailer}: ${commit}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function ensureSimpleMergeCommit(params: {
|
||||
mode: MigrationMode;
|
||||
sourceRepo: string;
|
||||
commit: string;
|
||||
parents: string[];
|
||||
pathspecs: string[];
|
||||
handledCommits: Set<string>;
|
||||
targetSyncedCommits: Set<string>;
|
||||
}): Promise<void> {
|
||||
const [firstParent, secondParent] = params.parents;
|
||||
if (!firstParent || !secondParent || params.parents.length !== 2) {
|
||||
throw new Error(`Cannot inspect octopus merge commit: ${params.commit}`);
|
||||
}
|
||||
|
||||
const introducedCommits = await getMergeIntroducedCommits(
|
||||
params.sourceRepo,
|
||||
firstParent,
|
||||
params.commit,
|
||||
params.pathspecs,
|
||||
);
|
||||
const missingCommits: string[] = [];
|
||||
for (const commit of introducedCommits) {
|
||||
if (
|
||||
params.handledCommits.has(commit) ||
|
||||
params.targetSyncedCommits.has(commit) ||
|
||||
(await shouldSkipSyncedCommit(params.sourceRepo, commit, params.mode))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
missingCommits.push(commit);
|
||||
}
|
||||
if (missingCommits.length > 0) {
|
||||
throw new Error(
|
||||
`Merge commit introduced unhandled commits: ${params.commit}\n` +
|
||||
missingCommits.map((commit) => ` ${commit}`).join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const mergeTree = await git(
|
||||
params.sourceRepo,
|
||||
['merge-tree', '--write-tree', firstParent, secondParent],
|
||||
{ allowFailure: true, capture: true },
|
||||
);
|
||||
if (mergeTree.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Merge commit requires manual resolution: ${params.commit}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tree = mergeTree.stdout.trim().split(/\s+/)[0];
|
||||
const diff = await git(
|
||||
params.sourceRepo,
|
||||
['diff', '--quiet', tree, params.commit, '--', ...params.pathspecs],
|
||||
{ allowFailure: true },
|
||||
);
|
||||
if (diff.exitCode === 0) return;
|
||||
if (diff.exitCode === 1) {
|
||||
throw new Error(
|
||||
`Merge commit has manual resolution changes: ${params.commit}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unable to inspect merge commit: ${params.commit}`);
|
||||
}
|
||||
|
||||
async function getCommitSubject(cwd: string, commit: string): Promise<string> {
|
||||
const result = await git(cwd, ['log', '-n', '1', '--format=%s', commit], {
|
||||
capture: true,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function applyPatch(cwd: string, patch: string): Promise<void> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'openwork-sync-'));
|
||||
const patchPath = join(dir, 'sync.patch');
|
||||
try {
|
||||
await writeFile(patchPath, patch);
|
||||
await git(cwd, ['apply', '-3', '--binary', patchPath]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function commitChanges(
|
||||
cwd: string,
|
||||
subject: string,
|
||||
trailers: string[],
|
||||
): Promise<boolean> {
|
||||
await git(cwd, ['add', '-A']);
|
||||
const diff = await git(cwd, ['diff', '--cached', '--quiet'], {
|
||||
allowFailure: true,
|
||||
});
|
||||
|
||||
if (diff.exitCode === 0) {
|
||||
console.log('Source patch produced no target changes; no commit created.');
|
||||
return false;
|
||||
}
|
||||
if (diff.exitCode !== 1) {
|
||||
throw new Error('Unable to inspect staged sync diff.');
|
||||
}
|
||||
|
||||
await git(cwd, [
|
||||
'-c',
|
||||
'core.hooksPath=/dev/null',
|
||||
'commit',
|
||||
'-m',
|
||||
[subject, '', ...trailers].join('\n'),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function migrateCommits(params: {
|
||||
mode: MigrationMode;
|
||||
sourceRepo: string;
|
||||
targetRepo: string;
|
||||
commits: string[];
|
||||
pathspecs: string[];
|
||||
createPatch: (parent: string, commit: string) => Promise<string>;
|
||||
trailers: (parent: string, commit: string) => string[];
|
||||
}): Promise<number> {
|
||||
let count = 0;
|
||||
const handledCommits = new Set<string>();
|
||||
const targetTrailer =
|
||||
params.mode === 'import' ? 'OpenWork-Commit' : 'Qwen-Code-Commit';
|
||||
const targetSyncedCommits = await findTrailerValues(
|
||||
params.targetRepo,
|
||||
'HEAD',
|
||||
targetTrailer,
|
||||
);
|
||||
|
||||
for (const commit of params.commits) {
|
||||
const parents = await getParents(params.sourceRepo, commit);
|
||||
const parent = parents[0];
|
||||
if (!parent) {
|
||||
throw new Error(`Cannot migrate root commit as a patch: ${commit}`);
|
||||
}
|
||||
|
||||
const targetReason = targetAlreadySyncedCommit(
|
||||
commit,
|
||||
targetTrailer,
|
||||
targetSyncedCommits,
|
||||
);
|
||||
if (targetReason) {
|
||||
console.log(`Skipping ${commit.slice(0, 12)}; ${targetReason}.`);
|
||||
handledCommits.add(commit);
|
||||
continue;
|
||||
}
|
||||
|
||||
const skipReason = await shouldSkipSyncedCommit(
|
||||
params.sourceRepo,
|
||||
commit,
|
||||
params.mode,
|
||||
);
|
||||
if (skipReason) {
|
||||
console.log(`Skipping ${commit.slice(0, 12)}; ${skipReason}.`);
|
||||
handledCommits.add(commit);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parents.length > 1) {
|
||||
await ensureSimpleMergeCommit({
|
||||
mode: params.mode,
|
||||
sourceRepo: params.sourceRepo,
|
||||
commit,
|
||||
parents,
|
||||
pathspecs: params.pathspecs,
|
||||
handledCommits,
|
||||
targetSyncedCommits,
|
||||
});
|
||||
console.log(
|
||||
`Skipping merge ${commit.slice(0, 12)}; regular commits handled.`,
|
||||
);
|
||||
handledCommits.add(commit);
|
||||
continue;
|
||||
}
|
||||
|
||||
const patch = await params.createPatch(parent, commit);
|
||||
if (!patch.trim()) {
|
||||
handledCommits.add(commit);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Applying ${commit.slice(0, 12)}...`);
|
||||
await applyPatch(params.targetRepo, patch);
|
||||
const subject = await getCommitSubject(params.sourceRepo, commit);
|
||||
if (
|
||||
await commitChanges(params.targetRepo, subject, [
|
||||
...params.trailers(parent, commit),
|
||||
])
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
handledCommits.add(commit);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async function runExport(options: Options): Promise<void> {
|
||||
const openworkRoot = await getRepoRoot(options.openworkDir);
|
||||
const branch = options.branch || defaultBranch('export');
|
||||
|
||||
await ensureCleanWorktree(openworkRoot, 'OpenWork checkout');
|
||||
await ensureCommittedDesktopSource(options.allowDirtySource);
|
||||
|
||||
const source = await revParse(repoRoot, 'HEAD');
|
||||
const base = await resolveSourceBase(
|
||||
repoRoot,
|
||||
options.sourceBase,
|
||||
openworkRoot,
|
||||
options.openworkRef,
|
||||
'Qwen-Code-Commit',
|
||||
);
|
||||
const pathspecs = exportPathspecs(options.overlayPaths);
|
||||
const commits = await getSourceCommits(repoRoot, base, source, pathspecs);
|
||||
if (commits.length === 0) {
|
||||
console.log('No qwen-code source changes to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Preparing ${branch} in ${openworkRoot}...`);
|
||||
await switchTargetBranch(openworkRoot, branch, options.openworkRef);
|
||||
const openworkBase = await revParse(openworkRoot, options.openworkRef);
|
||||
const count = await migrateCommits({
|
||||
mode: 'export',
|
||||
sourceRepo: repoRoot,
|
||||
targetRepo: openworkRoot,
|
||||
commits,
|
||||
pathspecs,
|
||||
createPatch: (parent, commit) =>
|
||||
createExportPatch(parent, commit, options.overlayPaths),
|
||||
trailers: (parent, commit) => [
|
||||
'OpenWork-Sync-Mode: export',
|
||||
`Qwen-Code-Base: ${parent}`,
|
||||
`Qwen-Code-Commit: ${commit}`,
|
||||
`OpenWork-Base: ${openworkBase}`,
|
||||
],
|
||||
});
|
||||
if (count === 0) return;
|
||||
|
||||
console.log(`Created ${branch} in ${openworkRoot} with ${count} commits.`);
|
||||
console.log(`Next: git -C ${openworkRoot} push -u origin ${branch}`);
|
||||
}
|
||||
|
||||
async function runImport(options: Options): Promise<void> {
|
||||
const openworkRoot = await getRepoRoot(options.openworkDir);
|
||||
const branch = options.branch || defaultBranch('import');
|
||||
|
||||
await ensureCleanWorktree(openworkRoot, 'OpenWork checkout');
|
||||
await ensureCleanWorktree(repoRoot, 'qwen-code checkout');
|
||||
|
||||
const source = await revParse(openworkRoot, options.openworkRef);
|
||||
const base = await resolveSourceBase(
|
||||
openworkRoot,
|
||||
options.sourceBase,
|
||||
repoRoot,
|
||||
options.qwenBase,
|
||||
'OpenWork-Commit',
|
||||
);
|
||||
const pathspecs = importPathspecs(options.overlayPaths);
|
||||
const commits = await getSourceCommits(openworkRoot, base, source, pathspecs);
|
||||
if (commits.length === 0) {
|
||||
console.log('No OpenWork source changes to import.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Preparing ${branch} in ${repoRoot}...`);
|
||||
await switchTargetBranch(repoRoot, branch, options.qwenBase);
|
||||
const qwenBase = await revParse(repoRoot, options.qwenBase);
|
||||
const count = await migrateCommits({
|
||||
mode: 'import',
|
||||
sourceRepo: openworkRoot,
|
||||
targetRepo: repoRoot,
|
||||
commits,
|
||||
pathspecs,
|
||||
createPatch: (parent, commit) =>
|
||||
createImportPatch(openworkRoot, parent, commit, options.overlayPaths),
|
||||
trailers: (parent, commit) => [
|
||||
'OpenWork-Sync-Mode: import',
|
||||
`OpenWork-Base: ${parent}`,
|
||||
`OpenWork-Commit: ${commit}`,
|
||||
`Qwen-Code-Base: ${qwenBase}`,
|
||||
],
|
||||
});
|
||||
if (count === 0) return;
|
||||
|
||||
console.log(`Created ${branch} in ${repoRoot} with ${count} commits.`);
|
||||
}
|
||||
|
||||
async function runAuto(): Promise<void> {
|
||||
throw new Error(
|
||||
'Auto mode is intentionally conservative. Use --mode export or --mode ' +
|
||||
'import so the receiving repository is explicit.',
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
switch (options.mode) {
|
||||
case 'auto':
|
||||
await runAuto();
|
||||
break;
|
||||
case 'export':
|
||||
await runExport(options);
|
||||
break;
|
||||
case 'import':
|
||||
await runImport(options);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -109,24 +109,27 @@ const env = {
|
|||
|
||||
// On Windows, use tsx.cmd; on Unix, use tsx directly
|
||||
const isWin = platform() === 'win32';
|
||||
const localTsxCmd = join(
|
||||
root,
|
||||
'node_modules',
|
||||
'.bin',
|
||||
isWin ? 'tsx.cmd' : 'tsx',
|
||||
);
|
||||
const tsxCmd = existsSync(localTsxCmd)
|
||||
? localTsxCmd
|
||||
: isWin
|
||||
? 'tsx.cmd'
|
||||
: 'tsx';
|
||||
const tsxArgs = [cliEntry, ...process.argv.slice(2)];
|
||||
const tsxBinName = isWin ? 'tsx.cmd' : 'tsx';
|
||||
const localTsxCli = join(root, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
||||
const localTsxCmd = join(root, 'node_modules', '.bin', tsxBinName);
|
||||
const hasLocalTsxCli = existsSync(localTsxCli);
|
||||
const tsxCmd = hasLocalTsxCli
|
||||
? process.execPath
|
||||
: existsSync(localTsxCmd)
|
||||
? localTsxCmd
|
||||
: tsxBinName;
|
||||
const tsxArgs = [
|
||||
...(hasLocalTsxCli ? [localTsxCli] : []),
|
||||
cliEntry,
|
||||
...process.argv.slice(2),
|
||||
];
|
||||
const useShell = isWin && !hasLocalTsxCli;
|
||||
|
||||
const child = spawn(tsxCmd, tsxArgs, {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
cwd: process.cwd(),
|
||||
shell: isWin, // Use shell on Windows to resolve .cmd files
|
||||
shell: useShell, // Needed only when falling back to tsx.cmd on Windows.
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
|
|
|
|||
97
scripts/tests/dev.test.js
Normal file
97
scripts/tests/dev.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { spawnMock, platformMock, existsSyncMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(() => ({ on: vi.fn() })),
|
||||
platformMock: vi.fn(() => 'darwin'),
|
||||
existsSyncMock: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const normalizePath = (filePath) => String(filePath).replaceAll('\\', '/');
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
platform: platformMock,
|
||||
tmpdir: vi.fn(() => '/tmp'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
mkdtempSync: vi.fn(() => '/tmp/qwen-dev-test'),
|
||||
rmSync: vi.fn(),
|
||||
existsSync: existsSyncMock,
|
||||
symlinkSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('scripts/dev.js launcher', () => {
|
||||
const originalArgv = process.argv;
|
||||
const execPathDescriptor = Object.getOwnPropertyDescriptor(
|
||||
process,
|
||||
'execPath',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
process.argv = ['node', 'scripts/dev.js'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
if (execPathDescriptor) {
|
||||
Object.defineProperty(process, 'execPath', execPathDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it('spawns Node without a shell on Windows when local tsx cli.mjs exists', async () => {
|
||||
platformMock.mockReturnValue('win32');
|
||||
existsSyncMock.mockImplementation((filePath) =>
|
||||
normalizePath(filePath).endsWith('node_modules/tsx/dist/cli.mjs'),
|
||||
);
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
configurable: true,
|
||||
value: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
});
|
||||
process.argv = ['node', 'scripts/dev.js', '--help'];
|
||||
|
||||
await import('../dev.js?direct-node');
|
||||
|
||||
const [command, args, options] = spawnMock.mock.calls[0];
|
||||
expect(command).toBe('C:\\Program Files\\nodejs\\node.exe');
|
||||
expect(args.map(normalizePath)).toEqual([
|
||||
expect.stringContaining('node_modules/tsx/dist/cli.mjs'),
|
||||
expect.stringContaining('packages/cli/index.ts'),
|
||||
'--help',
|
||||
]);
|
||||
expect(options).toEqual(expect.objectContaining({ shell: false }));
|
||||
});
|
||||
|
||||
it('keeps shell fallback for Windows tsx.cmd resolution', async () => {
|
||||
platformMock.mockReturnValue('win32');
|
||||
existsSyncMock.mockImplementation((filePath) =>
|
||||
normalizePath(filePath).endsWith('node_modules/.bin/tsx.cmd'),
|
||||
);
|
||||
|
||||
await import('../dev.js?cmd-fallback');
|
||||
|
||||
const [command, args, options] = spawnMock.mock.calls[0];
|
||||
expect(normalizePath(command)).toContain('tsx.cmd');
|
||||
expect(args.map(normalizePath)).toEqual([
|
||||
expect.stringContaining('packages/cli/index.ts'),
|
||||
]);
|
||||
expect(options).toEqual(expect.objectContaining({ shell: true }));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue