feat(acp): support desktop qwen integration (#4728)

* feat(acp): support desktop qwen integration

* feat(providers): add qwen3.7 standard models
This commit is contained in:
Dragon 2026-06-09 19:09:44 +08:00 committed by GitHub
parent 9e4c87a7e4
commit 423cac110c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 9267 additions and 37 deletions

545
.github/workflows/desktop-release.yml vendored Normal file
View 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"

View 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.

2049
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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', () => ({

View file

@ -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

View file

@ -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 } : {}),
},
}
: {}),

View file

@ -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',
}),
}),
);
});

View file

@ -276,6 +276,8 @@ export class SubAgentTracker {
event.text,
'assistant',
event.thought ?? false,
undefined,
this.getSubagentMeta(),
);
};
}

View file

@ -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', () => {

View file

@ -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);
}
}

View file

@ -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'];

View file

@ -690,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',

View file

@ -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(

View file

@ -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(

View file

@ -397,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,

View file

@ -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,

View file

@ -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',

View file

@ -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.

View 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;
}
}
});
});

View file

@ -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);

View file

@ -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",

View file

@ -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

View 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);
});

View file

@ -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) => {

95
scripts/tests/dev.test.js Normal file
View file

@ -0,0 +1,95 @@
/**
* @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),
}));
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) =>
String(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');
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\nodejs\\node.exe',
[
expect.stringContaining('node_modules/tsx/dist/cli.mjs'),
expect.stringContaining('packages/cli/index.ts'),
'--help',
],
expect.objectContaining({ shell: false }),
);
});
it('keeps shell fallback for Windows tsx.cmd resolution', async () => {
platformMock.mockReturnValue('win32');
existsSyncMock.mockImplementation((filePath) =>
String(filePath).endsWith('node_modules/.bin/tsx.cmd'),
);
await import('../dev.js?cmd-fallback');
expect(spawnMock).toHaveBeenCalledWith(
expect.stringContaining('tsx.cmd'),
[expect.stringContaining('packages/cli/index.ts')],
expect.objectContaining({ shell: true }),
);
});
});