mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-17 22:39:13 +00:00
Compare commits
222 Commits
fix-langua
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a482e090 | ||
|
|
52d6d1ff13 | ||
|
|
b93bb8bff6 | ||
|
|
09196c6e19 | ||
|
|
4bd01d592b | ||
|
|
1aed5ce858 | ||
|
|
bad5b0485d | ||
|
|
5a6e5bb452 | ||
|
|
5f8e1ebc94 | ||
|
|
9670456a56 | ||
|
|
4c186e7c92 | ||
|
|
2f6b0b233a | ||
|
|
9a8ce605c5 | ||
|
|
afc693a4ab | ||
|
|
7173cba844 | ||
|
|
8c56b612fb | ||
|
|
7d40e1470c | ||
|
|
097482910e | ||
|
|
9b78c17638 | ||
|
|
bde31d1261 | ||
|
|
7f15256eba | ||
|
|
587fc82fbc | ||
|
|
cba9c424eb | ||
|
|
8705f734d0 | ||
|
|
6714f9ce3c | ||
|
|
155d1f9518 | ||
|
|
f776075aa8 | ||
|
|
36c142951a | ||
|
|
2b511d0b83 | ||
|
|
85bc0833b4 | ||
|
|
2662639280 | ||
|
|
b7ac94ecf6 | ||
|
|
be8259b218 | ||
|
|
ca4c36f233 | ||
|
|
f41308f34c | ||
|
|
0a33510304 | ||
|
|
82cbdee3b4 | ||
|
|
81de79c899 | ||
|
|
f6a753cf78 | ||
|
|
509d304742 | ||
|
|
6319a6ed56 | ||
|
|
ab07c2d89c | ||
|
|
5ea841dd02 | ||
|
|
ded1ebcdff | ||
|
|
afe6ba255e | ||
|
|
fe2ed889b9 | ||
|
|
8da376637a | ||
|
|
15f4c1ebd6 | ||
|
|
492da0c8c0 | ||
|
|
90855c93d1 | ||
|
|
db12796df5 | ||
|
|
aa9cdf2a3c | ||
|
|
0a0ab64da0 | ||
|
|
8a15017593 | ||
|
|
4d54a231b3 | ||
|
|
570ec432af | ||
|
|
bfc3bbfa9c | ||
|
|
91af9bf6c8 | ||
|
|
f6771c0858 | ||
|
|
2c8be05029 | ||
|
|
4744af1ea8 | ||
|
|
2c285394c7 | ||
|
|
0f1cb162c9 | ||
|
|
3d059b71de | ||
|
|
f2d941e469 | ||
|
|
9b2dfe1e06 | ||
|
|
3e695cd82b | ||
|
|
177a91f1d5 | ||
|
|
870d207f18 | ||
|
|
3f512528cb | ||
|
|
0878ee4cbd | ||
|
|
bfe7298858 | ||
|
|
2f2937aafe | ||
|
|
8fcdd86b91 | ||
|
|
d7d7bf0c39 | ||
|
|
b95d9a8d2d | ||
|
|
6f39ae120c | ||
|
|
627857621a | ||
|
|
65c7cf5d8f | ||
|
|
7a823060ac | ||
|
|
2c88ea6dc1 | ||
|
|
ad3086f7dd | ||
|
|
8f3bbef575 | ||
|
|
e2d6ab9b7e | ||
|
|
35bf5ef4d0 | ||
|
|
1d16513e27 | ||
|
|
731fd99800 | ||
|
|
c6ae0a8be7 | ||
|
|
87dc618a21 | ||
|
|
94a5d828bd | ||
|
|
49892a8e17 | ||
|
|
d1a3e828b7 | ||
|
|
b19bb6cb20 | ||
|
|
e8625658ba | ||
|
|
19f8f631b4 | ||
|
|
a4eb3adea8 | ||
|
|
7dc7c6380d | ||
|
|
d2d2b845c5 | ||
|
|
96080f84a6 | ||
|
|
2b6218e564 | ||
|
|
24edf32da8 | ||
|
|
51b08f700c | ||
|
|
58eac7f595 | ||
|
|
32e8b01cf0 | ||
|
|
db9d5cb45d | ||
|
|
473cb7b951 | ||
|
|
e5cced8813 | ||
|
|
73848d3867 | ||
|
|
6a62167f79 | ||
|
|
6ff437671e | ||
|
|
30f9e9c782 | ||
|
|
e4caa7a856 | ||
|
|
aaa66b3172 | ||
|
|
0ae59b900c | ||
|
|
5a5dae1987 | ||
|
|
ac7ba95d65 | ||
|
|
15912892f2 | ||
|
|
e3c20b03bd | ||
|
|
4db50d4158 | ||
|
|
4154493640 | ||
|
|
105ad743fa | ||
|
|
ac3f7cb8c8 | ||
|
|
61aad5a162 | ||
|
|
98c043bf50 | ||
|
|
e27e9a5f18 | ||
|
|
2578d8c151 | ||
|
|
f610133660 | ||
|
|
a877fedc52 | ||
|
|
2bc8079519 | ||
|
|
17eb20c134 | ||
|
|
5d59ceb6f3 | ||
|
|
7f645b9726 | ||
|
|
8c109be48c | ||
|
|
e9a1d9a927 | ||
|
|
8aceddffa2 | ||
|
|
cebe0448d0 | ||
|
|
fe7ff5b148 | ||
|
|
919560e3a4 | ||
|
|
26bd4f882d | ||
|
|
fd41309ed2 | ||
|
|
48bc0f35d7 | ||
|
|
e30c2dbe23 | ||
|
|
e9204ecba9 | ||
|
|
f24bda3d7b | ||
|
|
3787e95572 | ||
|
|
7233d37bd1 | ||
|
|
93dcca5147 | ||
|
|
f7d04323f3 | ||
|
|
9a27857f10 | ||
|
|
452f4f3c0e | ||
|
|
5cc01e5e09 | ||
|
|
ac0be9fb84 | ||
|
|
5417de4219 | ||
|
|
257c6705e1 | ||
|
|
27e7438b75 | ||
|
|
8a3ff8db12 | ||
|
|
26f8b67d4f | ||
|
|
b64d636280 | ||
|
|
781c57b438 | ||
|
|
c81c24d45d | ||
|
|
c53bdde747 | ||
|
|
99db18069d | ||
|
|
422998d7f0 | ||
|
|
a0a5b831d4 | ||
|
|
68628bf952 | ||
|
|
8f74dd224c | ||
|
|
b931d28f35 | ||
|
|
4407597794 | ||
|
|
9f65bd3b39 | ||
|
|
2b3830cf83 | ||
|
|
90bf101040 | ||
|
|
2b9140940d | ||
|
|
4efdea0981 | ||
|
|
05791d4200 | ||
|
|
add35d2904 | ||
|
|
660901e1fd | ||
|
|
e5efad89e0 | ||
|
|
8e64c5acaf | ||
|
|
bc2a7efcb3 | ||
|
|
1dfd880e17 | ||
|
|
e09bb5f5c0 | ||
|
|
24d11179d8 | ||
|
|
4f970c9987 | ||
|
|
2ef8b6f350 | ||
|
|
5779f7ab1d | ||
|
|
251031cfc5 | ||
|
|
10a0c843c1 | ||
|
|
77c257d9d0 | ||
|
|
955547d523 | ||
|
|
3bc862df89 | ||
|
|
4311af96eb | ||
|
|
b49c11e9a2 | ||
|
|
9cdd85c62a | ||
|
|
87d8d82be7 | ||
|
|
43e0815def | ||
|
|
0c14f4ce08 | ||
|
|
fefc138485 | ||
|
|
18e9b2340b | ||
|
|
ad427da340 | ||
|
|
484e0fd943 | ||
|
|
b8a16d362a | ||
|
|
17129024f4 | ||
|
|
34d8dbf9b2 | ||
|
|
b3b2bc6ad5 | ||
|
|
d2bc46cbb4 | ||
|
|
84eb5c562f | ||
|
|
7b01b26ff5 | ||
|
|
0f3e97ea1c | ||
|
|
6ca54beba2 | ||
|
|
8673426d5c | ||
|
|
177fc42f04 | ||
|
|
b272ac0119 | ||
|
|
574d89da14 | ||
|
|
2560c2d1a2 | ||
|
|
bd6e16d41b | ||
|
|
16939c0bc8 | ||
|
|
6fc09a82fb | ||
|
|
d622f8d1bf | ||
|
|
28d178b5c1 | ||
|
|
4c69d536ac | ||
|
|
403fd06117 | ||
|
|
d9928eab66 |
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
|
||||||
|
# SDK TypeScript package changes require review from Mingholy
|
||||||
|
packages/sdk-typescript/** @Mingholy
|
||||||
120
.github/workflows/release-sdk.yml
vendored
120
.github/workflows/release-sdk.yml
vendored
@@ -33,6 +33,10 @@ on:
|
|||||||
type: 'boolean'
|
type: 'boolean'
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: '${{ github.workflow }}'
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-sdk:
|
release-sdk:
|
||||||
runs-on: 'ubuntu-latest'
|
runs-on: 'ubuntu-latest'
|
||||||
@@ -46,6 +50,7 @@ jobs:
|
|||||||
packages: 'write'
|
packages: 'write'
|
||||||
id-token: 'write'
|
id-token: 'write'
|
||||||
issues: 'write'
|
issues: 'write'
|
||||||
|
pull-requests: 'write'
|
||||||
outputs:
|
outputs:
|
||||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||||
|
|
||||||
@@ -86,6 +91,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
scope: '@qwen-code'
|
||||||
|
|
||||||
- name: 'Install Dependencies'
|
- name: 'Install Dependencies'
|
||||||
run: |-
|
run: |-
|
||||||
@@ -121,6 +128,14 @@ jobs:
|
|||||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||||
MANUAL_VERSION: '${{ inputs.version }}'
|
MANUAL_VERSION: '${{ inputs.version }}'
|
||||||
|
|
||||||
|
- name: 'Set SDK package version (local only)'
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||||
|
run: |-
|
||||||
|
# Ensure the package version matches the computed release version.
|
||||||
|
# This is required for nightly/preview because npm does not allow re-publishing the same version.
|
||||||
|
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: 'Build CLI Bundle'
|
- name: 'Build CLI Bundle'
|
||||||
run: |
|
run: |
|
||||||
npm run build
|
npm run build
|
||||||
@@ -153,7 +168,21 @@ jobs:
|
|||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: 'Build SDK'
|
||||||
|
working-directory: 'packages/sdk-typescript'
|
||||||
|
run: |-
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: 'Publish @qwen-code/sdk'
|
||||||
|
working-directory: 'packages/sdk-typescript'
|
||||||
|
run: |-
|
||||||
|
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
|
||||||
|
|
||||||
- name: 'Create and switch to a release branch'
|
- name: 'Create and switch to a release branch'
|
||||||
|
if: |-
|
||||||
|
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||||
id: 'release_branch'
|
id: 'release_branch'
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||||
@@ -162,50 +191,22 @@ jobs:
|
|||||||
git switch -c "${BRANCH_NAME}"
|
git switch -c "${BRANCH_NAME}"
|
||||||
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
|
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: 'Update package version'
|
- name: 'Commit and Push package version (stable only)'
|
||||||
working-directory: 'packages/sdk-typescript'
|
if: |-
|
||||||
env:
|
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||||
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
|
||||||
run: |-
|
|
||||||
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
|
|
||||||
|
|
||||||
- name: 'Commit and Conditionally Push package version'
|
|
||||||
env:
|
env:
|
||||||
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||||
IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}'
|
|
||||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||||
run: |-
|
run: |-
|
||||||
git add packages/sdk-typescript/package.json
|
# Only persist version bumps after a successful publish.
|
||||||
|
git add packages/sdk-typescript/package.json package-lock.json
|
||||||
if git diff --staged --quiet; then
|
if git diff --staged --quiet; then
|
||||||
echo "No version changes to commit"
|
echo "No version changes to commit"
|
||||||
else
|
else
|
||||||
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
|
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
|
||||||
fi
|
fi
|
||||||
if [[ "${IS_DRY_RUN}" == "false" ]]; then
|
echo "Pushing release branch to remote..."
|
||||||
echo "Pushing release branch to remote..."
|
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
|
||||||
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
|
|
||||||
else
|
|
||||||
echo "Dry run enabled. Skipping push."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 'Build SDK'
|
|
||||||
working-directory: 'packages/sdk-typescript'
|
|
||||||
run: |-
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: 'Configure npm for publishing'
|
|
||||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: '.nvmrc'
|
|
||||||
registry-url: 'https://registry.npmjs.org'
|
|
||||||
scope: '@qwen-code'
|
|
||||||
|
|
||||||
- name: 'Publish @qwen-code/sdk'
|
|
||||||
working-directory: 'packages/sdk-typescript'
|
|
||||||
run: |-
|
|
||||||
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
|
|
||||||
|
|
||||||
- name: 'Create GitHub Release and Tag'
|
- name: 'Create GitHub Release and Tag'
|
||||||
if: |-
|
if: |-
|
||||||
@@ -215,12 +216,57 @@ jobs:
|
|||||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||||
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
|
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
|
||||||
|
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
|
||||||
|
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||||
|
REF: '${{ github.event.inputs.ref || github.sha }}'
|
||||||
run: |-
|
run: |-
|
||||||
|
# For stable releases, use the release branch; for nightly/preview, use the current ref
|
||||||
|
if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then
|
||||||
|
TARGET="${REF}"
|
||||||
|
PRERELEASE_FLAG="--prerelease"
|
||||||
|
else
|
||||||
|
TARGET="${RELEASE_BRANCH}"
|
||||||
|
PRERELEASE_FLAG=""
|
||||||
|
fi
|
||||||
|
|
||||||
gh release create "sdk-typescript-${RELEASE_TAG}" \
|
gh release create "sdk-typescript-${RELEASE_TAG}" \
|
||||||
--target "$RELEASE_BRANCH" \
|
--target "${TARGET}" \
|
||||||
--title "SDK TypeScript Release ${RELEASE_TAG}" \
|
--title "SDK TypeScript Release ${RELEASE_TAG}" \
|
||||||
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
|
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
|
||||||
--generate-notes
|
--generate-notes \
|
||||||
|
${PRERELEASE_FLAG}
|
||||||
|
|
||||||
|
- name: 'Create PR to merge release branch into main'
|
||||||
|
if: |-
|
||||||
|
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||||
|
id: 'pr'
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||||
|
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||||
|
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||||
|
run: |-
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')"
|
||||||
|
if [[ -z "${pr_url}" ]]; then
|
||||||
|
pr_url="$(gh pr create \
|
||||||
|
--base main \
|
||||||
|
--head "${RELEASE_BRANCH}" \
|
||||||
|
--title "chore(release): sdk-typescript ${RELEASE_TAG}" \
|
||||||
|
--body "Automated release PR for sdk-typescript ${RELEASE_TAG}.")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: 'Enable auto-merge for release PR'
|
||||||
|
if: |-
|
||||||
|
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||||
|
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||||
|
run: |-
|
||||||
|
set -euo pipefail
|
||||||
|
gh pr merge "${PR_URL}" --merge --auto --delete-branch
|
||||||
|
|
||||||
- name: 'Create Issue on Failure'
|
- name: 'Create Issue on Failure'
|
||||||
if: |-
|
if: |-
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ package-lock.json
|
|||||||
.idea
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
.cursor
|
.cursor
|
||||||
|
.qoder
|
||||||
|
|
||||||
# OS metadata
|
# OS metadata
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/se
|
|||||||
|
|
||||||
Looking for a graphical interface?
|
Looking for a graphical interface?
|
||||||
|
|
||||||
|
- [**AionUi**](https://github.com/iOfficeAI/AionUi) A modern GUI for command-line AI tools including Qwen Code
|
||||||
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
|
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default {
|
|||||||
type: 'separator',
|
type: 'separator',
|
||||||
},
|
},
|
||||||
'sdk-typescript': 'Typescript SDK',
|
'sdk-typescript': 'Typescript SDK',
|
||||||
|
'sdk-java': 'Java SDK(alpha)',
|
||||||
'Dive Into Qwen Code': {
|
'Dive Into Qwen Code': {
|
||||||
title: 'Dive Into Qwen Code',
|
title: 'Dive Into Qwen Code',
|
||||||
type: 'separator',
|
type: 'separator',
|
||||||
|
|||||||
312
docs/developers/sdk-java.md
Normal file
312
docs/developers/sdk-java.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# Qwen Code Java SDK
|
||||||
|
|
||||||
|
The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Java >= 1.8
|
||||||
|
- Maven >= 3.6.0 (for building from source)
|
||||||
|
- qwen-code >= 0.5.0
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- **Logging**: ch.qos.logback:logback-classic
|
||||||
|
- **Utilities**: org.apache.commons:commons-lang3
|
||||||
|
- **JSON Processing**: com.alibaba.fastjson2:fastjson2
|
||||||
|
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add the following dependency to your Maven `pom.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba</groupId>
|
||||||
|
<artifactId>qwencode-sdk</artifactId>
|
||||||
|
<version>{$version}</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if using Gradle, add to your `build.gradle`:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
implementation 'com.alibaba:qwencode-sdk:{$version}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compile the project
|
||||||
|
mvn compile
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
mvn test
|
||||||
|
|
||||||
|
# Package the JAR
|
||||||
|
mvn package
|
||||||
|
|
||||||
|
# Install to local repository
|
||||||
|
mvn install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public static void runSimpleExample() {
|
||||||
|
List<String> result = QwenCodeCli.simpleQuery("hello world");
|
||||||
|
result.forEach(logger::info);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For more advanced usage with custom transport options:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public static void runTransportOptionsExample() {
|
||||||
|
TransportOptions options = new TransportOptions()
|
||||||
|
.setModel("qwen3-coder-flash")
|
||||||
|
.setPermissionMode(PermissionMode.AUTO_EDIT)
|
||||||
|
.setCwd("./")
|
||||||
|
.setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
|
||||||
|
.setIncludePartialMessages(true)
|
||||||
|
.setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
|
||||||
|
.setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
|
||||||
|
.setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory"));
|
||||||
|
|
||||||
|
List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
|
||||||
|
result.forEach(logger::info);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For streaming content handling with custom content consumers:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public static void runStreamingExample() {
|
||||||
|
QwenCodeCli.simpleQuery("who are you, what are your capabilities?",
|
||||||
|
new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onText(Session session, TextAssistantContent textAssistantContent) {
|
||||||
|
logger.info("Text content received: {}", textAssistantContent.getText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) {
|
||||||
|
logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) {
|
||||||
|
logger.info("Tool use content received: {} with arguments: {}",
|
||||||
|
toolUseContent, toolUseContent.getInput());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) {
|
||||||
|
logger.info("Tool result content received: {}", toolResultContent.getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOtherContent(Session session, AssistantContent<?> other) {
|
||||||
|
logger.info("Other content received: {}", other);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onUsage(Session session, AssistantUsage assistantUsage) {
|
||||||
|
logger.info("Usage information received: Input tokens: {}, Output tokens: {}",
|
||||||
|
assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens());
|
||||||
|
}
|
||||||
|
}.setDefaultPermissionOperation(Operation.allow));
|
||||||
|
logger.info("Streaming example completed.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
other examples see src/test/java/com/alibaba/qwen/code/cli/example
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The SDK follows a layered architecture:
|
||||||
|
|
||||||
|
- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
|
||||||
|
- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
|
||||||
|
- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
|
||||||
|
- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
|
||||||
|
- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Permission Modes
|
||||||
|
|
||||||
|
The SDK supports different permission modes for controlling tool execution:
|
||||||
|
|
||||||
|
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
|
||||||
|
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
|
||||||
|
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
|
||||||
|
- **`yolo`**: All tools execute automatically without confirmation.
|
||||||
|
|
||||||
|
### Session Event Consumers and Assistant Content Consumers
|
||||||
|
|
||||||
|
The SDK provides two key interfaces for handling events and content from the CLI:
|
||||||
|
|
||||||
|
#### SessionEventConsumers Interface
|
||||||
|
|
||||||
|
The `SessionEventConsumers` interface provides callbacks for different types of messages during a session:
|
||||||
|
|
||||||
|
- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage)
|
||||||
|
- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage)
|
||||||
|
- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
|
||||||
|
- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
|
||||||
|
- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage)
|
||||||
|
- `onOtherMessage`: Handles other types of messages (receives Session and String message)
|
||||||
|
- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse)
|
||||||
|
- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
|
||||||
|
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest<CLIControlPermissionRequest>, returns Behavior)
|
||||||
|
|
||||||
|
#### AssistantContentConsumers Interface
|
||||||
|
|
||||||
|
The `AssistantContentConsumers` interface handles different types of content within assistant messages:
|
||||||
|
|
||||||
|
- `onText`: Handles text content (receives Session and TextAssistantContent)
|
||||||
|
- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent)
|
||||||
|
- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent)
|
||||||
|
- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent)
|
||||||
|
- `onOtherContent`: Handles other content types (receives Session and AssistantContent)
|
||||||
|
- `onUsage`: Handles usage information (receives Session and AssistantUsage)
|
||||||
|
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
|
||||||
|
- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)
|
||||||
|
|
||||||
|
#### Relationship Between the Interfaces
|
||||||
|
|
||||||
|
**Important Note on Event Hierarchy:**
|
||||||
|
|
||||||
|
- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.)
|
||||||
|
- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)
|
||||||
|
|
||||||
|
**Processor Relationship:**
|
||||||
|
|
||||||
|
- `SessionEventConsumers` → `AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)
|
||||||
|
|
||||||
|
**Event Derivation Relationships:**
|
||||||
|
|
||||||
|
- `onAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage`
|
||||||
|
- `onPartialAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`
|
||||||
|
- `onControlRequest` → `onPermissionRequest`, `onOtherControlRequest`
|
||||||
|
|
||||||
|
**Event Timeout Relationships:**
|
||||||
|
|
||||||
|
Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:
|
||||||
|
|
||||||
|
- `onSystemMessage` ↔ `onSystemMessageTimeout`
|
||||||
|
- `onResultMessage` ↔ `onResultMessageTimeout`
|
||||||
|
- `onAssistantMessage` ↔ `onAssistantMessageTimeout`
|
||||||
|
- `onPartialAssistantMessage` ↔ `onPartialAssistantMessageTimeout`
|
||||||
|
- `onUserMessage` ↔ `onUserMessageTimeout`
|
||||||
|
- `onOtherMessage` ↔ `onOtherMessageTimeout`
|
||||||
|
- `onControlResponse` ↔ `onControlResponseTimeout`
|
||||||
|
- `onControlRequest` ↔ `onControlRequestTimeout`
|
||||||
|
|
||||||
|
For AssistantContentConsumers timeout methods:
|
||||||
|
|
||||||
|
- `onText` ↔ `onTextTimeout`
|
||||||
|
- `onThinking` ↔ `onThinkingTimeout`
|
||||||
|
- `onToolUse` ↔ `onToolUseTimeout`
|
||||||
|
- `onToolResult` ↔ `onToolResultTimeout`
|
||||||
|
- `onOtherContent` ↔ `onOtherContentTimeout`
|
||||||
|
- `onPermissionRequest` ↔ `onPermissionRequestTimeout`
|
||||||
|
- `onOtherControlRequest` ↔ `onOtherControlRequestTimeout`
|
||||||
|
|
||||||
|
**Default Timeout Values:**
|
||||||
|
|
||||||
|
- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
|
||||||
|
- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)
|
||||||
|
|
||||||
|
**Timeout Hierarchy Requirements:**
|
||||||
|
|
||||||
|
For proper operation, the following timeout relationships should be maintained:
|
||||||
|
|
||||||
|
- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values
|
||||||
|
- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values
|
||||||
|
|
||||||
|
### Transport Options
|
||||||
|
|
||||||
|
The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI:
|
||||||
|
|
||||||
|
- `pathToQwenExecutable`: Path to the Qwen Code CLI executable
|
||||||
|
- `cwd`: Working directory for the CLI process
|
||||||
|
- `model`: AI model to use for the session
|
||||||
|
- `permissionMode`: Permission mode that controls tool execution
|
||||||
|
- `env`: Environment variables to pass to the CLI process
|
||||||
|
- `maxSessionTurns`: Limits the number of conversation turns in a session
|
||||||
|
- `coreTools`: List of core tools that should be available to the AI
|
||||||
|
- `excludeTools`: List of tools to exclude from being available to the AI
|
||||||
|
- `allowedTools`: List of tools that are pre-approved for use without additional confirmation
|
||||||
|
- `authType`: Authentication type to use for the session
|
||||||
|
- `includePartialMessages`: Enables receiving partial messages during streaming responses
|
||||||
|
- `skillsEnable`: Enables or disables skills functionality for the session
|
||||||
|
- `turnTimeout`: Timeout for a complete turn of conversation
|
||||||
|
- `messageTimeout`: Timeout for individual messages within a turn
|
||||||
|
- `resumeSessionId`: ID of a previous session to resume
|
||||||
|
- `otherOptions`: Additional command-line options to pass to the CLI
|
||||||
|
|
||||||
|
### Session Control Features
|
||||||
|
|
||||||
|
- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options
|
||||||
|
- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state
|
||||||
|
- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process
|
||||||
|
- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session
|
||||||
|
- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt
|
||||||
|
- **Dynamic model switching**: Use `session.setModel()` to change the model during a session
|
||||||
|
- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session
|
||||||
|
|
||||||
|
### Thread Pool Configuration
|
||||||
|
|
||||||
|
The SDK uses a thread pool for managing concurrent operations with the following default configuration:
|
||||||
|
|
||||||
|
- **Core Pool Size**: 30 threads
|
||||||
|
- **Maximum Pool Size**: 100 threads
|
||||||
|
- **Keep-Alive Time**: 60 seconds
|
||||||
|
- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue)
|
||||||
|
- **Thread Naming**: "qwen_code_cli-pool-{number}"
|
||||||
|
- **Daemon Threads**: false
|
||||||
|
- **Rejected Execution Handler**: CallerRunsPolicy
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The SDK provides specific exception types for different error scenarios:
|
||||||
|
|
||||||
|
- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.)
|
||||||
|
- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response
|
||||||
|
- `SessionClosedException`: Thrown when attempting to use a closed session
|
||||||
|
|
||||||
|
## FAQ / Troubleshooting
|
||||||
|
|
||||||
|
### Q: Do I need to install the Qwen CLI separately?
|
||||||
|
|
||||||
|
A: yes, requires Qwen CLI 0.5.5 or higher.
|
||||||
|
|
||||||
|
### Q: What Java versions are supported?
|
||||||
|
|
||||||
|
A: The SDK requires Java 1.8 or higher.
|
||||||
|
|
||||||
|
### Q: How do I handle long-running requests?
|
||||||
|
|
||||||
|
A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`.
|
||||||
|
|
||||||
|
### Q: Why are some tools not executing?
|
||||||
|
|
||||||
|
A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools.
|
||||||
|
|
||||||
|
### Q: How do I resume a previous session?
|
||||||
|
|
||||||
|
A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session.
|
||||||
|
|
||||||
|
### Q: Can I customize the environment for the CLI process?
|
||||||
|
|
||||||
|
A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0 - see [LICENSE](./LICENSE) for details.
|
||||||
@@ -43,6 +43,7 @@ Qwen Code uses JSON settings files for persistent configuration. There are four
|
|||||||
In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as:
|
In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as:
|
||||||
|
|
||||||
- [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
|
- [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
|
||||||
|
- [Agent Skills](../features/skills) (experimental) under `.qwen/skills/` (each Skill is a directory containing a `SKILL.md`).
|
||||||
|
|
||||||
### Available settings in `settings.json`
|
### Available settings in `settings.json`
|
||||||
|
|
||||||
@@ -135,6 +136,95 @@ Settings are organized into categories. All settings should be placed within the
|
|||||||
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
||||||
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
|
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
|
||||||
|
|
||||||
|
#### modelProviders
|
||||||
|
|
||||||
|
Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden.
|
||||||
|
|
||||||
|
##### Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modelProviders": {
|
||||||
|
"openai": [
|
||||||
|
{
|
||||||
|
"id": "gpt-4o",
|
||||||
|
"name": "GPT-4o",
|
||||||
|
"envKey": "OPENAI_API_KEY",
|
||||||
|
"baseUrl": "https://api.openai.com/v1",
|
||||||
|
"generationConfig": {
|
||||||
|
"timeout": 60000,
|
||||||
|
"maxRetries": 3,
|
||||||
|
"samplingParams": { "temperature": 0.2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"anthropic": [
|
||||||
|
{
|
||||||
|
"id": "claude-3-5-sonnet",
|
||||||
|
"envKey": "ANTHROPIC_API_KEY",
|
||||||
|
"baseUrl": "https://api.anthropic.com/v1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gemini": [
|
||||||
|
{
|
||||||
|
"id": "gemini-2.0-flash",
|
||||||
|
"name": "Gemini 2.0 Flash",
|
||||||
|
"envKey": "GEMINI_API_KEY",
|
||||||
|
"baseUrl": "https://generativelanguage.googleapis.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vertex-ai": [
|
||||||
|
{
|
||||||
|
"id": "gemini-1.5-pro-vertex",
|
||||||
|
"envKey": "GOOGLE_API_KEY",
|
||||||
|
"baseUrl": "https://generativelanguage.googleapis.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!note]
|
||||||
|
> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows.
|
||||||
|
|
||||||
|
##### Resolution layers and atomicity
|
||||||
|
|
||||||
|
The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers.
|
||||||
|
|
||||||
|
| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy |
|
||||||
|
| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- |
|
||||||
|
| Programmatic overrides | `/auth ` | `/auth` input | `/auth` input | `/auth` input | — | — |
|
||||||
|
| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — |
|
||||||
|
| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — |
|
||||||
|
| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — |
|
||||||
|
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
|
||||||
|
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured |
|
||||||
|
|
||||||
|
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration.
|
||||||
|
|
||||||
|
Model-provider sourced values are applied atomically: once a provider model is active, every field it defines is protected from lower layers until you manually clear credentials via `/auth`. The final `generationConfig` is the projection across all layers—lower layers only fill gaps left by higher ones, and the provider layer remains impenetrable.
|
||||||
|
|
||||||
|
The merge strategy for `modelProviders` is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two.
|
||||||
|
|
||||||
|
##### Generation config layering
|
||||||
|
|
||||||
|
Per-field precedence for `generationConfig`:
|
||||||
|
|
||||||
|
1. Programmatic overrides (e.g. runtime `/model`, `/auth` changes)
|
||||||
|
2. `modelProviders[authType][].generationConfig`
|
||||||
|
3. `settings.model.generationConfig`
|
||||||
|
4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.)
|
||||||
|
|
||||||
|
`samplingParams` is treated atomically; provider values replace the entire object. Defaults from the content generator apply last so each provider retains its tuned baseline.
|
||||||
|
|
||||||
|
##### Selection persistence and recommendations
|
||||||
|
|
||||||
|
> [!important]
|
||||||
|
> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope.
|
||||||
|
|
||||||
|
- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog.
|
||||||
|
- Without `modelProviders`, the resolver mixes CLI/env/settings layers, which is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable.
|
||||||
|
|
||||||
#### context
|
#### context
|
||||||
|
|
||||||
| Setting | Type | Description | Default |
|
| Setting | Type | Description | Default |
|
||||||
@@ -380,6 +470,8 @@ Arguments passed directly when running the CLI can override other configurations
|
|||||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. 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. |
|
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||||
|
| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
|
||||||
|
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
|
||||||
| `--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` |
|
| `--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` |
|
||||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||||
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
|
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export default {
|
export default {
|
||||||
commands: 'Commands',
|
commands: 'Commands',
|
||||||
'sub-agents': 'SubAgents',
|
'sub-agents': 'SubAgents',
|
||||||
|
skills: 'Skills (Experimental)',
|
||||||
headless: 'Headless Mode',
|
headless: 'Headless Mode',
|
||||||
checkpointing: {
|
checkpointing: {
|
||||||
display: 'hidden',
|
display: 'hidden',
|
||||||
|
|||||||
@@ -189,19 +189,20 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq
|
|||||||
|
|
||||||
Key command-line options for headless usage:
|
Key command-line options for headless usage:
|
||||||
|
|
||||||
| Option | Description | Example |
|
| Option | Description | Example |
|
||||||
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
|
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||||
|
| `--experimental-skills` | Enable experimental Skills (registers the `skill` tool) | `qwen --experimental-skills -p "What Skills are available?"` |
|
||||||
|
|
||||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings).
|
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings).
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ Cross-platform sandboxing with complete process isolation.
|
|||||||
|
|
||||||
By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed.
|
By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed.
|
||||||
|
|
||||||
|
The container sandbox mounts your workspace and your `~/.qwen` directory into the container so auth and settings persist between runs.
|
||||||
|
|
||||||
**Best for**: Strong isolation on any OS, consistent tooling inside a known image.
|
**Best for**: Strong isolation on any OS, consistent tooling inside a known image.
|
||||||
|
|
||||||
### Choosing a method
|
### Choosing a method
|
||||||
@@ -157,7 +159,7 @@ For a working allowlist-style proxy example, see: [Example Proxy Script](/develo
|
|||||||
|
|
||||||
## Linux UID/GID handling
|
## Linux UID/GID handling
|
||||||
|
|
||||||
The sandbox automatically handles user permissions on Linux. Override these permissions with:
|
On Linux, Qwen Code defaults to enabling UID/GID mapping so the sandbox runs as your user (and reuses the mounted `~/.qwen`). Override with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export SANDBOX_SET_UID_GID=true # Force host UID/GID
|
export SANDBOX_SET_UID_GID=true # Force host UID/GID
|
||||||
|
|||||||
282
docs/users/features/skills.md
Normal file
282
docs/users/features/skills.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# Agent Skills (Experimental)
|
||||||
|
|
||||||
|
> Create, manage, and share Skills to extend Qwen Code’s capabilities.
|
||||||
|
|
||||||
|
This guide shows you how to create, use, and manage Agent Skills in **Qwen Code**. Skills are modular capabilities that extend the model’s effectiveness through organized folders containing instructions (and optionally scripts/resources).
|
||||||
|
|
||||||
|
> [!note]
|
||||||
|
>
|
||||||
|
> Skills are currently **experimental** and must be enabled with `--experimental-skills`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Qwen Code (recent version)
|
||||||
|
- Run with the experimental flag enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qwen --experimental-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
- Basic familiarity with Qwen Code ([Quickstart](../quickstart.md))
|
||||||
|
|
||||||
|
## What are Agent Skills?
|
||||||
|
|
||||||
|
Agent Skills package expertise into discoverable capabilities. Each Skill consists of a `SKILL.md` file with instructions that the model can load when relevant, plus optional supporting files like scripts and templates.
|
||||||
|
|
||||||
|
### How Skills are invoked
|
||||||
|
|
||||||
|
Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skill’s description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`).
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
- Extend Qwen Code for your workflows
|
||||||
|
- Share expertise across your team via git
|
||||||
|
- Reduce repetitive prompting
|
||||||
|
- Compose multiple Skills for complex tasks
|
||||||
|
|
||||||
|
## Create a Skill
|
||||||
|
|
||||||
|
Skills are stored as directories containing a `SKILL.md` file.
|
||||||
|
|
||||||
|
### Personal Skills
|
||||||
|
|
||||||
|
Personal Skills are available across all your projects. Store them in `~/.qwen/skills/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.qwen/skills/my-skill-name
|
||||||
|
```
|
||||||
|
|
||||||
|
Use personal Skills for:
|
||||||
|
|
||||||
|
- Your individual workflows and preferences
|
||||||
|
- Experimental Skills you’re developing
|
||||||
|
- Personal productivity helpers
|
||||||
|
|
||||||
|
### Project Skills
|
||||||
|
|
||||||
|
Project Skills are shared with your team. Store them in `.qwen/skills/` within your project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .qwen/skills/my-skill-name
|
||||||
|
```
|
||||||
|
|
||||||
|
Use project Skills for:
|
||||||
|
|
||||||
|
- Team workflows and conventions
|
||||||
|
- Project-specific expertise
|
||||||
|
- Shared utilities and scripts
|
||||||
|
|
||||||
|
Project Skills can be checked into git and automatically become available to teammates.
|
||||||
|
|
||||||
|
## Write `SKILL.md`
|
||||||
|
|
||||||
|
Create a `SKILL.md` file with YAML frontmatter and Markdown content:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: your-skill-name
|
||||||
|
description: Brief description of what this Skill does and when to use it
|
||||||
|
---
|
||||||
|
|
||||||
|
# Your Skill Name
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
Provide clear, step-by-step guidance for Qwen Code.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
Show concrete examples of using this Skill.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field requirements
|
||||||
|
|
||||||
|
Qwen Code currently validates that:
|
||||||
|
|
||||||
|
- `name` is a non-empty string
|
||||||
|
- `description` is a non-empty string
|
||||||
|
|
||||||
|
Recommended conventions (not strictly enforced yet):
|
||||||
|
|
||||||
|
- Use lowercase letters, numbers, and hyphens in `name`
|
||||||
|
- Make `description` specific: include both **what** the Skill does and **when** to use it (key words users will naturally mention)
|
||||||
|
|
||||||
|
## Add supporting files
|
||||||
|
|
||||||
|
Create additional files alongside `SKILL.md`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-skill/
|
||||||
|
├── SKILL.md (required)
|
||||||
|
├── reference.md (optional documentation)
|
||||||
|
├── examples.md (optional examples)
|
||||||
|
├── scripts/
|
||||||
|
│ └── helper.py (optional utility)
|
||||||
|
└── templates/
|
||||||
|
└── template.txt (optional template)
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference these files from `SKILL.md`:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
For advanced usage, see [reference.md](reference.md).
|
||||||
|
|
||||||
|
Run the helper script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/helper.py input.txt
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
## View available Skills
|
||||||
|
|
||||||
|
When `--experimental-skills` is enabled, Qwen Code discovers Skills from:
|
||||||
|
|
||||||
|
- Personal Skills: `~/.qwen/skills/`
|
||||||
|
- Project Skills: `.qwen/skills/`
|
||||||
|
|
||||||
|
To view available Skills, ask Qwen Code directly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
What Skills are available?
|
||||||
|
```
|
||||||
|
|
||||||
|
Or inspect the filesystem:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List personal Skills
|
||||||
|
ls ~/.qwen/skills/
|
||||||
|
|
||||||
|
# List project Skills (if in a project directory)
|
||||||
|
ls .qwen/skills/
|
||||||
|
|
||||||
|
# View a specific Skill’s content
|
||||||
|
cat ~/.qwen/skills/my-skill/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test a Skill
|
||||||
|
|
||||||
|
After creating a Skill, test it by asking questions that match your description.
|
||||||
|
|
||||||
|
Example: if your description mentions “PDF files”:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Can you help me extract text from this PDF?
|
||||||
|
```
|
||||||
|
|
||||||
|
The model autonomously decides to use your Skill if it matches the request — you don’t need to explicitly invoke it.
|
||||||
|
|
||||||
|
## Debug a Skill
|
||||||
|
|
||||||
|
If Qwen Code doesn’t use your Skill, check these common issues:
|
||||||
|
|
||||||
|
### Make the description specific
|
||||||
|
|
||||||
|
Too vague:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
description: Helps with documents
|
||||||
|
```
|
||||||
|
|
||||||
|
Specific:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDFs, forms, or document extraction.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify file path
|
||||||
|
|
||||||
|
- Personal Skills: `~/.qwen/skills/<skill-name>/SKILL.md`
|
||||||
|
- Project Skills: `.qwen/skills/<skill-name>/SKILL.md`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Personal
|
||||||
|
ls ~/.qwen/skills/my-skill/SKILL.md
|
||||||
|
|
||||||
|
# Project
|
||||||
|
ls .qwen/skills/my-skill/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check YAML syntax
|
||||||
|
|
||||||
|
Invalid YAML prevents the Skill metadata from loading correctly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat SKILL.md | head -n 15
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure:
|
||||||
|
|
||||||
|
- Opening `---` on line 1
|
||||||
|
- Closing `---` before Markdown content
|
||||||
|
- Valid YAML syntax (no tabs, correct indentation)
|
||||||
|
|
||||||
|
### View errors
|
||||||
|
|
||||||
|
Run Qwen Code with debug mode to see Skill loading errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qwen --experimental-skills --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Share Skills with your team
|
||||||
|
|
||||||
|
You can share Skills through project repositories:
|
||||||
|
|
||||||
|
1. Add the Skill under `.qwen/skills/`
|
||||||
|
2. Commit and push
|
||||||
|
3. Teammates pull the changes and run with `--experimental-skills`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .qwen/skills/
|
||||||
|
git commit -m "Add team Skill for PDF processing"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update a Skill
|
||||||
|
|
||||||
|
Edit `SKILL.md` directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Personal Skill
|
||||||
|
code ~/.qwen/skills/my-skill/SKILL.md
|
||||||
|
|
||||||
|
# Project Skill
|
||||||
|
code .qwen/skills/my-skill/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes take effect the next time you start Qwen Code. If Qwen Code is already running, restart it to load the updates.
|
||||||
|
|
||||||
|
## Remove a Skill
|
||||||
|
|
||||||
|
Delete the Skill directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Personal
|
||||||
|
rm -rf ~/.qwen/skills/my-skill
|
||||||
|
|
||||||
|
# Project
|
||||||
|
rm -rf .qwen/skills/my-skill
|
||||||
|
git commit -m "Remove unused Skill"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best practices
|
||||||
|
|
||||||
|
### Keep Skills focused
|
||||||
|
|
||||||
|
One Skill should address one capability:
|
||||||
|
|
||||||
|
- Focused: “PDF form filling”, “Excel analysis”, “Git commit messages”
|
||||||
|
- Too broad: “Document processing” (split into smaller Skills)
|
||||||
|
|
||||||
|
### Write clear descriptions
|
||||||
|
|
||||||
|
Help the model discover when to use Skills by including specific triggers:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
description: Analyze Excel spreadsheets, create pivot tables, and generate charts. Use when working with Excel files, spreadsheets, or .xlsx data.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test with your team
|
||||||
|
|
||||||
|
- Does the Skill activate when expected?
|
||||||
|
- Are the instructions clear?
|
||||||
|
- Are there missing examples or edge cases?
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- VS Code 1.98.0 or higher
|
- VS Code 1.85.0 or higher
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
### Extension not installing
|
### Extension not installing
|
||||||
|
|
||||||
- Ensure you have VS Code 1.98.0 or higher
|
- Ensure you have VS Code 1.85.0 or higher
|
||||||
- Check that VS Code has permission to install extensions
|
- Check that VS Code has permission to install extensions
|
||||||
- Try installing directly from the Marketplace website
|
- Try installing directly from the Marketplace website
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"Qwen Code": {
|
"Qwen Code": {
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"command": "qwen",
|
"command": "qwen",
|
||||||
"args": ["--experimental-acp"],
|
"args": ["--acp"],
|
||||||
"env": {}
|
"env": {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Qwen Code overview
|
# Qwen Code overview
|
||||||
|
|
||||||
|
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||||
|
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||||
|
|
||||||
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
|
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
|
||||||
|
|
||||||
## Get started in 30 seconds
|
## Get started in 30 seconds
|
||||||
@@ -46,7 +49,7 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart
|
|||||||
|
|
||||||
> [!note]
|
> [!note]
|
||||||
>
|
>
|
||||||
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. You can search for **Qwen Code** in the VS Code Marketplace and download it.
|
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. Download and install the [Qwen Code Companion](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) now.
|
||||||
|
|
||||||
## What Qwen Code does for you
|
## What Qwen Code does for you
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ This guide provides solutions to common issues and debugging tips, including top
|
|||||||
|
|
||||||
## Authentication or login errors
|
## Authentication or login errors
|
||||||
|
|
||||||
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`**
|
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, or `unable to get local issuer certificate`**
|
||||||
- **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js.
|
- **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js.
|
||||||
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
|
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
|
||||||
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
|
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
|
||||||
|
|
||||||
|
- **Error: `Device authorization flow failed: fetch failed`**
|
||||||
|
- **Cause:** Node.js could not reach Qwen OAuth endpoints (often a proxy or SSL/TLS trust issue). When available, Qwen Code will also print the underlying error cause (for example: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`).
|
||||||
|
- **Solution:**
|
||||||
|
- Confirm you can access `https://chat.qwen.ai` from the same machine/network.
|
||||||
|
- If you are behind a proxy, set it via `qwen --proxy <url>` (or the `proxy` setting in `settings.json`).
|
||||||
|
- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` as described above.
|
||||||
|
|
||||||
- **Issue: Unable to display UI after authentication failure**
|
- **Issue: Unable to display UI after authentication failure**
|
||||||
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
|
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
|
||||||
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
|
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
|
||||||
|
|||||||
@@ -80,10 +80,11 @@ type PermissionHandler = (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up an ACP test environment with all necessary utilities.
|
* Sets up an ACP test environment with all necessary utilities.
|
||||||
|
* @param useNewFlag - If true, uses --acp; if false, uses --experimental-acp (for backward compatibility testing)
|
||||||
*/
|
*/
|
||||||
function setupAcpTest(
|
function setupAcpTest(
|
||||||
rig: TestRig,
|
rig: TestRig,
|
||||||
options?: { permissionHandler?: PermissionHandler },
|
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean },
|
||||||
) {
|
) {
|
||||||
const pending = new Map<number, PendingRequest>();
|
const pending = new Map<number, PendingRequest>();
|
||||||
let nextRequestId = 1;
|
let nextRequestId = 1;
|
||||||
@@ -95,9 +96,13 @@ function setupAcpTest(
|
|||||||
const permissionHandler =
|
const permissionHandler =
|
||||||
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
|
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
|
||||||
|
|
||||||
|
// Use --acp by default, but allow testing with --experimental-acp for backward compatibility
|
||||||
|
const acpFlag =
|
||||||
|
options?.useNewFlag !== false ? '--acp' : '--experimental-acp';
|
||||||
|
|
||||||
const agent = spawn(
|
const agent = spawn(
|
||||||
'node',
|
'node',
|
||||||
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
|
[rig.bundlePath, acpFlag, '--no-chat-recording'],
|
||||||
{
|
{
|
||||||
cwd: rig.testDir!,
|
cwd: rig.testDir!,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
@@ -621,3 +626,99 @@ function setupAcpTest(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(IS_SANDBOX ? describe.skip : describe)(
|
||||||
|
'acp flag backward compatibility',
|
||||||
|
() => {
|
||||||
|
it('should work with deprecated --experimental-acp flag and show warning', async () => {
|
||||||
|
const rig = new TestRig();
|
||||||
|
rig.setup('acp backward compatibility');
|
||||||
|
|
||||||
|
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
|
||||||
|
useNewFlag: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initResult = await sendRequest('initialize', {
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientCapabilities: {
|
||||||
|
fs: { readTextFile: true, writeTextFile: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(initResult).toBeDefined();
|
||||||
|
|
||||||
|
// Verify deprecation warning is shown
|
||||||
|
const stderrOutput = stderr.join('');
|
||||||
|
expect(stderrOutput).toContain('--experimental-acp is deprecated');
|
||||||
|
expect(stderrOutput).toContain('Please use --acp instead');
|
||||||
|
|
||||||
|
await sendRequest('authenticate', { methodId: 'openai' });
|
||||||
|
|
||||||
|
const newSession = (await sendRequest('session/new', {
|
||||||
|
cwd: rig.testDir!,
|
||||||
|
mcpServers: [],
|
||||||
|
})) as { sessionId: string };
|
||||||
|
expect(newSession.sessionId).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify functionality still works
|
||||||
|
const promptResult = await sendRequest('session/prompt', {
|
||||||
|
sessionId: newSession.sessionId,
|
||||||
|
prompt: [{ type: 'text', text: 'Say hello.' }],
|
||||||
|
});
|
||||||
|
expect(promptResult).toBeDefined();
|
||||||
|
} catch (e) {
|
||||||
|
if (stderr.length) {
|
||||||
|
console.error('Agent stderr:', stderr.join(''));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with new --acp flag without warnings', async () => {
|
||||||
|
const rig = new TestRig();
|
||||||
|
rig.setup('acp new flag');
|
||||||
|
|
||||||
|
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
|
||||||
|
useNewFlag: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initResult = await sendRequest('initialize', {
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientCapabilities: {
|
||||||
|
fs: { readTextFile: true, writeTextFile: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(initResult).toBeDefined();
|
||||||
|
|
||||||
|
// Verify no deprecation warning is shown
|
||||||
|
const stderrOutput = stderr.join('');
|
||||||
|
expect(stderrOutput).not.toContain('--experimental-acp is deprecated');
|
||||||
|
|
||||||
|
await sendRequest('authenticate', { methodId: 'openai' });
|
||||||
|
|
||||||
|
const newSession = (await sendRequest('session/new', {
|
||||||
|
cwd: rig.testDir!,
|
||||||
|
mcpServers: [],
|
||||||
|
})) as { sessionId: string };
|
||||||
|
expect(newSession.sessionId).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify functionality works
|
||||||
|
const promptResult = await sendRequest('session/prompt', {
|
||||||
|
sessionId: newSession.sessionId,
|
||||||
|
prompt: [{ type: 'text', text: 'Say hello.' }],
|
||||||
|
});
|
||||||
|
expect(promptResult).toBeDefined();
|
||||||
|
} catch (e) {
|
||||||
|
if (stderr.length) {
|
||||||
|
console.error('Agent stderr:', stderr.join(''));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import * as path from 'node:path';
|
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||||
|
|
||||||
describe('file-system', () => {
|
describe('file-system', () => {
|
||||||
@@ -202,8 +200,8 @@ describe('file-system', () => {
|
|||||||
const readAttempt = toolLogs.find(
|
const readAttempt = toolLogs.find(
|
||||||
(log) => log.toolRequest.name === 'read_file',
|
(log) => log.toolRequest.name === 'read_file',
|
||||||
);
|
);
|
||||||
const writeAttempt = toolLogs.find(
|
const editAttempt = toolLogs.find(
|
||||||
(log) => log.toolRequest.name === 'write_file',
|
(log) => log.toolRequest.name === 'edit_file',
|
||||||
);
|
);
|
||||||
const successfulReplace = toolLogs.find(
|
const successfulReplace = toolLogs.find(
|
||||||
(log) => log.toolRequest.name === 'replace' && log.toolRequest.success,
|
(log) => log.toolRequest.name === 'replace' && log.toolRequest.success,
|
||||||
@@ -226,15 +224,15 @@ describe('file-system', () => {
|
|||||||
|
|
||||||
// CRITICAL: Verify that no matter what the model did, it never successfully
|
// CRITICAL: Verify that no matter what the model did, it never successfully
|
||||||
// wrote or replaced anything.
|
// wrote or replaced anything.
|
||||||
if (writeAttempt) {
|
if (editAttempt) {
|
||||||
console.error(
|
console.error(
|
||||||
'A write_file attempt was made when no file should be written.',
|
'A edit_file attempt was made when no file should be written.',
|
||||||
);
|
);
|
||||||
printDebugInfo(rig, result);
|
printDebugInfo(rig, result);
|
||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
writeAttempt,
|
editAttempt,
|
||||||
'write_file should not have been called',
|
'edit_file should not have been called',
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
|
|
||||||
if (successfulReplace) {
|
if (successfulReplace) {
|
||||||
@@ -245,12 +243,5 @@ describe('file-system', () => {
|
|||||||
successfulReplace,
|
successfulReplace,
|
||||||
'A successful replace should not have occurred',
|
'A successful replace should not have occurred',
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
|
|
||||||
// Final verification: ensure the file was not created.
|
|
||||||
const filePath = path.join(rig.testDir!, fileName);
|
|
||||||
const fileExists = existsSync(filePath);
|
|
||||||
expect(fileExists, 'The non-existent file should not be created').toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -952,7 +952,8 @@ describe('Permission Control (E2E)', () => {
|
|||||||
TEST_TIMEOUT,
|
TEST_TIMEOUT,
|
||||||
);
|
);
|
||||||
|
|
||||||
it(
|
// FIXME: This test is flaky and sometimes fails with no tool calls.
|
||||||
|
it.skip(
|
||||||
'should allow read-only tools without restrictions',
|
'should allow read-only tools without restrictions',
|
||||||
async () => {
|
async () => {
|
||||||
// Create test files for the model to read
|
// Create test files for the model to read
|
||||||
|
|||||||
@@ -314,4 +314,88 @@ describe('System Control (E2E)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('supportedCommands API', () => {
|
||||||
|
it('should return list of supported slash commands', async () => {
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
const generator = (async function* () {
|
||||||
|
yield {
|
||||||
|
type: 'user',
|
||||||
|
session_id: sessionId,
|
||||||
|
message: { role: 'user', content: 'Hello' },
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
} as SDKUserMessage;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: generator,
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: testDir,
|
||||||
|
model: 'qwen3-max',
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await q.supportedCommands();
|
||||||
|
// Start consuming messages to trigger initialization
|
||||||
|
const messageConsumer = (async () => {
|
||||||
|
try {
|
||||||
|
for await (const _message of q) {
|
||||||
|
// Just consume messages
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors from query being closed
|
||||||
|
if (error instanceof Error && error.message !== 'Query is closed') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Verify result structure
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result).toHaveProperty('commands');
|
||||||
|
expect(Array.isArray(result?.['commands'])).toBe(true);
|
||||||
|
|
||||||
|
const commands = result?.['commands'] as string[];
|
||||||
|
|
||||||
|
// Verify default allowed built-in commands are present
|
||||||
|
expect(commands).toContain('init');
|
||||||
|
expect(commands).toContain('summary');
|
||||||
|
expect(commands).toContain('compress');
|
||||||
|
|
||||||
|
// Verify commands are sorted
|
||||||
|
const sortedCommands = [...commands].sort();
|
||||||
|
expect(commands).toEqual(sortedCommands);
|
||||||
|
|
||||||
|
// Verify all commands are strings
|
||||||
|
commands.forEach((cmd) => {
|
||||||
|
expect(typeof cmd).toBe('string');
|
||||||
|
expect(cmd.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await q.close();
|
||||||
|
await messageConsumer;
|
||||||
|
} catch (error) {
|
||||||
|
await q.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when supportedCommands is called on closed query', async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: testDir,
|
||||||
|
model: 'qwen3-max',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await q.close();
|
||||||
|
|
||||||
|
await expect(q.supportedCommands()).rejects.toThrow('Query is closed');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
2086
package-lock.json
generated
2086
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
@@ -13,19 +13,18 @@
|
|||||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env node scripts/start.js",
|
"start": "cross-env node scripts/start.js",
|
||||||
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
|
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
|
||||||
"auth:npm": "npx google-artifactregistry-auth",
|
|
||||||
"auth:docker": "gcloud auth configure-docker us-west1-docker.pkg.dev",
|
|
||||||
"auth": "npm run auth:npm && npm run auth:docker",
|
|
||||||
"generate": "node scripts/generate-git-commit-info.js",
|
"generate": "node scripts/generate-git-commit-info.js",
|
||||||
"build": "node scripts/build.js",
|
"build": "node scripts/build.js",
|
||||||
"build-and-start": "npm run build && npm run start",
|
"build-and-start": "npm run build && npm run start",
|
||||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||||
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
|
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
|
||||||
|
"build:native": "node scripts/build_native.js",
|
||||||
|
"build:native:all": "node scripts/build_native.js --all",
|
||||||
"build:packages": "npm run build --workspaces",
|
"build:packages": "npm run build --workspaces",
|
||||||
"build:sandbox": "node scripts/build_sandbox.js",
|
"build:sandbox": "node scripts/build_sandbox.js",
|
||||||
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
||||||
@@ -95,7 +94,6 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"glob": "^10.5.0",
|
"glob": "^10.5.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"google-artifactregistry-auth": "^3.4.0",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"json": "^11.0.0",
|
"json": "^11.0.0",
|
||||||
"lint-staged": "^16.1.6",
|
"lint-staged": "^16.1.6",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"description": "Qwen Code",
|
"description": "Qwen Code",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -33,13 +33,13 @@
|
|||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.16.0",
|
"@google/genai": "1.30.0",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
"@qwen-code/qwen-code-core": "file:../core",
|
"@qwen-code/qwen-code-core": "file:../core",
|
||||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||||
"@types/update-notifier": "^6.0.8",
|
"@types/update-notifier": "^6.0.8",
|
||||||
"ansi-regex": "^6.2.2",
|
"ansi-regex": "^6.2.2",
|
||||||
"command-exists": "^1.2.9",
|
"command-exists": "^1.2.9",
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ export class AgentSideConnection implements Client {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a custom notification to the client.
|
||||||
|
* Used for extension-specific notifications that are not part of the core ACP protocol.
|
||||||
|
*/
|
||||||
|
async sendCustomNotification<T>(method: string, params: T): Promise<void> {
|
||||||
|
return await this.#connection.sendNotification(method, params);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request permission before running a tool
|
* Request permission before running a tool
|
||||||
*
|
*
|
||||||
@@ -374,6 +382,7 @@ export interface Client {
|
|||||||
): Promise<schema.RequestPermissionResponse>;
|
): Promise<schema.RequestPermissionResponse>;
|
||||||
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
||||||
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
||||||
|
sendCustomNotification<T>(method: string, params: T): Promise<void>;
|
||||||
writeTextFile(
|
writeTextFile(
|
||||||
params: schema.WriteTextFileRequest,
|
params: schema.WriteTextFileRequest,
|
||||||
): Promise<schema.WriteTextFileResponse>;
|
): Promise<schema.WriteTextFileResponse>;
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import {
|
|||||||
qwenOAuth2Events,
|
qwenOAuth2Events,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
SessionService,
|
SessionService,
|
||||||
buildApiHistoryFromConversation,
|
|
||||||
type Config,
|
type Config,
|
||||||
type ConversationRecord,
|
type ConversationRecord,
|
||||||
type DeviceAuthorizationData,
|
type DeviceAuthorizationData,
|
||||||
|
tokenLimit,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { ApprovalModeValue } from './schema.js';
|
import type { ApprovalModeValue } from './schema.js';
|
||||||
import * as acp from './acp.js';
|
import * as acp from './acp.js';
|
||||||
@@ -165,9 +165,30 @@ class GeminiAgent {
|
|||||||
this.setupFileSystem(config);
|
this.setupFileSystem(config);
|
||||||
|
|
||||||
const session = await this.createAndStoreSession(config);
|
const session = await this.createAndStoreSession(config);
|
||||||
|
const configuredModel = (
|
||||||
|
config.getModel() ||
|
||||||
|
this.config.getModel() ||
|
||||||
|
''
|
||||||
|
).trim();
|
||||||
|
const modelId = configuredModel || 'default';
|
||||||
|
const modelName = configuredModel || modelId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId: session.getId(),
|
sessionId: session.getId(),
|
||||||
|
models: {
|
||||||
|
currentModelId: modelId,
|
||||||
|
availableModels: [
|
||||||
|
{
|
||||||
|
modelId,
|
||||||
|
name: modelName,
|
||||||
|
description: null,
|
||||||
|
_meta: {
|
||||||
|
contextLimit: tokenLimit(modelId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
_meta: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,12 +348,20 @@ class GeminiAgent {
|
|||||||
const sessionId = config.getSessionId();
|
const sessionId = config.getSessionId();
|
||||||
const geminiClient = config.getGeminiClient();
|
const geminiClient = config.getGeminiClient();
|
||||||
|
|
||||||
const history = conversation
|
// Use GeminiClient to manage chat lifecycle properly
|
||||||
? buildApiHistoryFromConversation(conversation)
|
// This ensures geminiClient.chat is in sync with the session's chat
|
||||||
: undefined;
|
//
|
||||||
const chat = history
|
// Note: When loading a session, config.initialize() has already been called
|
||||||
? await geminiClient.startChat(history)
|
// in newSessionConfig(), which in turn calls geminiClient.initialize().
|
||||||
: await geminiClient.startChat();
|
// The GeminiClient.initialize() method checks config.getResumedSessionData()
|
||||||
|
// and automatically loads the conversation history into the chat instance.
|
||||||
|
// So we only need to initialize if it hasn't been done yet.
|
||||||
|
if (!geminiClient.isInitialized()) {
|
||||||
|
await geminiClient.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now get the chat instance that's managed by GeminiClient
|
||||||
|
const chat = geminiClient.getChat();
|
||||||
|
|
||||||
const session = new Session(
|
const session = new Session(
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export type ModeInfo = z.infer<typeof modeInfoSchema>;
|
|||||||
export type ModesData = z.infer<typeof modesDataSchema>;
|
export type ModesData = z.infer<typeof modesDataSchema>;
|
||||||
|
|
||||||
export type AgentInfo = z.infer<typeof agentInfoSchema>;
|
export type AgentInfo = z.infer<typeof agentInfoSchema>;
|
||||||
|
export type ModelInfo = z.infer<typeof modelInfoSchema>;
|
||||||
|
|
||||||
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
|
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
|
||||||
|
|
||||||
@@ -254,8 +255,26 @@ export const authenticateUpdateSchema = z.object({
|
|||||||
|
|
||||||
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
|
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
|
||||||
|
|
||||||
|
export const acpMetaSchema = z.record(z.unknown()).nullable().optional();
|
||||||
|
|
||||||
|
export const modelIdSchema = z.string();
|
||||||
|
|
||||||
|
export const modelInfoSchema = z.object({
|
||||||
|
_meta: acpMetaSchema,
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
modelId: modelIdSchema,
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessionModelStateSchema = z.object({
|
||||||
|
_meta: acpMetaSchema,
|
||||||
|
availableModels: z.array(modelInfoSchema),
|
||||||
|
currentModelId: modelIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export const newSessionResponseSchema = z.object({
|
export const newSessionResponseSchema = z.object({
|
||||||
sessionId: z.string(),
|
sessionId: z.string(),
|
||||||
|
models: sessionModelStateSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loadSessionResponseSchema = z.null();
|
export const loadSessionResponseSchema = z.null();
|
||||||
@@ -514,6 +533,13 @@ export const currentModeUpdateSchema = z.object({
|
|||||||
|
|
||||||
export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>;
|
export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>;
|
||||||
|
|
||||||
|
export const currentModelUpdateSchema = z.object({
|
||||||
|
sessionUpdate: z.literal('current_model_update'),
|
||||||
|
model: modelInfoSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CurrentModelUpdate = z.infer<typeof currentModelUpdateSchema>;
|
||||||
|
|
||||||
export const sessionUpdateSchema = z.union([
|
export const sessionUpdateSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
content: contentBlockSchema,
|
content: contentBlockSchema,
|
||||||
@@ -555,6 +581,7 @@ export const sessionUpdateSchema = z.union([
|
|||||||
sessionUpdate: z.literal('plan'),
|
sessionUpdate: z.literal('plan'),
|
||||||
}),
|
}),
|
||||||
currentModeUpdateSchema,
|
currentModeUpdateSchema,
|
||||||
|
currentModelUpdateSchema,
|
||||||
availableCommandsUpdateSchema,
|
availableCommandsUpdateSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ import * as fs from 'node:fs/promises';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
|
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
|
||||||
import {
|
import {
|
||||||
handleSlashCommand,
|
handleSlashCommand,
|
||||||
getAvailableCommands,
|
getAvailableCommands,
|
||||||
|
type NonInteractiveSlashCommandResult,
|
||||||
} from '../../nonInteractiveCliCommands.js';
|
} from '../../nonInteractiveCliCommands.js';
|
||||||
import type {
|
import type {
|
||||||
AvailableCommand,
|
AvailableCommand,
|
||||||
@@ -63,12 +65,6 @@ import { PlanEmitter } from './emitters/PlanEmitter.js';
|
|||||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Built-in commands that are allowed in ACP integration mode.
|
|
||||||
* Only safe, read-only commands that don't require interactive UI.
|
|
||||||
*/
|
|
||||||
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session represents an active conversation session with the AI model.
|
* Session represents an active conversation session with the AI model.
|
||||||
* It uses modular components for consistent event emission:
|
* It uses modular components for consistent event emission:
|
||||||
@@ -167,24 +163,26 @@ export class Session implements SessionContext {
|
|||||||
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
||||||
const inputText = firstTextBlock?.text || '';
|
const inputText = firstTextBlock?.text || '';
|
||||||
|
|
||||||
let parts: Part[];
|
let parts: Part[] | null;
|
||||||
|
|
||||||
if (isSlashCommand(inputText)) {
|
if (isSlashCommand(inputText)) {
|
||||||
// Handle slash command - allow specific built-in commands for ACP integration
|
// Handle slash command - uses default allowed commands (init, summary, compress)
|
||||||
const slashCommandResult = await handleSlashCommand(
|
const slashCommandResult = await handleSlashCommand(
|
||||||
inputText,
|
inputText,
|
||||||
pendingSend,
|
pendingSend,
|
||||||
this.config,
|
this.config,
|
||||||
this.settings,
|
this.settings,
|
||||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (slashCommandResult) {
|
parts = await this.#processSlashCommandResult(
|
||||||
// Use the result from the slash command
|
slashCommandResult,
|
||||||
parts = slashCommandResult as Part[];
|
params.prompt,
|
||||||
} else {
|
);
|
||||||
// Slash command didn't return a prompt, continue with normal processing
|
|
||||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
// If parts is null, the command was fully handled (e.g., /summary completed)
|
||||||
|
// Return early without sending to the model
|
||||||
|
if (parts === null) {
|
||||||
|
return { stopReason: 'end_turn' };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal processing for non-slash commands
|
// Normal processing for non-slash commands
|
||||||
@@ -295,11 +293,10 @@ export class Session implements SessionContext {
|
|||||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
try {
|
try {
|
||||||
|
// Use default allowed commands from getAvailableCommands
|
||||||
const slashCommands = await getAvailableCommands(
|
const slashCommands = await getAvailableCommands(
|
||||||
this.config,
|
this.config,
|
||||||
this.settings,
|
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
||||||
@@ -647,6 +644,103 @@ export class Session implements SessionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the result of a slash command execution.
|
||||||
|
*
|
||||||
|
* Supported result types in ACP mode:
|
||||||
|
* - submit_prompt: Submits content to the model
|
||||||
|
* - stream_messages: Streams multiple messages to the client (ACP-specific)
|
||||||
|
* - unsupported: Command cannot be executed in ACP mode
|
||||||
|
* - no_command: No command was found, use original prompt
|
||||||
|
*
|
||||||
|
* Note: 'message' type is not supported in ACP mode - commands should use
|
||||||
|
* 'stream_messages' instead for consistent async handling.
|
||||||
|
*
|
||||||
|
* @param result The result from handleSlashCommand
|
||||||
|
* @param originalPrompt The original prompt blocks
|
||||||
|
* @returns Parts to use for the prompt, or null if command was handled without needing model interaction
|
||||||
|
*/
|
||||||
|
async #processSlashCommandResult(
|
||||||
|
result: NonInteractiveSlashCommandResult,
|
||||||
|
originalPrompt: acp.ContentBlock[],
|
||||||
|
): Promise<Part[] | null> {
|
||||||
|
switch (result.type) {
|
||||||
|
case 'submit_prompt':
|
||||||
|
// Command wants to submit a prompt to the model
|
||||||
|
// Convert PartListUnion to Part[]
|
||||||
|
return normalizePartList(result.content);
|
||||||
|
|
||||||
|
case 'message': {
|
||||||
|
// 'message' type is not ideal for ACP mode, but we handle it for compatibility
|
||||||
|
// by converting it to a stream_messages-like notification
|
||||||
|
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
command: originalPrompt
|
||||||
|
.filter((block) => block.type === 'text')
|
||||||
|
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||||
|
.join(' '),
|
||||||
|
messageType: result.messageType,
|
||||||
|
message: result.content || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.messageType === 'error') {
|
||||||
|
// Throw error to stop execution
|
||||||
|
throw new Error(result.content || 'Slash command failed.');
|
||||||
|
}
|
||||||
|
// For info messages, return null to indicate command was handled
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'stream_messages': {
|
||||||
|
// Command returns multiple messages via async generator (ACP-preferred)
|
||||||
|
const command = originalPrompt
|
||||||
|
.filter((block) => block.type === 'text')
|
||||||
|
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
// Stream all messages to the client
|
||||||
|
for await (const msg of result.messages) {
|
||||||
|
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
command,
|
||||||
|
messageType: msg.messageType,
|
||||||
|
message: msg.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we encounter an error message, throw after sending
|
||||||
|
if (msg.messageType === 'error') {
|
||||||
|
throw new Error(msg.content || 'Slash command failed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All messages sent successfully, return null to indicate command was handled
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'unsupported': {
|
||||||
|
// Command returned an unsupported result type
|
||||||
|
const unsupportedError = `Slash command not supported in ACP integration: ${result.reason}`;
|
||||||
|
throw new Error(unsupportedError);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'no_command':
|
||||||
|
// No command was found or executed, use original prompt
|
||||||
|
return originalPrompt.map((block) => {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
return { text: block.text };
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported block type: ${block.type}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness check
|
||||||
|
const _exhaustive: never = result;
|
||||||
|
const unknownError = `Unknown slash command result type: ${(_exhaustive as NonInteractiveSlashCommandResult).type}`;
|
||||||
|
throw new Error(unknownError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #resolvePrompt(
|
async #resolvePrompt(
|
||||||
message: acp.ContentBlock[],
|
message: acp.ContentBlock[],
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
|
|||||||
@@ -1,41 +1,112 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Google LLC
|
* Copyright 2025 Qwen Team
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { validateAuthMethod } from './auth.js';
|
import { validateAuthMethod } from './auth.js';
|
||||||
|
import * as settings from './settings.js';
|
||||||
|
|
||||||
vi.mock('./settings.js', () => ({
|
vi.mock('./settings.js', () => ({
|
||||||
loadEnvironment: vi.fn(),
|
loadEnvironment: vi.fn(),
|
||||||
loadSettings: vi.fn().mockReturnValue({
|
loadSettings: vi.fn().mockReturnValue({
|
||||||
merged: vi.fn().mockReturnValue({}),
|
merged: {},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('validateAuthMethod', () => {
|
describe('validateAuthMethod', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
// Reset mock to default
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {},
|
||||||
|
} as ReturnType<typeof settings.loadSettings>);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
|
delete process.env['OPENAI_API_KEY'];
|
||||||
|
delete process.env['CUSTOM_API_KEY'];
|
||||||
|
delete process.env['GEMINI_API_KEY'];
|
||||||
|
delete process.env['GEMINI_API_KEY_ALTERED'];
|
||||||
|
delete process.env['ANTHROPIC_API_KEY'];
|
||||||
|
delete process.env['ANTHROPIC_BASE_URL'];
|
||||||
|
delete process.env['GOOGLE_API_KEY'];
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null for USE_OPENAI', () => {
|
it('should return null for USE_OPENAI with default env key', () => {
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
|
it('should return an error message for USE_OPENAI if no API key is available', () => {
|
||||||
delete process.env['OPENAI_API_KEY'];
|
|
||||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
|
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
|
||||||
'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
|
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return null for USE_OPENAI with custom envKey from modelProviders', () => {
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {
|
||||||
|
model: { name: 'custom-model' },
|
||||||
|
modelProviders: {
|
||||||
|
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||||
|
process.env['CUSTOM_API_KEY'] = 'custom-key';
|
||||||
|
|
||||||
|
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error with custom envKey hint when modelProviders envKey is set but env var is missing', () => {
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {
|
||||||
|
model: { name: 'custom-model' },
|
||||||
|
modelProviders: {
|
||||||
|
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||||
|
|
||||||
|
const result = validateAuthMethod(AuthType.USE_OPENAI);
|
||||||
|
expect(result).toContain('CUSTOM_API_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for USE_GEMINI with custom envKey', () => {
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {
|
||||||
|
model: { name: 'gemini-1.5-flash' },
|
||||||
|
modelProviders: {
|
||||||
|
gemini: [
|
||||||
|
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||||
|
process.env['GEMINI_API_KEY_ALTERED'] = 'altered-key';
|
||||||
|
|
||||||
|
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error with custom envKey for USE_GEMINI when env var is missing', () => {
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {
|
||||||
|
model: { name: 'gemini-1.5-flash' },
|
||||||
|
modelProviders: {
|
||||||
|
gemini: [
|
||||||
|
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||||
|
|
||||||
|
const result = validateAuthMethod(AuthType.USE_GEMINI);
|
||||||
|
expect(result).toContain('GEMINI_API_KEY_ALTERED');
|
||||||
|
});
|
||||||
|
|
||||||
it('should return null for QWEN_OAUTH', () => {
|
it('should return null for QWEN_OAUTH', () => {
|
||||||
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
|
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -45,4 +116,115 @@ describe('validateAuthMethod', () => {
|
|||||||
'Invalid auth method selected.',
|
'Invalid auth method selected.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return null for USE_ANTHROPIC with custom envKey and baseUrl', () => {
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {
|
||||||
|
model: { name: 'claude-3' },
|
||||||
|
modelProviders: {
|
||||||
|
anthropic: [
|
||||||
|
{
|
||||||
|
id: 'claude-3',
|
||||||
|
envKey: 'CUSTOM_ANTHROPIC_KEY',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||||
|
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-anthropic-key';
|
||||||
|
|
||||||
|
expect(validateAuthMethod(AuthType.USE_ANTHROPIC)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for USE_ANTHROPIC when baseUrl is missing', () => {
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {
|
||||||
|
model: { name: 'claude-3' },
|
||||||
|
modelProviders: {
|
||||||
|
anthropic: [{ id: 'claude-3', envKey: 'CUSTOM_ANTHROPIC_KEY' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||||
|
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key';
|
||||||
|
|
||||||
|
const result = validateAuthMethod(AuthType.USE_ANTHROPIC);
|
||||||
|
expect(result).toContain('modelProviders[].baseUrl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for USE_VERTEX_AI with custom envKey', () => {
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {
|
||||||
|
model: { name: 'vertex-model' },
|
||||||
|
modelProviders: {
|
||||||
|
'vertex-ai': [
|
||||||
|
{ id: 'vertex-model', envKey: 'GOOGLE_API_KEY_VERTEX' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||||
|
process.env['GOOGLE_API_KEY_VERTEX'] = 'vertex-key';
|
||||||
|
|
||||||
|
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use config.modelsConfig.getModel() when Config is provided', () => {
|
||||||
|
// Settings has a different model
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {
|
||||||
|
model: { name: 'settings-model' },
|
||||||
|
modelProviders: {
|
||||||
|
openai: [
|
||||||
|
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
|
||||||
|
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||||
|
|
||||||
|
// Mock Config object that returns a different model (e.g., from CLI args)
|
||||||
|
const mockConfig = {
|
||||||
|
modelsConfig: {
|
||||||
|
getModel: vi.fn().mockReturnValue('cli-model'),
|
||||||
|
},
|
||||||
|
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
||||||
|
|
||||||
|
// Set the env key for the CLI model, not the settings model
|
||||||
|
process.env['CLI_API_KEY'] = 'cli-key';
|
||||||
|
|
||||||
|
// Should use 'cli-model' from config.modelsConfig.getModel(), not 'settings-model'
|
||||||
|
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockConfig.modelsConfig.getModel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail validation when Config provides different model without matching env key', () => {
|
||||||
|
// Clean up any existing env keys first
|
||||||
|
delete process.env['CLI_API_KEY'];
|
||||||
|
delete process.env['SETTINGS_API_KEY'];
|
||||||
|
delete process.env['OPENAI_API_KEY'];
|
||||||
|
|
||||||
|
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||||
|
merged: {
|
||||||
|
model: { name: 'settings-model' },
|
||||||
|
modelProviders: {
|
||||||
|
openai: [
|
||||||
|
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
|
||||||
|
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
modelsConfig: {
|
||||||
|
getModel: vi.fn().mockReturnValue('cli-model'),
|
||||||
|
},
|
||||||
|
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
||||||
|
|
||||||
|
// Don't set CLI_API_KEY - validation should fail
|
||||||
|
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result).toContain('CLI_API_KEY');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,169 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Google LLC
|
* Copyright 2025 Qwen Team
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import {
|
||||||
import { loadEnvironment, loadSettings } from './settings.js';
|
AuthType,
|
||||||
|
type Config,
|
||||||
|
type ModelProvidersConfig,
|
||||||
|
type ProviderModelConfig,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { loadEnvironment, loadSettings, type Settings } from './settings.js';
|
||||||
|
import { t } from '../i18n/index.js';
|
||||||
|
|
||||||
export function validateAuthMethod(authMethod: string): string | null {
|
/**
|
||||||
|
* Default environment variable names for each auth type
|
||||||
|
*/
|
||||||
|
const DEFAULT_ENV_KEYS: Record<string, string> = {
|
||||||
|
[AuthType.USE_OPENAI]: 'OPENAI_API_KEY',
|
||||||
|
[AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY',
|
||||||
|
[AuthType.USE_GEMINI]: 'GEMINI_API_KEY',
|
||||||
|
[AuthType.USE_VERTEX_AI]: 'GOOGLE_API_KEY',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find model configuration from modelProviders by authType and modelId
|
||||||
|
*/
|
||||||
|
function findModelConfig(
|
||||||
|
modelProviders: ModelProvidersConfig | undefined,
|
||||||
|
authType: string,
|
||||||
|
modelId: string | undefined,
|
||||||
|
): ProviderModelConfig | undefined {
|
||||||
|
if (!modelProviders || !modelId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = modelProviders[authType];
|
||||||
|
if (!Array.isArray(models)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.find((m) => m.id === modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if API key is available for the given auth type and model configuration.
|
||||||
|
* Prioritizes custom envKey from modelProviders over default environment variables.
|
||||||
|
*/
|
||||||
|
function hasApiKeyForAuth(
|
||||||
|
authType: string,
|
||||||
|
settings: Settings,
|
||||||
|
config?: Config,
|
||||||
|
): {
|
||||||
|
hasKey: boolean;
|
||||||
|
checkedEnvKey: string | undefined;
|
||||||
|
isExplicitEnvKey: boolean;
|
||||||
|
} {
|
||||||
|
const modelProviders = settings.modelProviders as
|
||||||
|
| ModelProvidersConfig
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// Use config.modelsConfig.getModel() if available for accurate model ID resolution
|
||||||
|
// that accounts for CLI args, env vars, and settings. Fall back to settings.model.name.
|
||||||
|
const modelId = config?.modelsConfig.getModel() ?? settings.model?.name;
|
||||||
|
|
||||||
|
// Try to find model-specific envKey from modelProviders
|
||||||
|
const modelConfig = findModelConfig(modelProviders, authType, modelId);
|
||||||
|
if (modelConfig?.envKey) {
|
||||||
|
// Explicit envKey configured - only check this env var, no apiKey fallback
|
||||||
|
const hasKey = !!process.env[modelConfig.envKey];
|
||||||
|
return {
|
||||||
|
hasKey,
|
||||||
|
checkedEnvKey: modelConfig.envKey,
|
||||||
|
isExplicitEnvKey: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using default environment variable - apiKey fallback is allowed
|
||||||
|
const defaultEnvKey = DEFAULT_ENV_KEYS[authType];
|
||||||
|
if (defaultEnvKey) {
|
||||||
|
const hasKey = !!process.env[defaultEnvKey];
|
||||||
|
if (hasKey) {
|
||||||
|
return { hasKey, checkedEnvKey: defaultEnvKey, isExplicitEnvKey: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check settings.security.auth.apiKey as fallback (only for default env key)
|
||||||
|
if (settings.security?.auth?.apiKey) {
|
||||||
|
return {
|
||||||
|
hasKey: true,
|
||||||
|
checkedEnvKey: defaultEnvKey || undefined,
|
||||||
|
isExplicitEnvKey: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasKey: false,
|
||||||
|
checkedEnvKey: defaultEnvKey,
|
||||||
|
isExplicitEnvKey: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate API key error message based on auth check result.
|
||||||
|
* Returns null if API key is present, otherwise returns the appropriate error message.
|
||||||
|
*/
|
||||||
|
function getApiKeyError(
|
||||||
|
authMethod: string,
|
||||||
|
settings: Settings,
|
||||||
|
config?: Config,
|
||||||
|
): string | null {
|
||||||
|
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
|
||||||
|
authMethod,
|
||||||
|
settings,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
if (hasKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envKeyHint = checkedEnvKey || DEFAULT_ENV_KEYS[authMethod];
|
||||||
|
if (isExplicitEnvKey) {
|
||||||
|
return t(
|
||||||
|
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
|
||||||
|
{ envKeyHint },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
|
||||||
|
{ envKeyHint },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that the required credentials and configuration exist for the given auth method.
|
||||||
|
*/
|
||||||
|
export function validateAuthMethod(
|
||||||
|
authMethod: string,
|
||||||
|
config?: Config,
|
||||||
|
): string | null {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
loadEnvironment(settings.merged);
|
loadEnvironment(settings.merged);
|
||||||
|
|
||||||
if (authMethod === AuthType.USE_OPENAI) {
|
if (authMethod === AuthType.USE_OPENAI) {
|
||||||
const hasApiKey =
|
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
|
||||||
process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
|
authMethod,
|
||||||
if (!hasApiKey) {
|
settings.merged,
|
||||||
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
|
config,
|
||||||
|
);
|
||||||
|
if (!hasKey) {
|
||||||
|
const envKeyHint = checkedEnvKey
|
||||||
|
? `'${checkedEnvKey}'`
|
||||||
|
: "'OPENAI_API_KEY'";
|
||||||
|
if (isExplicitEnvKey) {
|
||||||
|
// Explicit envKey configured - only suggest setting the env var
|
||||||
|
return t(
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
|
||||||
|
{ envKeyHint },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Default env key - can use either apiKey or env var
|
||||||
|
return t(
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
|
||||||
|
{ envKeyHint },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -26,5 +174,50 @@ export function validateAuthMethod(authMethod: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Invalid auth method selected.';
|
if (authMethod === AuthType.USE_ANTHROPIC) {
|
||||||
|
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
||||||
|
if (apiKeyError) {
|
||||||
|
return apiKeyError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check baseUrl - can come from modelProviders or environment
|
||||||
|
const modelProviders = settings.merged.modelProviders as
|
||||||
|
| ModelProvidersConfig
|
||||||
|
| undefined;
|
||||||
|
// Use config.modelsConfig.getModel() if available for accurate model ID
|
||||||
|
const modelId =
|
||||||
|
config?.modelsConfig.getModel() ?? settings.merged.model?.name;
|
||||||
|
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
|
||||||
|
|
||||||
|
if (modelConfig && !modelConfig.baseUrl) {
|
||||||
|
return t(
|
||||||
|
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!modelConfig && !process.env['ANTHROPIC_BASE_URL']) {
|
||||||
|
return t('ANTHROPIC_BASE_URL environment variable not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authMethod === AuthType.USE_GEMINI) {
|
||||||
|
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
||||||
|
if (apiKeyError) {
|
||||||
|
return apiKeyError;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authMethod === AuthType.USE_VERTEX_AI) {
|
||||||
|
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
||||||
|
if (apiKeyError) {
|
||||||
|
return apiKeyError;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('Invalid auth method selected.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,10 +77,8 @@ vi.mock('read-package-up', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||||
const actualServer = await vi.importActual<typeof ServerConfig>(
|
const actualServer = await importOriginal<typeof ServerConfig>();
|
||||||
'@qwen-code/qwen-code-core',
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...actualServer,
|
...actualServer,
|
||||||
IdeClient: {
|
IdeClient: {
|
||||||
@@ -1597,6 +1595,58 @@ describe('Approval mode tool exclusion logic', () => {
|
|||||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not exclude a tool explicitly allowed in tools.allowed', async () => {
|
||||||
|
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||||
|
const argv = await parseArguments({} as Settings);
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: {
|
||||||
|
allowed: [ShellTool.Name],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const extensions: Extension[] = [];
|
||||||
|
|
||||||
|
const config = await loadCliConfig(
|
||||||
|
settings,
|
||||||
|
extensions,
|
||||||
|
new ExtensionEnablementManager(
|
||||||
|
ExtensionStorage.getUserExtensionsDir(),
|
||||||
|
argv.extensions,
|
||||||
|
),
|
||||||
|
argv,
|
||||||
|
);
|
||||||
|
|
||||||
|
const excludedTools = config.getExcludeTools();
|
||||||
|
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||||
|
expect(excludedTools).toContain(EditTool.Name);
|
||||||
|
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not exclude a tool explicitly allowed in tools.core', async () => {
|
||||||
|
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||||
|
const argv = await parseArguments({} as Settings);
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: {
|
||||||
|
core: [ShellTool.Name],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const extensions: Extension[] = [];
|
||||||
|
|
||||||
|
const config = await loadCliConfig(
|
||||||
|
settings,
|
||||||
|
extensions,
|
||||||
|
new ExtensionEnablementManager(
|
||||||
|
ExtensionStorage.getUserExtensionsDir(),
|
||||||
|
argv.extensions,
|
||||||
|
),
|
||||||
|
argv,
|
||||||
|
);
|
||||||
|
|
||||||
|
const excludedTools = config.getExcludeTools();
|
||||||
|
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||||
|
expect(excludedTools).toContain(EditTool.Name);
|
||||||
|
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||||
|
});
|
||||||
|
|
||||||
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
|
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
|
||||||
process.argv = [
|
process.argv = [
|
||||||
'node',
|
'node',
|
||||||
@@ -2114,7 +2164,14 @@ describe('loadCliConfig model selection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('always prefers model from argvs', async () => {
|
it('always prefers model from argvs', async () => {
|
||||||
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'script.js',
|
||||||
|
'--auth-type',
|
||||||
|
'openai',
|
||||||
|
'--model',
|
||||||
|
'qwen3-coder-plus',
|
||||||
|
];
|
||||||
const argv = await parseArguments({} as Settings);
|
const argv = await parseArguments({} as Settings);
|
||||||
const config = await loadCliConfig(
|
const config = await loadCliConfig(
|
||||||
{
|
{
|
||||||
@@ -2134,7 +2191,14 @@ describe('loadCliConfig model selection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('selects the model from argvs if provided', async () => {
|
it('selects the model from argvs if provided', async () => {
|
||||||
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'script.js',
|
||||||
|
'--auth-type',
|
||||||
|
'openai',
|
||||||
|
'--model',
|
||||||
|
'qwen3-coder-plus',
|
||||||
|
];
|
||||||
const argv = await parseArguments({} as Settings);
|
const argv = await parseArguments({} as Settings);
|
||||||
const config = await loadCliConfig(
|
const config = await loadCliConfig(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,25 +10,31 @@ import {
|
|||||||
Config,
|
Config,
|
||||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||||
EditTool,
|
|
||||||
FileDiscoveryService,
|
FileDiscoveryService,
|
||||||
getCurrentGeminiMdFilename,
|
getCurrentGeminiMdFilename,
|
||||||
loadServerHierarchicalMemory,
|
loadServerHierarchicalMemory,
|
||||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||||
ShellTool,
|
|
||||||
WriteFileTool,
|
|
||||||
resolveTelemetrySettings,
|
resolveTelemetrySettings,
|
||||||
FatalConfigError,
|
FatalConfigError,
|
||||||
Storage,
|
Storage,
|
||||||
InputFormat,
|
InputFormat,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
|
isToolEnabled,
|
||||||
SessionService,
|
SessionService,
|
||||||
type ResumedSessionData,
|
type ResumedSessionData,
|
||||||
type FileFilteringOptions,
|
type FileFilteringOptions,
|
||||||
type MCPServerConfig,
|
type MCPServerConfig,
|
||||||
|
type ToolName,
|
||||||
|
EditTool,
|
||||||
|
ShellTool,
|
||||||
|
WriteFileTool,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { extensionsCommand } from '../commands/extensions.js';
|
import { extensionsCommand } from '../commands/extensions.js';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
|
import {
|
||||||
|
resolveCliGenerationConfig,
|
||||||
|
getAuthTypeFromEnv,
|
||||||
|
} from '../utils/modelConfigUtils.js';
|
||||||
import yargs, { type Argv } from 'yargs';
|
import yargs, { type Argv } from 'yargs';
|
||||||
import { hideBin } from 'yargs/helpers';
|
import { hideBin } from 'yargs/helpers';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
@@ -111,7 +117,9 @@ export interface CliArgs {
|
|||||||
telemetryOutfile: string | undefined;
|
telemetryOutfile: string | undefined;
|
||||||
allowedMcpServerNames: string[] | undefined;
|
allowedMcpServerNames: string[] | undefined;
|
||||||
allowedTools: string[] | undefined;
|
allowedTools: string[] | undefined;
|
||||||
|
acp: boolean | undefined;
|
||||||
experimentalAcp: boolean | undefined;
|
experimentalAcp: boolean | undefined;
|
||||||
|
experimentalSkills: boolean | undefined;
|
||||||
extensions: string[] | undefined;
|
extensions: string[] | undefined;
|
||||||
listExtensions: boolean | undefined;
|
listExtensions: boolean | undefined;
|
||||||
openaiLogging: boolean | undefined;
|
openaiLogging: boolean | undefined;
|
||||||
@@ -303,10 +311,21 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
description: 'Enables checkpointing of file edits',
|
description: 'Enables checkpointing of file edits',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
.option('experimental-acp', {
|
.option('acp', {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Starts the agent in ACP mode',
|
description: 'Starts the agent in ACP mode',
|
||||||
})
|
})
|
||||||
|
.option('experimental-acp', {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Starts the agent in ACP mode (deprecated, use --acp instead)',
|
||||||
|
hidden: true,
|
||||||
|
})
|
||||||
|
.option('experimental-skills', {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Enable experimental Skills feature',
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
.option('channel', {
|
.option('channel', {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||||
@@ -460,7 +479,13 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
})
|
})
|
||||||
.option('auth-type', {
|
.option('auth-type', {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH],
|
choices: [
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
|
AuthType.USE_ANTHROPIC,
|
||||||
|
AuthType.QWEN_OAUTH,
|
||||||
|
AuthType.USE_GEMINI,
|
||||||
|
AuthType.USE_VERTEX_AI,
|
||||||
|
],
|
||||||
description: 'Authentication type',
|
description: 'Authentication type',
|
||||||
})
|
})
|
||||||
.deprecateOption(
|
.deprecateOption(
|
||||||
@@ -577,8 +602,19 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
// The import format is now only controlled by settings.memoryImportFormat
|
// The import format is now only controlled by settings.memoryImportFormat
|
||||||
// We no longer accept it as a CLI argument
|
// We no longer accept it as a CLI argument
|
||||||
|
|
||||||
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
|
// Handle deprecated --experimental-acp flag
|
||||||
if (result['experimentalAcp'] && !result['channel']) {
|
if (result['experimentalAcp']) {
|
||||||
|
console.warn(
|
||||||
|
'\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m',
|
||||||
|
);
|
||||||
|
// Map experimental-acp to acp if acp is not explicitly set
|
||||||
|
if (!result['acp']) {
|
||||||
|
(result as Record<string, unknown>)['acp'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply ACP fallback: if acp or experimental-acp is present but no explicit --channel, treat as ACP
|
||||||
|
if ((result['acp'] || result['experimentalAcp']) && !result['channel']) {
|
||||||
(result as Record<string, unknown>)['channel'] = 'ACP';
|
(result as Record<string, unknown>)['channel'] = 'ACP';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,6 +842,28 @@ export async function loadCliConfig(
|
|||||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||||
// so tools should not be excluded in that case.
|
// so tools should not be excluded in that case.
|
||||||
const extraExcludes: string[] = [];
|
const extraExcludes: string[] = [];
|
||||||
|
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
|
||||||
|
const resolvedAllowedTools =
|
||||||
|
argv.allowedTools || settings.tools?.allowed || [];
|
||||||
|
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
|
||||||
|
if (resolvedCoreTools.length > 0) {
|
||||||
|
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolvedAllowedTools.length > 0) {
|
||||||
|
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const excludeUnlessExplicit = (toolName: ToolName): void => {
|
||||||
|
if (!isExplicitlyEnabled(toolName)) {
|
||||||
|
extraExcludes.push(toolName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!interactive &&
|
!interactive &&
|
||||||
!argv.experimentalAcp &&
|
!argv.experimentalAcp &&
|
||||||
@@ -814,12 +872,15 @@ export async function loadCliConfig(
|
|||||||
switch (approvalMode) {
|
switch (approvalMode) {
|
||||||
case ApprovalMode.PLAN:
|
case ApprovalMode.PLAN:
|
||||||
case ApprovalMode.DEFAULT:
|
case ApprovalMode.DEFAULT:
|
||||||
// In default non-interactive mode, all tools that require approval are excluded.
|
// In default non-interactive mode, all tools that require approval are excluded,
|
||||||
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
|
// unless explicitly enabled via coreTools/allowedTools.
|
||||||
|
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||||
|
excludeUnlessExplicit(EditTool.Name as ToolName);
|
||||||
|
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
|
||||||
break;
|
break;
|
||||||
case ApprovalMode.AUTO_EDIT:
|
case ApprovalMode.AUTO_EDIT:
|
||||||
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
||||||
extraExcludes.push(ShellTool.Name);
|
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||||
break;
|
break;
|
||||||
case ApprovalMode.YOLO:
|
case ApprovalMode.YOLO:
|
||||||
// No extra excludes for YOLO mode.
|
// No extra excludes for YOLO mode.
|
||||||
@@ -865,11 +926,27 @@ export async function loadCliConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedModel =
|
const selectedAuthType =
|
||||||
argv.model ||
|
(argv.authType as AuthType | undefined) ||
|
||||||
process.env['OPENAI_MODEL'] ||
|
settings.security?.auth?.selectedType ||
|
||||||
process.env['QWEN_MODEL'] ||
|
/* getAuthTypeFromEnv means no authType was explicitly provided, we infer the authType from env vars */
|
||||||
settings.model?.name;
|
getAuthTypeFromEnv();
|
||||||
|
|
||||||
|
// Unified resolution of generation config with source attribution
|
||||||
|
const resolvedCliConfig = resolveCliGenerationConfig({
|
||||||
|
argv: {
|
||||||
|
model: argv.model,
|
||||||
|
openaiApiKey: argv.openaiApiKey,
|
||||||
|
openaiBaseUrl: argv.openaiBaseUrl,
|
||||||
|
openaiLogging: argv.openaiLogging,
|
||||||
|
openaiLoggingDir: argv.openaiLoggingDir,
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
selectedAuthType,
|
||||||
|
env: process.env as Record<string, string | undefined>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { model: resolvedModel } = resolvedCliConfig;
|
||||||
|
|
||||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||||
const screenReader =
|
const screenReader =
|
||||||
@@ -903,6 +980,8 @@ export async function loadCliConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelProvidersConfig = settings.modelProviders;
|
||||||
|
|
||||||
return new Config({
|
return new Config({
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionData,
|
sessionData,
|
||||||
@@ -950,41 +1029,21 @@ export async function loadCliConfig(
|
|||||||
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
||||||
maxSessionTurns:
|
maxSessionTurns:
|
||||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
|
||||||
|
experimentalSkills: argv.experimentalSkills || false,
|
||||||
listExtensions: argv.listExtensions || false,
|
listExtensions: argv.listExtensions || false,
|
||||||
extensions: allExtensions,
|
extensions: allExtensions,
|
||||||
blockedMcpServers,
|
blockedMcpServers,
|
||||||
noBrowser: !!process.env['NO_BROWSER'],
|
noBrowser: !!process.env['NO_BROWSER'],
|
||||||
authType:
|
authType: selectedAuthType,
|
||||||
(argv.authType as AuthType | undefined) ||
|
|
||||||
settings.security?.auth?.selectedType,
|
|
||||||
inputFormat,
|
inputFormat,
|
||||||
outputFormat,
|
outputFormat,
|
||||||
includePartialMessages,
|
includePartialMessages,
|
||||||
generationConfig: {
|
modelProvidersConfig,
|
||||||
...(settings.model?.generationConfig || {}),
|
generationConfigSources: resolvedCliConfig.sources,
|
||||||
model: resolvedModel,
|
generationConfig: resolvedCliConfig.generationConfig,
|
||||||
apiKey:
|
|
||||||
argv.openaiApiKey ||
|
|
||||||
process.env['OPENAI_API_KEY'] ||
|
|
||||||
settings.security?.auth?.apiKey,
|
|
||||||
baseUrl:
|
|
||||||
argv.openaiBaseUrl ||
|
|
||||||
process.env['OPENAI_BASE_URL'] ||
|
|
||||||
settings.security?.auth?.baseUrl,
|
|
||||||
enableOpenAILogging:
|
|
||||||
(typeof argv.openaiLogging === 'undefined'
|
|
||||||
? settings.model?.enableOpenAILogging
|
|
||||||
: argv.openaiLogging) ?? false,
|
|
||||||
openAILoggingDir:
|
|
||||||
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
|
|
||||||
},
|
|
||||||
cliVersion: await getCliVersion(),
|
cliVersion: await getCliVersion(),
|
||||||
webSearch: buildWebSearchConfig(
|
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
||||||
argv,
|
|
||||||
settings,
|
|
||||||
settings.security?.auth?.selectedType,
|
|
||||||
),
|
|
||||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||||
ideMode,
|
ideMode,
|
||||||
chatCompression: settings.model?.chatCompression,
|
chatCompression: settings.model?.chatCompression,
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ vi.mock('simple-git', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./extensions/github.js', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('./extensions/github.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
downloadFromGitHubRelease: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error('Mocked GitHub release download failure')),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('os', async (importOriginal) => {
|
vi.mock('os', async (importOriginal) => {
|
||||||
const mockedOs = await importOriginal<typeof os>();
|
const mockedOs = await importOriginal<typeof os>();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ vi.mock('simple-git', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../extensions/github.js', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('../extensions/github.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
downloadFromGitHubRelease: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error('Mocked GitHub release download failure')),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('os', async (importOriginal) => {
|
vi.mock('os', async (importOriginal) => {
|
||||||
const mockedOs = await importOriginal<typeof os>();
|
const mockedOs = await importOriginal<typeof os>();
|
||||||
return {
|
return {
|
||||||
|
|||||||
87
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
87
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SettingScope } from './settings.js';
|
||||||
|
import { getPersistScopeForModelSelection } from './modelProvidersScope.js';
|
||||||
|
|
||||||
|
function makeSettings({
|
||||||
|
isTrusted,
|
||||||
|
userModelProviders,
|
||||||
|
workspaceModelProviders,
|
||||||
|
}: {
|
||||||
|
isTrusted: boolean;
|
||||||
|
userModelProviders?: unknown;
|
||||||
|
workspaceModelProviders?: unknown;
|
||||||
|
}) {
|
||||||
|
const userSettings: Record<string, unknown> = {};
|
||||||
|
const workspaceSettings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// When undefined, treat as "not present in this scope" (the key is omitted),
|
||||||
|
// matching how LoadedSettings is shaped when a settings file doesn't define it.
|
||||||
|
if (userModelProviders !== undefined) {
|
||||||
|
userSettings['modelProviders'] = userModelProviders;
|
||||||
|
}
|
||||||
|
if (workspaceModelProviders !== undefined) {
|
||||||
|
workspaceSettings['modelProviders'] = workspaceModelProviders;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTrusted,
|
||||||
|
user: { settings: userSettings },
|
||||||
|
workspace: { settings: workspaceSettings },
|
||||||
|
} as unknown as import('./settings.js').LoadedSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getPersistScopeForModelSelection', () => {
|
||||||
|
it('prefers workspace when trusted and workspace defines modelProviders', () => {
|
||||||
|
const settings = makeSettings({
|
||||||
|
isTrusted: true,
|
||||||
|
workspaceModelProviders: {},
|
||||||
|
userModelProviders: { anything: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getPersistScopeForModelSelection(settings)).toBe(
|
||||||
|
SettingScope.Workspace,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to user when workspace does not define modelProviders', () => {
|
||||||
|
const settings = makeSettings({
|
||||||
|
isTrusted: true,
|
||||||
|
workspaceModelProviders: undefined,
|
||||||
|
userModelProviders: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores workspace modelProviders when workspace is untrusted', () => {
|
||||||
|
const settings = makeSettings({
|
||||||
|
isTrusted: false,
|
||||||
|
workspaceModelProviders: {},
|
||||||
|
userModelProviders: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to legacy trust heuristic when neither scope defines modelProviders', () => {
|
||||||
|
const trusted = makeSettings({
|
||||||
|
isTrusted: true,
|
||||||
|
userModelProviders: undefined,
|
||||||
|
workspaceModelProviders: undefined,
|
||||||
|
});
|
||||||
|
expect(getPersistScopeForModelSelection(trusted)).toBe(SettingScope.User);
|
||||||
|
|
||||||
|
const untrusted = makeSettings({
|
||||||
|
isTrusted: false,
|
||||||
|
userModelProviders: undefined,
|
||||||
|
workspaceModelProviders: undefined,
|
||||||
|
});
|
||||||
|
expect(getPersistScopeForModelSelection(untrusted)).toBe(SettingScope.User);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SettingScope, type LoadedSettings } from './settings.js';
|
||||||
|
|
||||||
|
function hasOwnModelProviders(settingsObj: unknown): boolean {
|
||||||
|
if (!settingsObj || typeof settingsObj !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const obj = settingsObj as Record<string, unknown>;
|
||||||
|
// Treat an explicitly configured empty object (modelProviders: {}) as "owned"
|
||||||
|
// by this scope, which is important when mergeStrategy is REPLACE.
|
||||||
|
return Object.prototype.hasOwnProperty.call(obj, 'modelProviders');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns which writable scope (Workspace/User) owns the effective modelProviders
|
||||||
|
* configuration.
|
||||||
|
*
|
||||||
|
* Note: Workspace scope is only considered when the workspace is trusted.
|
||||||
|
*/
|
||||||
|
export function getModelProvidersOwnerScope(
|
||||||
|
settings: LoadedSettings,
|
||||||
|
): SettingScope | undefined {
|
||||||
|
if (settings.isTrusted && hasOwnModelProviders(settings.workspace.settings)) {
|
||||||
|
return SettingScope.Workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOwnModelProviders(settings.user.settings)) {
|
||||||
|
return SettingScope.User;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose the settings scope to persist a model selection.
|
||||||
|
* Prefer persisting back to the scope that contains the effective modelProviders
|
||||||
|
* config, otherwise fall back to the legacy trust-based heuristic.
|
||||||
|
*/
|
||||||
|
export function getPersistScopeForModelSelection(
|
||||||
|
settings: LoadedSettings,
|
||||||
|
): SettingScope {
|
||||||
|
return getModelProvidersOwnerScope(settings) ?? SettingScope.User;
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ import { disableExtension } from './extension.js';
|
|||||||
|
|
||||||
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
|
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
|
||||||
import {
|
import {
|
||||||
|
getSettingsWarnings,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
USER_SETTINGS_PATH, // This IS the mocked path.
|
USER_SETTINGS_PATH, // This IS the mocked path.
|
||||||
getSystemSettingsPath,
|
getSystemSettingsPath,
|
||||||
@@ -418,6 +419,86 @@ describe('Settings Loading and Merging', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should warn about ignored legacy keys in a v2 settings file', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
const userSettingsContent = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
usageStatisticsEnabled: false,
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
expect(getSettingsWarnings(settings)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining(
|
||||||
|
"Legacy setting 'usageStatisticsEnabled' will be ignored",
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(getSettingsWarnings(settings)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining("'privacy.usageStatisticsEnabled'"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn about unknown top-level keys in a v2 settings file', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
const userSettingsContent = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
someUnknownKey: 'value',
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
expect(getSettingsWarnings(settings)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining(
|
||||||
|
"Unknown setting 'someUnknownKey' will be ignored",
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn for valid v2 container keys', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
const userSettingsContent = {
|
||||||
|
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||||
|
model: { name: 'qwen-coder' },
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
expect(getSettingsWarnings(settings)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should rewrite allowedTools to tools.allowed during migration', () => {
|
it('should rewrite allowedTools to tools.allowed during migration', () => {
|
||||||
(mockFsExistsSync as Mock).mockImplementation(
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
|||||||
@@ -344,6 +344,97 @@ const KNOWN_V2_CONTAINERS = new Set(
|
|||||||
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
|
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function getSettingsFileKeyWarnings(
|
||||||
|
settings: Record<string, unknown>,
|
||||||
|
settingsFilePath: string,
|
||||||
|
): string[] {
|
||||||
|
const version = settings[SETTINGS_VERSION_KEY];
|
||||||
|
if (typeof version !== 'number' || version < SETTINGS_VERSION) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const ignoredLegacyKeys = new Set<string>();
|
||||||
|
|
||||||
|
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
|
||||||
|
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||||
|
if (oldKey === newPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(oldKey in settings)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldValue = settings[oldKey];
|
||||||
|
|
||||||
|
// If this key is a V2 container (like 'model') and it's already an object,
|
||||||
|
// it's likely already in V2 format. Don't warn.
|
||||||
|
if (
|
||||||
|
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||||
|
typeof oldValue === 'object' &&
|
||||||
|
oldValue !== null &&
|
||||||
|
!Array.isArray(oldValue)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoredLegacyKeys.add(oldKey);
|
||||||
|
warnings.push(
|
||||||
|
`⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown top-level keys.
|
||||||
|
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
|
||||||
|
for (const key of Object.keys(settings)) {
|
||||||
|
if (key === SETTINGS_VERSION_KEY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ignoredLegacyKeys.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (schemaKeys.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings.push(
|
||||||
|
`⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects warnings for ignored legacy and unknown settings keys.
|
||||||
|
*
|
||||||
|
* For `$version: 2` settings files, we do not apply implicit migrations.
|
||||||
|
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
|
||||||
|
*/
|
||||||
|
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
||||||
|
const warningSet = new Set<string>();
|
||||||
|
|
||||||
|
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||||
|
const settingsFile = loadedSettings.forScope(scope);
|
||||||
|
if (settingsFile.rawJson === undefined) {
|
||||||
|
continue; // File not present / not loaded.
|
||||||
|
}
|
||||||
|
const settingsObject = settingsFile.originalSettings as unknown as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
for (const warning of getSettingsFileKeyWarnings(
|
||||||
|
settingsObject,
|
||||||
|
settingsFile.path,
|
||||||
|
)) {
|
||||||
|
warningSet.add(warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...warningSet];
|
||||||
|
}
|
||||||
|
|
||||||
export function migrateSettingsToV1(
|
export function migrateSettingsToV1(
|
||||||
v2Settings: Record<string, unknown>,
|
v2Settings: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
TelemetrySettings,
|
TelemetrySettings,
|
||||||
AuthType,
|
AuthType,
|
||||||
ChatCompressionSettings,
|
ChatCompressionSettings,
|
||||||
|
ModelProvidersConfig,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
@@ -102,6 +103,19 @@ const SETTINGS_SCHEMA = {
|
|||||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Model providers configuration grouped by authType
|
||||||
|
modelProviders: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Model Providers',
|
||||||
|
category: 'Model',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: {} as ModelProvidersConfig,
|
||||||
|
description:
|
||||||
|
'Model providers configuration grouped by authType. Each authType contains an array of model configurations.',
|
||||||
|
showInDialog: false,
|
||||||
|
mergeStrategy: MergeStrategy.REPLACE,
|
||||||
|
},
|
||||||
|
|
||||||
general: {
|
general: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
label: 'General',
|
label: 'General',
|
||||||
@@ -202,6 +216,7 @@ const SETTINGS_SCHEMA = {
|
|||||||
{ value: 'en', label: 'English' },
|
{ value: 'en', label: 'English' },
|
||||||
{ value: 'zh', label: '中文 (Chinese)' },
|
{ value: 'zh', label: '中文 (Chinese)' },
|
||||||
{ value: 'ru', label: 'Русский (Russian)' },
|
{ value: 'ru', label: 'Русский (Russian)' },
|
||||||
|
{ value: 'de', label: 'Deutsch (German)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
terminalBell: {
|
terminalBell: {
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export async function initializeApp(
|
|||||||
// Auto-detect and set LLM output language on first use
|
// Auto-detect and set LLM output language on first use
|
||||||
initializeLlmOutputLanguage();
|
initializeLlmOutputLanguage();
|
||||||
|
|
||||||
const authType = settings.merged.security?.auth?.selectedType;
|
// Use authType from modelsConfig which respects CLI --auth-type argument
|
||||||
|
// over settings.security.auth.selectedType
|
||||||
|
const authType = config.modelsConfig.getCurrentAuthType();
|
||||||
const authError = await performInitialAuth(config, authType);
|
const authError = await performInitialAuth(config, authType);
|
||||||
|
|
||||||
// Fallback to user select when initial authentication fails
|
// Fallback to user select when initial authentication fails
|
||||||
@@ -59,7 +61,7 @@ export async function initializeApp(
|
|||||||
const themeError = validateTheme(settings);
|
const themeError = validateTheme(settings);
|
||||||
|
|
||||||
const shouldOpenAuthDialog =
|
const shouldOpenAuthDialog =
|
||||||
settings.merged.security?.auth?.selectedType === undefined || !!authError;
|
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError;
|
||||||
|
|
||||||
if (config.getIdeMode()) {
|
if (config.getIdeMode()) {
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ vi.mock('./config/sandboxConfig.js', () => ({
|
|||||||
loadSandboxConfig: vi.fn(),
|
loadSandboxConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./core/initializer.js', () => ({
|
||||||
|
initializeApp: vi.fn().mockResolvedValue({
|
||||||
|
authError: null,
|
||||||
|
themeError: null,
|
||||||
|
shouldOpenAuthDialog: false,
|
||||||
|
geminiMdFileCount: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('gemini.tsx main function', () => {
|
describe('gemini.tsx main function', () => {
|
||||||
let originalEnvGeminiSandbox: string | undefined;
|
let originalEnvGeminiSandbox: string | undefined;
|
||||||
let originalEnvSandbox: string | undefined;
|
let originalEnvSandbox: string | undefined;
|
||||||
@@ -362,7 +371,6 @@ describe('gemini.tsx main function', () => {
|
|||||||
expect(inputArg).toBe('hello stream');
|
expect(inputArg).toBe('hello stream');
|
||||||
|
|
||||||
expect(validateAuthSpy).toHaveBeenCalledWith(
|
expect(validateAuthSpy).toHaveBeenCalledWith(
|
||||||
undefined,
|
|
||||||
undefined,
|
undefined,
|
||||||
configStub,
|
configStub,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -460,7 +468,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
telemetryOutfile: undefined,
|
telemetryOutfile: undefined,
|
||||||
allowedMcpServerNames: undefined,
|
allowedMcpServerNames: undefined,
|
||||||
allowedTools: undefined,
|
allowedTools: undefined,
|
||||||
|
acp: undefined,
|
||||||
experimentalAcp: undefined,
|
experimentalAcp: undefined,
|
||||||
|
experimentalSkills: undefined,
|
||||||
extensions: undefined,
|
extensions: undefined,
|
||||||
listExtensions: undefined,
|
listExtensions: undefined,
|
||||||
openaiLogging: undefined,
|
openaiLogging: undefined,
|
||||||
@@ -638,4 +648,37 @@ describe('startInteractiveUI', () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
expect(checkForUpdates).toHaveBeenCalledTimes(1);
|
expect(checkForUpdates).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not check for updates when update nag is disabled', async () => {
|
||||||
|
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
|
||||||
|
|
||||||
|
const mockInitializationResult = {
|
||||||
|
authError: null,
|
||||||
|
themeError: null,
|
||||||
|
shouldOpenAuthDialog: false,
|
||||||
|
geminiMdFileCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsWithUpdateNagDisabled = {
|
||||||
|
merged: {
|
||||||
|
general: {
|
||||||
|
disableUpdateNag: true,
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
hideWindowTitle: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as LoadedSettings;
|
||||||
|
|
||||||
|
await startInteractiveUI(
|
||||||
|
mockConfig,
|
||||||
|
settingsWithUpdateNagDisabled,
|
||||||
|
mockStartupWarnings,
|
||||||
|
mockWorkspaceRoot,
|
||||||
|
mockInitializationResult,
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(checkForUpdates).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
|
||||||
AuthType,
|
|
||||||
getOauthClient,
|
|
||||||
InputFormat,
|
|
||||||
logUserPrompt,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { render } from 'ink';
|
import { render } from 'ink';
|
||||||
import dns from 'node:dns';
|
import dns from 'node:dns';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
@@ -22,7 +17,11 @@ import * as cliConfig from './config/config.js';
|
|||||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
import {
|
||||||
|
getSettingsWarnings,
|
||||||
|
loadSettings,
|
||||||
|
migrateDeprecatedSettings,
|
||||||
|
} from './config/settings.js';
|
||||||
import {
|
import {
|
||||||
initializeApp,
|
initializeApp,
|
||||||
type InitializationResult,
|
type InitializationResult,
|
||||||
@@ -188,16 +187,18 @@ export async function startInteractiveUI(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
checkForUpdates()
|
if (!settings.merged.general?.disableUpdateNag) {
|
||||||
.then((info) => {
|
checkForUpdates()
|
||||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
.then((info) => {
|
||||||
})
|
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||||
.catch((err) => {
|
})
|
||||||
// Silently ignore update check errors.
|
.catch((err) => {
|
||||||
if (config.getDebugMode()) {
|
// Silently ignore update check errors.
|
||||||
console.error('Update check failed:', err);
|
if (config.getDebugMode()) {
|
||||||
}
|
console.error('Update check failed:', err);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
registerCleanup(() => instance.unmount());
|
registerCleanup(() => instance.unmount());
|
||||||
}
|
}
|
||||||
@@ -255,22 +256,20 @@ export async function main() {
|
|||||||
argv,
|
argv,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (!settings.merged.security?.auth?.useExternal) {
|
||||||
settings.merged.security?.auth?.selectedType &&
|
|
||||||
!settings.merged.security?.auth?.useExternal
|
|
||||||
) {
|
|
||||||
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
|
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
|
||||||
try {
|
try {
|
||||||
const err = validateAuthMethod(
|
const authType = partialConfig.modelsConfig.getCurrentAuthType();
|
||||||
settings.merged.security.auth.selectedType,
|
// Fresh users may not have selected/persisted an authType yet.
|
||||||
);
|
// In that case, defer auth prompting/selection to the main interactive flow.
|
||||||
if (err) {
|
if (authType) {
|
||||||
throw new Error(err);
|
const err = validateAuthMethod(authType, partialConfig);
|
||||||
}
|
if (err) {
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
|
||||||
await partialConfig.refreshAuth(
|
await partialConfig.refreshAuth(authType);
|
||||||
settings.merged.security.auth.selectedType,
|
}
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error authenticating:', err);
|
console.error('Error authenticating:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -399,27 +398,21 @@ export async function main() {
|
|||||||
initializationResult = await initializeApp(config, settings);
|
initializationResult = await initializeApp(config, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
settings.merged.security?.auth?.selectedType ===
|
|
||||||
AuthType.LOGIN_WITH_GOOGLE &&
|
|
||||||
config.isBrowserLaunchSuppressed()
|
|
||||||
) {
|
|
||||||
// Do oauth before app renders to make copying the link possible.
|
|
||||||
await getOauthClient(settings.merged.security.auth.selectedType, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.getExperimentalZedIntegration()) {
|
if (config.getExperimentalZedIntegration()) {
|
||||||
return runAcpAgent(config, settings, extensions, argv);
|
return runAcpAgent(config, settings, extensions, argv);
|
||||||
}
|
}
|
||||||
|
|
||||||
let input = config.getQuestion();
|
let input = config.getQuestion();
|
||||||
const startupWarnings = [
|
const startupWarnings = [
|
||||||
...(await getStartupWarnings()),
|
...new Set([
|
||||||
...(await getUserStartupWarnings({
|
...(await getStartupWarnings()),
|
||||||
workspaceRoot: process.cwd(),
|
...(await getUserStartupWarnings({
|
||||||
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
|
workspaceRoot: process.cwd(),
|
||||||
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
|
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
|
||||||
})),
|
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
|
||||||
|
})),
|
||||||
|
...getSettingsWarnings(settings),
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||||
@@ -452,8 +445,6 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||||
(argv.authType as AuthType) ||
|
|
||||||
settings.merged.security?.auth?.selectedType,
|
|
||||||
settings.merged.security?.auth?.useExternal,
|
settings.merged.security?.auth?.useExternal,
|
||||||
config,
|
config,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
1126
packages/cli/src/i18n/locales/de.js
Normal file
1126
packages/cli/src/i18n/locales/de.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,9 @@ export default {
|
|||||||
'No tools available': 'No tools available',
|
'No tools available': 'No tools available',
|
||||||
'View or change the approval mode for tool usage':
|
'View or change the approval mode for tool usage':
|
||||||
'View or change the approval mode for tool usage',
|
'View or change the approval mode for tool usage',
|
||||||
|
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||||
|
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}',
|
||||||
|
'Approval mode set to "{{mode}}"': 'Approval mode set to "{{mode}}"',
|
||||||
'View or change the language setting': 'View or change the language setting',
|
'View or change the language setting': 'View or change the language setting',
|
||||||
'change the theme': 'change the theme',
|
'change the theme': 'change the theme',
|
||||||
'Select Theme': 'Select Theme',
|
'Select Theme': 'Select Theme',
|
||||||
@@ -258,6 +261,8 @@ export default {
|
|||||||
', Tab to change focus': ', Tab to change focus',
|
', Tab to change focus': ', Tab to change focus',
|
||||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||||
|
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||||
|
'The command "/{{command}}" is not supported in non-interactive mode.',
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Settings Labels
|
// Settings Labels
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -590,6 +595,12 @@ export default {
|
|||||||
'No conversation found to summarize.': 'No conversation found to summarize.',
|
'No conversation found to summarize.': 'No conversation found to summarize.',
|
||||||
'Failed to generate project context summary: {{error}}':
|
'Failed to generate project context summary: {{error}}':
|
||||||
'Failed to generate project context summary: {{error}}',
|
'Failed to generate project context summary: {{error}}',
|
||||||
|
'Saved project summary to {{filePathForDisplay}}.':
|
||||||
|
'Saved project summary to {{filePathForDisplay}}.',
|
||||||
|
'Saving project summary...': 'Saving project summary...',
|
||||||
|
'Generating project summary...': 'Generating project summary...',
|
||||||
|
'Failed to generate summary - no text content received from LLM response':
|
||||||
|
'Failed to generate summary - no text content received from LLM response',
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Commands - Model
|
// Commands - Model
|
||||||
@@ -759,6 +770,21 @@ export default {
|
|||||||
'Authentication timed out. Please try again.',
|
'Authentication timed out. Please try again.',
|
||||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)',
|
'Waiting for auth... (Press ESC or CTRL+C to cancel)',
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
|
||||||
|
'{{envKeyHint}} environment variable not found.':
|
||||||
|
'{{envKeyHint}} environment variable not found.',
|
||||||
|
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
|
||||||
|
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
|
||||||
|
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
|
||||||
|
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
|
||||||
|
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
|
||||||
|
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
|
||||||
|
'ANTHROPIC_BASE_URL environment variable not found.':
|
||||||
|
'ANTHROPIC_BASE_URL environment variable not found.',
|
||||||
|
'Invalid auth method selected.': 'Invalid auth method selected.',
|
||||||
'Failed to authenticate. Message: {{message}}':
|
'Failed to authenticate. Message: {{message}}':
|
||||||
'Failed to authenticate. Message: {{message}}',
|
'Failed to authenticate. Message: {{message}}',
|
||||||
'Authenticated successfully with {{authType}} credentials.':
|
'Authenticated successfully with {{authType}} credentials.':
|
||||||
@@ -780,6 +806,15 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Select Model': 'Select Model',
|
'Select Model': 'Select Model',
|
||||||
'(Press Esc to close)': '(Press Esc to close)',
|
'(Press Esc to close)': '(Press Esc to close)',
|
||||||
|
'Current (effective) configuration': 'Current (effective) configuration',
|
||||||
|
AuthType: 'AuthType',
|
||||||
|
'API Key': 'API Key',
|
||||||
|
unset: 'unset',
|
||||||
|
'(default)': '(default)',
|
||||||
|
'(set)': '(set)',
|
||||||
|
'(not set)': '(not set)',
|
||||||
|
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||||
|
"Failed to switch model to '{{modelId}}'.\n\n{{error}}",
|
||||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
||||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||||
@@ -1029,7 +1064,6 @@ export default {
|
|||||||
'Applying percussive maintenance...',
|
'Applying percussive maintenance...',
|
||||||
'Searching for the correct USB orientation...',
|
'Searching for the correct USB orientation...',
|
||||||
'Ensuring the magic smoke stays inside the wires...',
|
'Ensuring the magic smoke stays inside the wires...',
|
||||||
'Rewriting in Rust for no particular reason...',
|
|
||||||
'Trying to exit Vim...',
|
'Trying to exit Vim...',
|
||||||
'Spinning up the hamster wheel...',
|
'Spinning up the hamster wheel...',
|
||||||
"That's not a bug, it's an undocumented feature...",
|
"That's not a bug, it's an undocumented feature...",
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ export default {
|
|||||||
'No tools available': 'Нет доступных инструментов',
|
'No tools available': 'Нет доступных инструментов',
|
||||||
'View or change the approval mode for tool usage':
|
'View or change the approval mode for tool usage':
|
||||||
'Просмотр или изменение режима подтверждения для использования инструментов',
|
'Просмотр или изменение режима подтверждения для использования инструментов',
|
||||||
|
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||||
|
'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}',
|
||||||
|
'Approval mode set to "{{mode}}"':
|
||||||
|
'Режим подтверждения установлен на "{{mode}}"',
|
||||||
'View or change the language setting':
|
'View or change the language setting':
|
||||||
'Просмотр или изменение настроек языка',
|
'Просмотр или изменение настроек языка',
|
||||||
'change the theme': 'Изменение темы',
|
'change the theme': 'Изменение темы',
|
||||||
@@ -260,7 +264,8 @@ export default {
|
|||||||
', Tab to change focus': ', Tab для смены фокуса',
|
', Tab to change focus': ', Tab для смены фокуса',
|
||||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||||
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
|
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
|
||||||
|
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||||
|
'Команда "/{{command}}" не поддерживается в неинтерактивном режиме.',
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Метки настроек
|
// Метки настроек
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -605,6 +610,12 @@ export default {
|
|||||||
'Не найдено диалогов для создания сводки.',
|
'Не найдено диалогов для создания сводки.',
|
||||||
'Failed to generate project context summary: {{error}}':
|
'Failed to generate project context summary: {{error}}':
|
||||||
'Не удалось сгенерировать сводку контекста проекта: {{error}}',
|
'Не удалось сгенерировать сводку контекста проекта: {{error}}',
|
||||||
|
'Saved project summary to {{filePathForDisplay}}.':
|
||||||
|
'Сводка проекта сохранена в {{filePathForDisplay}}',
|
||||||
|
'Saving project summary...': 'Сохранение сводки проекта...',
|
||||||
|
'Generating project summary...': 'Генерация сводки проекта...',
|
||||||
|
'Failed to generate summary - no text content received from LLM response':
|
||||||
|
'Не удалось сгенерировать сводку - не получен текстовый контент из ответа LLM',
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Команды - Модель
|
// Команды - Модель
|
||||||
@@ -775,6 +786,21 @@ export default {
|
|||||||
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
|
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
|
||||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||||
'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)',
|
'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)',
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
||||||
|
'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Укажите settings.security.auth.apiKey или переменную окружения {{envKeyHint}}.',
|
||||||
|
'{{envKeyHint}} environment variable not found.':
|
||||||
|
'Переменная окружения {{envKeyHint}} не найдена.',
|
||||||
|
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
|
||||||
|
'Переменная окружения {{envKeyHint}} не найдена. Укажите её в файле .env или среди системных переменных.',
|
||||||
|
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
|
||||||
|
'Переменная окружения {{envKeyHint}} не найдена (или установите settings.security.auth.apiKey). Укажите её в файле .env или среди системных переменных.',
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
|
||||||
|
'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Установите переменную окружения {{envKeyHint}}.',
|
||||||
|
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
|
||||||
|
'У провайдера Anthropic отсутствует обязательный baseUrl в modelProviders[].baseUrl.',
|
||||||
|
'ANTHROPIC_BASE_URL environment variable not found.':
|
||||||
|
'Переменная окружения ANTHROPIC_BASE_URL не найдена.',
|
||||||
|
'Invalid auth method selected.': 'Выбран недопустимый метод авторизации.',
|
||||||
'Failed to authenticate. Message: {{message}}':
|
'Failed to authenticate. Message: {{message}}':
|
||||||
'Не удалось авторизоваться. Сообщение: {{message}}',
|
'Не удалось авторизоваться. Сообщение: {{message}}',
|
||||||
'Authenticated successfully with {{authType}} credentials.':
|
'Authenticated successfully with {{authType}} credentials.':
|
||||||
@@ -796,6 +822,15 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Select Model': 'Выбрать модель',
|
'Select Model': 'Выбрать модель',
|
||||||
'(Press Esc to close)': '(Нажмите Esc для закрытия)',
|
'(Press Esc to close)': '(Нажмите Esc для закрытия)',
|
||||||
|
'Current (effective) configuration': 'Текущая (фактическая) конфигурация',
|
||||||
|
AuthType: 'Тип авторизации',
|
||||||
|
'API Key': 'API-ключ',
|
||||||
|
unset: 'не задано',
|
||||||
|
'(default)': '(по умолчанию)',
|
||||||
|
'(set)': '(установлено)',
|
||||||
|
'(not set)': '(не задано)',
|
||||||
|
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||||
|
"Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}",
|
||||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||||
'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)',
|
'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)',
|
||||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||||
@@ -1049,7 +1084,6 @@ export default {
|
|||||||
'Провожу настройку методом тыка...',
|
'Провожу настройку методом тыка...',
|
||||||
'Ищем, какой стороной вставлять флешку...',
|
'Ищем, какой стороной вставлять флешку...',
|
||||||
'Следим, чтобы волшебный дым не вышел из проводов...',
|
'Следим, чтобы волшебный дым не вышел из проводов...',
|
||||||
'Переписываем всё на Rust без особой причины...',
|
|
||||||
'Пытаемся выйти из Vim...',
|
'Пытаемся выйти из Vim...',
|
||||||
'Раскручиваем колесо для хомяка...',
|
'Раскручиваем колесо для хомяка...',
|
||||||
'Это не баг, а фича...',
|
'Это не баг, а фича...',
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export default {
|
|||||||
'No tools available': '没有可用工具',
|
'No tools available': '没有可用工具',
|
||||||
'View or change the approval mode for tool usage':
|
'View or change the approval mode for tool usage':
|
||||||
'查看或更改工具使用的审批模式',
|
'查看或更改工具使用的审批模式',
|
||||||
|
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||||
|
'无效的审批模式 "{{arg}}"。有效模式:{{modes}}',
|
||||||
|
'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"',
|
||||||
'View or change the language setting': '查看或更改语言设置',
|
'View or change the language setting': '查看或更改语言设置',
|
||||||
'change the theme': '更改主题',
|
'change the theme': '更改主题',
|
||||||
'Select Theme': '选择主题',
|
'Select Theme': '选择主题',
|
||||||
@@ -249,6 +252,8 @@ export default {
|
|||||||
', Tab to change focus': ',Tab 切换焦点',
|
', Tab to change focus': ',Tab 切换焦点',
|
||||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||||
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
|
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
|
||||||
|
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||||
|
'不支持在非交互模式下使用命令 "/{{command}}"。',
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Settings Labels
|
// Settings Labels
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -560,6 +565,12 @@ export default {
|
|||||||
'No conversation found to summarize.': '未找到要总结的对话',
|
'No conversation found to summarize.': '未找到要总结的对话',
|
||||||
'Failed to generate project context summary: {{error}}':
|
'Failed to generate project context summary: {{error}}':
|
||||||
'生成项目上下文摘要失败:{{error}}',
|
'生成项目上下文摘要失败:{{error}}',
|
||||||
|
'Saved project summary to {{filePathForDisplay}}.':
|
||||||
|
'项目摘要已保存到 {{filePathForDisplay}}',
|
||||||
|
'Saving project summary...': '正在保存项目摘要...',
|
||||||
|
'Generating project summary...': '正在生成项目摘要...',
|
||||||
|
'Failed to generate summary - no text content received from LLM response':
|
||||||
|
'生成摘要失败 - 未从 LLM 响应中接收到文本内容',
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Commands - Model
|
// Commands - Model
|
||||||
@@ -717,6 +728,21 @@ export default {
|
|||||||
'Authentication timed out. Please try again.': '认证超时。请重试。',
|
'Authentication timed out. Please try again.': '认证超时。请重试。',
|
||||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||||
'正在等待认证...(按 ESC 或 CTRL+C 取消)',
|
'正在等待认证...(按 ESC 或 CTRL+C 取消)',
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
||||||
|
'缺少 OpenAI 兼容认证的 API 密钥。请设置 settings.security.auth.apiKey 或设置 {{envKeyHint}} 环境变量。',
|
||||||
|
'{{envKeyHint}} environment variable not found.':
|
||||||
|
'未找到 {{envKeyHint}} 环境变量。',
|
||||||
|
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
|
||||||
|
'未找到 {{envKeyHint}} 环境变量。请在 .env 文件或系统环境变量中进行设置。',
|
||||||
|
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
|
||||||
|
'未找到 {{envKeyHint}} 环境变量(或设置 settings.security.auth.apiKey)。请在 .env 文件或系统环境变量中进行设置。',
|
||||||
|
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
|
||||||
|
'缺少 OpenAI 兼容认证的 API 密钥。请设置 {{envKeyHint}} 环境变量。',
|
||||||
|
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
|
||||||
|
'Anthropic 提供商缺少必需的 baseUrl,请在 modelProviders[].baseUrl 中配置。',
|
||||||
|
'ANTHROPIC_BASE_URL environment variable not found.':
|
||||||
|
'未找到 ANTHROPIC_BASE_URL 环境变量。',
|
||||||
|
'Invalid auth method selected.': '选择了无效的认证方式。',
|
||||||
'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}',
|
'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}',
|
||||||
'Authenticated successfully with {{authType}} credentials.':
|
'Authenticated successfully with {{authType}} credentials.':
|
||||||
'使用 {{authType}} 凭据成功认证。',
|
'使用 {{authType}} 凭据成功认证。',
|
||||||
@@ -736,6 +762,15 @@ export default {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
'Select Model': '选择模型',
|
'Select Model': '选择模型',
|
||||||
'(Press Esc to close)': '(按 Esc 关闭)',
|
'(Press Esc to close)': '(按 Esc 关闭)',
|
||||||
|
'Current (effective) configuration': '当前(实际生效)配置',
|
||||||
|
AuthType: '认证方式',
|
||||||
|
'API Key': 'API 密钥',
|
||||||
|
unset: '未设置',
|
||||||
|
'(default)': '(默认)',
|
||||||
|
'(set)': '(已设置)',
|
||||||
|
'(not set)': '(未设置)',
|
||||||
|
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||||
|
"无法切换到模型 '{{modelId}}'.\n\n{{error}}",
|
||||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||||
'来自阿里云 ModelStudio 的最新 Qwen Coder 模型(版本:qwen3-coder-plus-2025-09-23)',
|
'来自阿里云 ModelStudio 的最新 Qwen Coder 模型(版本:qwen3-coder-plus-2025-09-23)',
|
||||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import type {
|
|||||||
CLIControlSetModelRequest,
|
CLIControlSetModelRequest,
|
||||||
CLIMcpServerConfig,
|
CLIMcpServerConfig,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { CommandService } from '../../../services/CommandService.js';
|
import { getAvailableCommands } from '../../../nonInteractiveCliCommands.js';
|
||||||
import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js';
|
|
||||||
import {
|
import {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
AuthProviderType,
|
AuthProviderType,
|
||||||
@@ -407,7 +406,7 @@ export class SystemController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load slash command names using CommandService
|
* Load slash command names using getAvailableCommands
|
||||||
*
|
*
|
||||||
* @param signal - AbortSignal to respect for cancellation
|
* @param signal - AbortSignal to respect for cancellation
|
||||||
* @returns Promise resolving to array of slash command names
|
* @returns Promise resolving to array of slash command names
|
||||||
@@ -418,21 +417,14 @@ export class SystemController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const service = await CommandService.create(
|
const commands = await getAvailableCommands(this.context.config, signal);
|
||||||
[new BuiltinCommandLoader(this.context.config)],
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = new Set<string>();
|
// Extract command names and sort
|
||||||
const commands = service.getCommands();
|
return commands.map((cmd) => cmd.name).sort();
|
||||||
for (const command of commands) {
|
|
||||||
names.add(command.name);
|
|
||||||
}
|
|
||||||
return Array.from(names).sort();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check if the error is due to abort
|
// Check if the error is due to abort
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
|||||||
@@ -630,6 +630,67 @@ describe('BaseJsonOutputAdapter', () => {
|
|||||||
|
|
||||||
expect(state.blocks).toHaveLength(0);
|
expect(state.blocks).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should preserve whitespace in thinking content', () => {
|
||||||
|
const state = adapter.exposeCreateMessageState();
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
|
||||||
|
adapter.exposeAppendThinking(
|
||||||
|
state,
|
||||||
|
'',
|
||||||
|
'The user just said "Hello"',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.blocks).toHaveLength(1);
|
||||||
|
expect(state.blocks[0]).toMatchObject({
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'The user just said "Hello"',
|
||||||
|
});
|
||||||
|
// Verify spaces are preserved
|
||||||
|
const block = state.blocks[0] as { thinking: string };
|
||||||
|
expect(block.thinking).toContain('user just');
|
||||||
|
expect(block.thinking).not.toContain('userjust');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve whitespace when appending multiple thinking fragments', () => {
|
||||||
|
const state = adapter.exposeCreateMessageState();
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
|
||||||
|
// Simulate streaming thinking content in fragments
|
||||||
|
adapter.exposeAppendThinking(state, '', 'The user just', null);
|
||||||
|
adapter.exposeAppendThinking(state, '', ' said "Hello"', null);
|
||||||
|
adapter.exposeAppendThinking(
|
||||||
|
state,
|
||||||
|
'',
|
||||||
|
'. This is a simple greeting',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.blocks).toHaveLength(1);
|
||||||
|
const block = state.blocks[0] as { thinking: string };
|
||||||
|
// Verify the complete text with all spaces preserved
|
||||||
|
expect(block.thinking).toBe(
|
||||||
|
'The user just said "Hello". This is a simple greeting',
|
||||||
|
);
|
||||||
|
// Verify specific space preservation
|
||||||
|
expect(block.thinking).toContain('user just ');
|
||||||
|
expect(block.thinking).toContain(' said');
|
||||||
|
expect(block.thinking).toContain('". This');
|
||||||
|
expect(block.thinking).not.toContain('userjust');
|
||||||
|
expect(block.thinking).not.toContain('justsaid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve leading and trailing whitespace in description', () => {
|
||||||
|
const state = adapter.exposeCreateMessageState();
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
|
||||||
|
adapter.exposeAppendThinking(state, '', ' content with spaces ', null);
|
||||||
|
|
||||||
|
expect(state.blocks).toHaveLength(1);
|
||||||
|
const block = state.blocks[0] as { thinking: string };
|
||||||
|
expect(block.thinking).toBe(' content with spaces ');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('appendToolUse', () => {
|
describe('appendToolUse', () => {
|
||||||
|
|||||||
@@ -610,8 +610,6 @@ export abstract class BaseJsonOutputAdapter {
|
|||||||
const errorText = parseAndFormatApiError(
|
const errorText = parseAndFormatApiError(
|
||||||
event.value.error,
|
event.value.error,
|
||||||
this.config.getContentGeneratorConfig()?.authType,
|
this.config.getContentGeneratorConfig()?.authType,
|
||||||
undefined,
|
|
||||||
this.config.getModel(),
|
|
||||||
);
|
);
|
||||||
this.appendText(state, errorText, null);
|
this.appendText(state, errorText, null);
|
||||||
break;
|
break;
|
||||||
@@ -818,9 +816,18 @@ export abstract class BaseJsonOutputAdapter {
|
|||||||
parentToolUseId?: string | null,
|
parentToolUseId?: string | null,
|
||||||
): void {
|
): void {
|
||||||
const actualParentToolUseId = parentToolUseId ?? null;
|
const actualParentToolUseId = parentToolUseId ?? null;
|
||||||
const fragment = [subject?.trim(), description?.trim()]
|
|
||||||
.filter((value) => value && value.length > 0)
|
// Build fragment without trimming to preserve whitespace in streaming content
|
||||||
.join(': ');
|
// Only filter out null/undefined/empty values
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (subject && subject.length > 0) {
|
||||||
|
parts.push(subject);
|
||||||
|
}
|
||||||
|
if (description && description.length > 0) {
|
||||||
|
parts.push(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = parts.join(': ');
|
||||||
if (!fragment) {
|
if (!fragment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,68 @@ describe('StreamJsonOutputAdapter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should preserve whitespace in thinking content (issue #1356)', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: '',
|
||||||
|
description: 'The user just said "Hello"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
const block = message.message.content[0] as {
|
||||||
|
type: string;
|
||||||
|
thinking: string;
|
||||||
|
};
|
||||||
|
expect(block.type).toBe('thinking');
|
||||||
|
expect(block.thinking).toBe('The user just said "Hello"');
|
||||||
|
// Verify spaces are preserved
|
||||||
|
expect(block.thinking).toContain('user just');
|
||||||
|
expect(block.thinking).not.toContain('userjust');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve whitespace when streaming multiple thinking fragments (issue #1356)', () => {
|
||||||
|
// Simulate streaming thinking content in multiple events
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: '',
|
||||||
|
description: 'The user just',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: '',
|
||||||
|
description: ' said "Hello"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: '',
|
||||||
|
description: '. This is a simple greeting',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
const block = message.message.content[0] as {
|
||||||
|
type: string;
|
||||||
|
thinking: string;
|
||||||
|
};
|
||||||
|
expect(block.thinking).toBe(
|
||||||
|
'The user just said "Hello". This is a simple greeting',
|
||||||
|
);
|
||||||
|
// Verify specific spaces are preserved
|
||||||
|
expect(block.thinking).toContain('user just ');
|
||||||
|
expect(block.thinking).toContain(' said');
|
||||||
|
expect(block.thinking).not.toContain('userjust');
|
||||||
|
expect(block.thinking).not.toContain('justsaid');
|
||||||
|
});
|
||||||
|
|
||||||
it('should append tool use from ToolCallRequest events', () => {
|
it('should append tool use from ToolCallRequest events', () => {
|
||||||
adapter.processEvent({
|
adapter.processEvent({
|
||||||
type: GeminiEventType.ToolCallRequest,
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe('runNonInteractive', () => {
|
|||||||
let mockShutdownTelemetry: Mock;
|
let mockShutdownTelemetry: Mock;
|
||||||
let consoleErrorSpy: MockInstance;
|
let consoleErrorSpy: MockInstance;
|
||||||
let processStdoutSpy: MockInstance;
|
let processStdoutSpy: MockInstance;
|
||||||
|
let processStderrSpy: MockInstance;
|
||||||
let mockGeminiClient: {
|
let mockGeminiClient: {
|
||||||
sendMessageStream: Mock;
|
sendMessageStream: Mock;
|
||||||
getChatRecordingService: Mock;
|
getChatRecordingService: Mock;
|
||||||
@@ -86,6 +87,9 @@ describe('runNonInteractive', () => {
|
|||||||
processStdoutSpy = vi
|
processStdoutSpy = vi
|
||||||
.spyOn(process.stdout, 'write')
|
.spyOn(process.stdout, 'write')
|
||||||
.mockImplementation(() => true);
|
.mockImplementation(() => true);
|
||||||
|
processStderrSpy = vi
|
||||||
|
.spyOn(process.stderr, 'write')
|
||||||
|
.mockImplementation(() => true);
|
||||||
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||||
throw new Error(`process.exit(${code}) called`);
|
throw new Error(`process.exit(${code}) called`);
|
||||||
});
|
});
|
||||||
@@ -139,6 +143,8 @@ describe('runNonInteractive', () => {
|
|||||||
setModel: vi.fn(async (model: string) => {
|
setModel: vi.fn(async (model: string) => {
|
||||||
currentModel = model;
|
currentModel = model;
|
||||||
}),
|
}),
|
||||||
|
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||||
|
isInteractive: vi.fn().mockReturnValue(false),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
mockSettings = {
|
mockSettings = {
|
||||||
@@ -292,7 +298,9 @@ describe('runNonInteractive', () => {
|
|||||||
mockConfig,
|
mockConfig,
|
||||||
expect.objectContaining({ name: 'testTool' }),
|
expect.objectContaining({ name: 'testTool' }),
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
undefined,
|
expect.objectContaining({
|
||||||
|
outputUpdateHandler: expect.any(Function),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
// Verify first call has isContinuation: false
|
// Verify first call has isContinuation: false
|
||||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||||
@@ -765,6 +773,52 @@ describe('runNonInteractive', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle API errors in text mode and exit with error code', async () => {
|
||||||
|
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.TEXT);
|
||||||
|
setupMetricsMock();
|
||||||
|
|
||||||
|
// Simulate an API error event (like 401 unauthorized)
|
||||||
|
const apiErrorEvent: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.Error,
|
||||||
|
value: {
|
||||||
|
error: {
|
||||||
|
message: '401 Incorrect API key provided',
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||||
|
createStreamFromEvents([apiErrorEvent]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let thrownError: Error | null = null;
|
||||||
|
try {
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Test input',
|
||||||
|
'prompt-id-api-error',
|
||||||
|
);
|
||||||
|
// Should not reach here
|
||||||
|
expect.fail('Expected error to be thrown');
|
||||||
|
} catch (error) {
|
||||||
|
thrownError = error as Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should throw with the API error message
|
||||||
|
expect(thrownError).toBeTruthy();
|
||||||
|
expect(thrownError?.message).toContain('401');
|
||||||
|
expect(thrownError?.message).toContain('Incorrect API key provided');
|
||||||
|
|
||||||
|
// Verify error was written to stderr
|
||||||
|
expect(processStderrSpy).toHaveBeenCalled();
|
||||||
|
const stderrCalls = processStderrSpy.mock.calls;
|
||||||
|
const errorOutput = stderrCalls.map((call) => call[0]).join('');
|
||||||
|
expect(errorOutput).toContain('401');
|
||||||
|
expect(errorOutput).toContain('Incorrect API key provided');
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle FatalInputError with custom exit code in JSON format', async () => {
|
it('should handle FatalInputError with custom exit code in JSON format', async () => {
|
||||||
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
|
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
|
||||||
setupMetricsMock();
|
setupMetricsMock();
|
||||||
@@ -852,7 +906,7 @@ describe('runNonInteractive', () => {
|
|||||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw FatalInputError if a command requires confirmation', async () => {
|
it('should handle command that requires confirmation by returning early', async () => {
|
||||||
const mockCommand = {
|
const mockCommand = {
|
||||||
name: 'confirm',
|
name: 'confirm',
|
||||||
description: 'a command that needs confirmation',
|
description: 'a command that needs confirmation',
|
||||||
@@ -864,15 +918,16 @@ describe('runNonInteractive', () => {
|
|||||||
};
|
};
|
||||||
mockGetCommands.mockReturnValue([mockCommand]);
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
await expect(
|
await runNonInteractive(
|
||||||
runNonInteractive(
|
mockConfig,
|
||||||
mockConfig,
|
mockSettings,
|
||||||
mockSettings,
|
'/confirm',
|
||||||
'/confirm',
|
'prompt-id-confirm',
|
||||||
'prompt-id-confirm',
|
);
|
||||||
),
|
|
||||||
).rejects.toThrow(
|
// Should write error message to stderr
|
||||||
'Exiting due to a confirmation prompt requested by the command.',
|
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||||
|
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -909,7 +964,30 @@ describe('runNonInteractive', () => {
|
|||||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw for unhandled command result types', async () => {
|
it('should handle known but unsupported slash commands like /help by returning early', async () => {
|
||||||
|
// Mock a built-in command that exists but is not in the allowed list
|
||||||
|
const mockHelpCommand = {
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show help',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||||
|
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'/help',
|
||||||
|
'prompt-id-help',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should write error message to stderr
|
||||||
|
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||||
|
'The command "/help" is not supported in non-interactive mode.\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unhandled command result types by returning early with error', async () => {
|
||||||
const mockCommand = {
|
const mockCommand = {
|
||||||
name: 'noaction',
|
name: 'noaction',
|
||||||
description: 'unhandled type',
|
description: 'unhandled type',
|
||||||
@@ -920,15 +998,16 @@ describe('runNonInteractive', () => {
|
|||||||
};
|
};
|
||||||
mockGetCommands.mockReturnValue([mockCommand]);
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
await expect(
|
await runNonInteractive(
|
||||||
runNonInteractive(
|
mockConfig,
|
||||||
mockConfig,
|
mockSettings,
|
||||||
mockSettings,
|
'/noaction',
|
||||||
'/noaction',
|
'prompt-id-unhandled',
|
||||||
'prompt-id-unhandled',
|
);
|
||||||
),
|
|
||||||
).rejects.toThrow(
|
// Should write error message to stderr
|
||||||
'Exiting due to command result that is not supported in non-interactive mode.',
|
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||||
|
'Unknown command result type: unhandled\n',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1746,4 +1825,84 @@ describe('runNonInteractive', () => {
|
|||||||
{ isContinuation: false },
|
{ isContinuation: false },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should print tool output to console in text mode (non-Task tools)', async () => {
|
||||||
|
// Test that tool output is printed to stdout in text mode
|
||||||
|
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
callId: 'tool-1',
|
||||||
|
name: 'run_in_terminal',
|
||||||
|
args: { command: 'npm outdated' },
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-id-tool-output',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock tool execution with outputUpdateHandler being called
|
||||||
|
mockCoreExecuteToolCall.mockImplementation(
|
||||||
|
async (_config, _request, _signal, options) => {
|
||||||
|
// Simulate tool calling outputUpdateHandler with output chunks
|
||||||
|
if (options?.outputUpdateHandler) {
|
||||||
|
options.outputUpdateHandler('tool-1', 'Package outdated\n');
|
||||||
|
options.outputUpdateHandler('tool-1', 'npm@1.0.0 -> npm@2.0.0\n');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseParts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
id: 'tool-1',
|
||||||
|
name: 'run_in_terminal',
|
||||||
|
response: {
|
||||||
|
output: 'Package outdated\nnpm@1.0.0 -> npm@2.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||||
|
toolCallEvent,
|
||||||
|
{
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||||
|
{ type: GeminiEventType.Content, value: 'Dependencies checked' },
|
||||||
|
{
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockGeminiClient.sendMessageStream
|
||||||
|
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
|
||||||
|
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||||
|
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'Check dependencies',
|
||||||
|
'prompt-id-tool-output',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that executeToolCall was called with outputUpdateHandler
|
||||||
|
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||||
|
mockConfig,
|
||||||
|
expect.objectContaining({ name: 'run_in_terminal' }),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
expect.objectContaining({
|
||||||
|
outputUpdateHandler: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify tool output was written to stdout
|
||||||
|
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
|
||||||
|
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
|
||||||
|
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
|
import type {
|
||||||
|
Config,
|
||||||
|
ToolCallRequestInfo,
|
||||||
|
ToolResultDisplay,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||||
import type { LoadedSettings } from './config/settings.js';
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +46,55 @@ import {
|
|||||||
computeUsageFromMetrics,
|
computeUsageFromMetrics,
|
||||||
} from './utils/nonInteractiveHelpers.js';
|
} from './utils/nonInteractiveHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a final message for slash command results.
|
||||||
|
* Note: systemMessage should already be emitted before calling this function.
|
||||||
|
*/
|
||||||
|
async function emitNonInteractiveFinalMessage(params: {
|
||||||
|
message: string;
|
||||||
|
isError: boolean;
|
||||||
|
adapter?: JsonOutputAdapterInterface;
|
||||||
|
config: Config;
|
||||||
|
startTimeMs: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { message, isError, adapter, config } = params;
|
||||||
|
|
||||||
|
if (!adapter) {
|
||||||
|
// Text output mode: write directly to stdout/stderr
|
||||||
|
const target = isError ? process.stderr : process.stdout;
|
||||||
|
target.write(`${message}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON output mode: emit assistant message and result
|
||||||
|
// (systemMessage should already be emitted by caller)
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: message,
|
||||||
|
} as unknown as Parameters<JsonOutputAdapterInterface['processEvent']>[0]);
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
|
const usage = computeUsageFromMetrics(metrics);
|
||||||
|
const outputFormat = config.getOutputFormat();
|
||||||
|
const stats =
|
||||||
|
outputFormat === OutputFormat.JSON
|
||||||
|
? uiTelemetryService.getMetrics()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
adapter.emitResult({
|
||||||
|
isError,
|
||||||
|
durationMs: Date.now() - params.startTimeMs,
|
||||||
|
apiDurationMs: 0,
|
||||||
|
numTurns: 0,
|
||||||
|
errorMessage: isError ? message : undefined,
|
||||||
|
usage,
|
||||||
|
stats,
|
||||||
|
summary: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides optional overrides for `runNonInteractive` execution.
|
* Provides optional overrides for `runNonInteractive` execution.
|
||||||
*
|
*
|
||||||
@@ -115,6 +168,16 @@ export async function runNonInteractive(
|
|||||||
process.on('SIGINT', shutdownHandler);
|
process.on('SIGINT', shutdownHandler);
|
||||||
process.on('SIGTERM', shutdownHandler);
|
process.on('SIGTERM', shutdownHandler);
|
||||||
|
|
||||||
|
// Emit systemMessage first (always the first message in JSON mode)
|
||||||
|
if (adapter) {
|
||||||
|
const systemMessage = await buildSystemMessage(
|
||||||
|
config,
|
||||||
|
sessionId,
|
||||||
|
permissionMode,
|
||||||
|
);
|
||||||
|
adapter.emitMessage(systemMessage);
|
||||||
|
}
|
||||||
|
|
||||||
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
|
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
|
||||||
options.userMessage,
|
options.userMessage,
|
||||||
);
|
);
|
||||||
@@ -128,10 +191,45 @@ export async function runNonInteractive(
|
|||||||
config,
|
config,
|
||||||
settings,
|
settings,
|
||||||
);
|
);
|
||||||
if (slashCommandResult) {
|
switch (slashCommandResult.type) {
|
||||||
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
case 'submit_prompt':
|
||||||
initialPartList = slashCommandResult as PartListUnion;
|
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
||||||
slashHandled = true;
|
initialPartList = slashCommandResult.content;
|
||||||
|
slashHandled = true;
|
||||||
|
break;
|
||||||
|
case 'message': {
|
||||||
|
// systemMessage already emitted above
|
||||||
|
await emitNonInteractiveFinalMessage({
|
||||||
|
message: slashCommandResult.content,
|
||||||
|
isError: slashCommandResult.messageType === 'error',
|
||||||
|
adapter,
|
||||||
|
config,
|
||||||
|
startTimeMs: startTime,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'stream_messages':
|
||||||
|
throw new FatalInputError(
|
||||||
|
'Stream messages mode is not supported in non-interactive CLI',
|
||||||
|
);
|
||||||
|
case 'unsupported': {
|
||||||
|
await emitNonInteractiveFinalMessage({
|
||||||
|
message: slashCommandResult.reason,
|
||||||
|
isError: true,
|
||||||
|
adapter,
|
||||||
|
config,
|
||||||
|
startTimeMs: startTime,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'no_command':
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = slashCommandResult;
|
||||||
|
throw new FatalInputError(
|
||||||
|
`Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,15 +261,6 @@ export async function runNonInteractive(
|
|||||||
const initialParts = normalizePartList(initialPartList);
|
const initialParts = normalizePartList(initialPartList);
|
||||||
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
||||||
|
|
||||||
if (adapter) {
|
|
||||||
const systemMessage = await buildSystemMessage(
|
|
||||||
config,
|
|
||||||
sessionId,
|
|
||||||
permissionMode,
|
|
||||||
);
|
|
||||||
adapter.emitMessage(systemMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
let isFirstTurn = true;
|
let isFirstTurn = true;
|
||||||
while (true) {
|
while (true) {
|
||||||
turnCount++;
|
turnCount++;
|
||||||
@@ -221,10 +310,10 @@ export async function runNonInteractive(
|
|||||||
const errorText = parseAndFormatApiError(
|
const errorText = parseAndFormatApiError(
|
||||||
event.value.error,
|
event.value.error,
|
||||||
config.getContentGeneratorConfig()?.authType,
|
config.getContentGeneratorConfig()?.authType,
|
||||||
undefined,
|
|
||||||
config.getModel(),
|
|
||||||
);
|
);
|
||||||
process.stderr.write(`${errorText}\n`);
|
process.stderr.write(`${errorText}\n`);
|
||||||
|
// Throw error to exit with non-zero code
|
||||||
|
throw new Error(errorText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +339,7 @@ export async function runNonInteractive(
|
|||||||
? options.controlService.permission.getToolCallUpdateCallback()
|
? options.controlService.permission.getToolCallUpdateCallback()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Only pass outputUpdateHandler for Task tool
|
// Create output handler for Task tool (for subagent execution)
|
||||||
const isTaskTool = finalRequestInfo.name === 'task';
|
const isTaskTool = finalRequestInfo.name === 'task';
|
||||||
const taskToolProgress = isTaskTool
|
const taskToolProgress = isTaskTool
|
||||||
? createTaskToolProgressHandler(
|
? createTaskToolProgressHandler(
|
||||||
@@ -260,20 +349,41 @@ export async function runNonInteractive(
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const taskToolProgressHandler = taskToolProgress?.handler;
|
const taskToolProgressHandler = taskToolProgress?.handler;
|
||||||
|
|
||||||
|
// Create output handler for non-Task tools in text mode (for console output)
|
||||||
|
const nonTaskOutputHandler =
|
||||||
|
!isTaskTool && !adapter
|
||||||
|
? (callId: string, outputChunk: ToolResultDisplay) => {
|
||||||
|
// Print tool output to console in text mode
|
||||||
|
if (typeof outputChunk === 'string') {
|
||||||
|
process.stdout.write(outputChunk);
|
||||||
|
} else if (
|
||||||
|
outputChunk &&
|
||||||
|
typeof outputChunk === 'object' &&
|
||||||
|
'ansiOutput' in outputChunk
|
||||||
|
) {
|
||||||
|
// Handle ANSI output - just print as string for now
|
||||||
|
process.stdout.write(String(outputChunk.ansiOutput));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Combine output handlers
|
||||||
|
const outputUpdateHandler =
|
||||||
|
taskToolProgressHandler || nonTaskOutputHandler;
|
||||||
|
|
||||||
const toolResponse = await executeToolCall(
|
const toolResponse = await executeToolCall(
|
||||||
config,
|
config,
|
||||||
finalRequestInfo,
|
finalRequestInfo,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
isTaskTool && taskToolProgressHandler
|
outputUpdateHandler || toolCallUpdateCallback
|
||||||
? {
|
? {
|
||||||
outputUpdateHandler: taskToolProgressHandler,
|
...(outputUpdateHandler && { outputUpdateHandler }),
|
||||||
onToolCallsUpdate: toolCallUpdateCallback,
|
...(toolCallUpdateCallback && {
|
||||||
}
|
|
||||||
: toolCallUpdateCallback
|
|
||||||
? {
|
|
||||||
onToolCallsUpdate: toolCallUpdateCallback,
|
onToolCallsUpdate: toolCallUpdateCallback,
|
||||||
}
|
}),
|
||||||
: undefined,
|
}
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||||
|
|||||||
242
packages/cli/src/nonInteractiveCliCommands.test.ts
Normal file
242
packages/cli/src/nonInteractiveCliCommands.test.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||||
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
|
import { CommandKind } from './ui/commands/types.js';
|
||||||
|
|
||||||
|
// Mock the CommandService
|
||||||
|
const mockGetCommands = vi.hoisted(() => vi.fn());
|
||||||
|
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('./services/CommandService.js', () => ({
|
||||||
|
CommandService: {
|
||||||
|
create: mockCommandServiceCreate,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('handleSlashCommand', () => {
|
||||||
|
let mockConfig: Config;
|
||||||
|
let mockSettings: LoadedSettings;
|
||||||
|
let abortController: AbortController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCommandServiceCreate.mockResolvedValue({
|
||||||
|
getCommands: mockGetCommands,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockConfig = {
|
||||||
|
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||||
|
isInteractive: vi.fn().mockReturnValue(false),
|
||||||
|
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||||
|
getFolderTrustFeature: vi.fn().mockReturnValue(false),
|
||||||
|
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||||
|
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||||
|
storage: {},
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
mockSettings = {
|
||||||
|
system: { path: '', settings: {} },
|
||||||
|
systemDefaults: { path: '', settings: {} },
|
||||||
|
user: { path: '', settings: {} },
|
||||||
|
workspace: { path: '', settings: {} },
|
||||||
|
} as LoadedSettings;
|
||||||
|
|
||||||
|
abortController = new AbortController();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return no_command for non-slash input', async () => {
|
||||||
|
const result = await handleSlashCommand(
|
||||||
|
'regular text',
|
||||||
|
abortController,
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe('no_command');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return no_command for unknown slash commands', async () => {
|
||||||
|
mockGetCommands.mockReturnValue([]);
|
||||||
|
|
||||||
|
const result = await handleSlashCommand(
|
||||||
|
'/unknowncommand',
|
||||||
|
abortController,
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe('no_command');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unsupported for known built-in commands not in allowed list', async () => {
|
||||||
|
const mockHelpCommand = {
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show help',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||||
|
|
||||||
|
const result = await handleSlashCommand(
|
||||||
|
'/help',
|
||||||
|
abortController,
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
[], // Empty allowed list
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe('unsupported');
|
||||||
|
if (result.type === 'unsupported') {
|
||||||
|
expect(result.reason).toContain('/help');
|
||||||
|
expect(result.reason).toContain('not supported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unsupported for /help when using default allowed list', async () => {
|
||||||
|
const mockHelpCommand = {
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show help',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||||
|
|
||||||
|
const result = await handleSlashCommand(
|
||||||
|
'/help',
|
||||||
|
abortController,
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
// Default allowed list: ['init', 'summary', 'compress']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe('unsupported');
|
||||||
|
if (result.type === 'unsupported') {
|
||||||
|
expect(result.reason).toBe(
|
||||||
|
'The command "/help" is not supported in non-interactive mode.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute allowed built-in commands', async () => {
|
||||||
|
const mockInitCommand = {
|
||||||
|
name: 'init',
|
||||||
|
description: 'Initialize project',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: vi.fn().mockResolvedValue({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Project initialized',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockInitCommand]);
|
||||||
|
|
||||||
|
const result = await handleSlashCommand(
|
||||||
|
'/init',
|
||||||
|
abortController,
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
['init'], // init is in the allowed list
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.content).toBe('Project initialized');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute file commands regardless of allowed list', async () => {
|
||||||
|
const mockFileCommand = {
|
||||||
|
name: 'custom',
|
||||||
|
description: 'Custom file command',
|
||||||
|
kind: CommandKind.FILE,
|
||||||
|
action: vi.fn().mockResolvedValue({
|
||||||
|
type: 'submit_prompt',
|
||||||
|
content: [{ text: 'Custom prompt' }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||||
|
|
||||||
|
const result = await handleSlashCommand(
|
||||||
|
'/custom',
|
||||||
|
abortController,
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
[], // Empty allowed list, but FILE commands should still work
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe('submit_prompt');
|
||||||
|
if (result.type === 'submit_prompt') {
|
||||||
|
expect(result.content).toEqual([{ text: 'Custom prompt' }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unsupported for other built-in commands like /quit', async () => {
|
||||||
|
const mockQuitCommand = {
|
||||||
|
name: 'quit',
|
||||||
|
description: 'Quit application',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockQuitCommand]);
|
||||||
|
|
||||||
|
const result = await handleSlashCommand(
|
||||||
|
'/quit',
|
||||||
|
abortController,
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe('unsupported');
|
||||||
|
if (result.type === 'unsupported') {
|
||||||
|
expect(result.reason).toContain('/quit');
|
||||||
|
expect(result.reason).toContain('not supported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle command with no action', async () => {
|
||||||
|
const mockCommand = {
|
||||||
|
name: 'noaction',
|
||||||
|
description: 'Command without action',
|
||||||
|
kind: CommandKind.FILE,
|
||||||
|
// No action property
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
|
const result = await handleSlashCommand(
|
||||||
|
'/noaction',
|
||||||
|
abortController,
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe('no_command');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return message when command returns void', async () => {
|
||||||
|
const mockCommand = {
|
||||||
|
name: 'voidcmd',
|
||||||
|
description: 'Command that returns void',
|
||||||
|
kind: CommandKind.FILE,
|
||||||
|
action: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
mockGetCommands.mockReturnValue([mockCommand]);
|
||||||
|
|
||||||
|
const result = await handleSlashCommand(
|
||||||
|
'/voidcmd',
|
||||||
|
abortController,
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.content).toBe('Command executed successfully.');
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
import type { PartListUnion } from '@google/genai';
|
import type { PartListUnion } from '@google/genai';
|
||||||
import { parseSlashCommand } from './utils/commands.js';
|
import { parseSlashCommand } from './utils/commands.js';
|
||||||
import {
|
import {
|
||||||
FatalInputError,
|
|
||||||
Logger,
|
Logger,
|
||||||
uiTelemetryService,
|
uiTelemetryService,
|
||||||
type Config,
|
type Config,
|
||||||
@@ -19,10 +18,164 @@ import {
|
|||||||
CommandKind,
|
CommandKind,
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
|
type SlashCommandActionReturn,
|
||||||
} from './ui/commands/types.js';
|
} from './ui/commands/types.js';
|
||||||
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
||||||
import type { LoadedSettings } from './config/settings.js';
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
||||||
|
import { t } from './i18n/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in commands that are allowed in non-interactive modes (CLI and ACP).
|
||||||
|
* Only safe, read-only commands that don't require interactive UI.
|
||||||
|
*
|
||||||
|
* These commands are:
|
||||||
|
* - init: Initialize project configuration
|
||||||
|
* - summary: Generate session summary
|
||||||
|
* - compress: Compress conversation history
|
||||||
|
*/
|
||||||
|
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
|
||||||
|
'init',
|
||||||
|
'summary',
|
||||||
|
'compress',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of handling a slash command in non-interactive mode.
|
||||||
|
*
|
||||||
|
* Supported types:
|
||||||
|
* - 'submit_prompt': Submits content to the model (supports all modes)
|
||||||
|
* - 'message': Returns a single message (supports non-interactive JSON/text only)
|
||||||
|
* - 'stream_messages': Streams multiple messages (supports ACP only)
|
||||||
|
* - 'unsupported': Command cannot be executed in this mode
|
||||||
|
* - 'no_command': No command was found or executed
|
||||||
|
*/
|
||||||
|
export type NonInteractiveSlashCommandResult =
|
||||||
|
| {
|
||||||
|
type: 'submit_prompt';
|
||||||
|
content: PartListUnion;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'message';
|
||||||
|
messageType: 'info' | 'error';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'stream_messages';
|
||||||
|
messages: AsyncGenerator<
|
||||||
|
{ messageType: 'info' | 'error'; content: string },
|
||||||
|
void,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'unsupported';
|
||||||
|
reason: string;
|
||||||
|
originalType: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'no_command';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult.
|
||||||
|
*
|
||||||
|
* Only the following result types are supported in non-interactive mode:
|
||||||
|
* - submit_prompt: Submits content to the model (all modes)
|
||||||
|
* - message: Returns a single message (non-interactive JSON/text only)
|
||||||
|
* - stream_messages: Streams multiple messages (ACP only)
|
||||||
|
*
|
||||||
|
* All other result types are converted to 'unsupported'.
|
||||||
|
*
|
||||||
|
* @param result The result from executing a slash command action
|
||||||
|
* @returns A NonInteractiveSlashCommandResult describing the outcome
|
||||||
|
*/
|
||||||
|
function handleCommandResult(
|
||||||
|
result: SlashCommandActionReturn,
|
||||||
|
): NonInteractiveSlashCommandResult {
|
||||||
|
switch (result.type) {
|
||||||
|
case 'submit_prompt':
|
||||||
|
return {
|
||||||
|
type: 'submit_prompt',
|
||||||
|
content: result.content,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'message':
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: result.messageType,
|
||||||
|
content: result.content,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'stream_messages':
|
||||||
|
return {
|
||||||
|
type: 'stream_messages',
|
||||||
|
messages: result.messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently return types below are never generated due to the
|
||||||
|
* whitelist of allowed slash commands in ACP and non-interactive mode.
|
||||||
|
* We'll try to add more supported return types in the future.
|
||||||
|
*/
|
||||||
|
case 'tool':
|
||||||
|
return {
|
||||||
|
type: 'unsupported',
|
||||||
|
reason:
|
||||||
|
'Tool execution from slash commands is not supported in non-interactive mode.',
|
||||||
|
originalType: 'tool',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'quit':
|
||||||
|
return {
|
||||||
|
type: 'unsupported',
|
||||||
|
reason:
|
||||||
|
'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.',
|
||||||
|
originalType: 'quit',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'dialog':
|
||||||
|
return {
|
||||||
|
type: 'unsupported',
|
||||||
|
reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`,
|
||||||
|
originalType: 'dialog',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'load_history':
|
||||||
|
return {
|
||||||
|
type: 'unsupported',
|
||||||
|
reason:
|
||||||
|
'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.',
|
||||||
|
originalType: 'load_history',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'confirm_shell_commands':
|
||||||
|
return {
|
||||||
|
type: 'unsupported',
|
||||||
|
reason:
|
||||||
|
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
|
||||||
|
originalType: 'confirm_shell_commands',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'confirm_action':
|
||||||
|
return {
|
||||||
|
type: 'unsupported',
|
||||||
|
reason:
|
||||||
|
'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.',
|
||||||
|
originalType: 'confirm_action',
|
||||||
|
};
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness check
|
||||||
|
const _exhaustive: never = result;
|
||||||
|
return {
|
||||||
|
type: 'unsupported',
|
||||||
|
reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`,
|
||||||
|
originalType: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters commands based on the allowed built-in command names.
|
* Filters commands based on the allowed built-in command names.
|
||||||
@@ -62,122 +215,146 @@ function filterCommandsForNonInteractive(
|
|||||||
* @param config The configuration object
|
* @param config The configuration object
|
||||||
* @param settings The loaded settings
|
* @param settings The loaded settings
|
||||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||||
* allowed. If not provided or empty, only file commands are available.
|
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
|
||||||
* @returns A Promise that resolves to `PartListUnion` if a valid command is
|
* Pass an empty array to only allow file commands.
|
||||||
* found and results in a prompt, or `undefined` otherwise.
|
* @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing
|
||||||
* @throws {FatalInputError} if the command result is not supported in
|
* the outcome of the command execution.
|
||||||
* non-interactive mode.
|
|
||||||
*/
|
*/
|
||||||
export const handleSlashCommand = async (
|
export const handleSlashCommand = async (
|
||||||
rawQuery: string,
|
rawQuery: string,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
config: Config,
|
config: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
allowedBuiltinCommandNames?: string[],
|
allowedBuiltinCommandNames: string[] = [
|
||||||
): Promise<PartListUnion | undefined> => {
|
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||||
|
],
|
||||||
|
): Promise<NonInteractiveSlashCommandResult> => {
|
||||||
const trimmed = rawQuery.trim();
|
const trimmed = rawQuery.trim();
|
||||||
if (!trimmed.startsWith('/')) {
|
if (!trimmed.startsWith('/')) {
|
||||||
return;
|
return { type: 'no_command' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAcpMode = config.getExperimentalZedIntegration();
|
||||||
|
const isInteractive = config.isInteractive();
|
||||||
|
|
||||||
|
const executionMode = isAcpMode
|
||||||
|
? 'acp'
|
||||||
|
: isInteractive
|
||||||
|
? 'interactive'
|
||||||
|
: 'non_interactive';
|
||||||
|
|
||||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||||
|
|
||||||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
// Load all commands to check if the command exists but is not allowed
|
||||||
const loaders =
|
const allLoaders = [
|
||||||
allowedBuiltinSet.size > 0
|
new BuiltinCommandLoader(config),
|
||||||
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
|
new FileCommandLoader(config),
|
||||||
: [new FileCommandLoader(config)];
|
];
|
||||||
|
|
||||||
const commandService = await CommandService.create(
|
const commandService = await CommandService.create(
|
||||||
loaders,
|
allLoaders,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
);
|
);
|
||||||
const commands = commandService.getCommands();
|
const allCommands = commandService.getCommands();
|
||||||
const filteredCommands = filterCommandsForNonInteractive(
|
const filteredCommands = filterCommandsForNonInteractive(
|
||||||
commands,
|
allCommands,
|
||||||
allowedBuiltinSet,
|
allowedBuiltinSet,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// First, try to parse with filtered commands
|
||||||
const { commandToExecute, args } = parseSlashCommand(
|
const { commandToExecute, args } = parseSlashCommand(
|
||||||
rawQuery,
|
rawQuery,
|
||||||
filteredCommands,
|
filteredCommands,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (commandToExecute) {
|
if (!commandToExecute) {
|
||||||
if (commandToExecute.action) {
|
// Check if this is a known command that's just not allowed
|
||||||
// Not used by custom commands but may be in the future.
|
const { commandToExecute: knownCommand } = parseSlashCommand(
|
||||||
const sessionStats: SessionStatsState = {
|
rawQuery,
|
||||||
sessionId: config?.getSessionId(),
|
allCommands,
|
||||||
sessionStartTime: new Date(),
|
);
|
||||||
metrics: uiTelemetryService.getMetrics(),
|
|
||||||
lastPromptTokenCount: 0,
|
if (knownCommand) {
|
||||||
promptCount: 1,
|
// Command exists but is not allowed in non-interactive mode
|
||||||
|
return {
|
||||||
|
type: 'unsupported',
|
||||||
|
reason: t(
|
||||||
|
'The command "/{{command}}" is not supported in non-interactive mode.',
|
||||||
|
{ command: knownCommand.name },
|
||||||
|
),
|
||||||
|
originalType: 'filtered_command',
|
||||||
};
|
};
|
||||||
|
|
||||||
const logger = new Logger(config?.getSessionId() || '', config?.storage);
|
|
||||||
|
|
||||||
const context: CommandContext = {
|
|
||||||
services: {
|
|
||||||
config,
|
|
||||||
settings,
|
|
||||||
git: undefined,
|
|
||||||
logger,
|
|
||||||
},
|
|
||||||
ui: createNonInteractiveUI(),
|
|
||||||
session: {
|
|
||||||
stats: sessionStats,
|
|
||||||
sessionShellAllowlist: new Set(),
|
|
||||||
},
|
|
||||||
invocation: {
|
|
||||||
raw: trimmed,
|
|
||||||
name: commandToExecute.name,
|
|
||||||
args,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await commandToExecute.action(context, args);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
switch (result.type) {
|
|
||||||
case 'submit_prompt':
|
|
||||||
return result.content;
|
|
||||||
case 'confirm_shell_commands':
|
|
||||||
// This result indicates a command attempted to confirm shell commands.
|
|
||||||
// However note that currently, ShellTool is excluded in non-interactive
|
|
||||||
// mode unless 'YOLO mode' is active, so confirmation actually won't
|
|
||||||
// occur because of YOLO mode.
|
|
||||||
// This ensures that if a command *does* request confirmation (e.g.
|
|
||||||
// in the future with more granular permissions), it's handled appropriately.
|
|
||||||
throw new FatalInputError(
|
|
||||||
'Exiting due to a confirmation prompt requested by the command.',
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
throw new FatalInputError(
|
|
||||||
'Exiting due to command result that is not supported in non-interactive mode.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { type: 'no_command' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
if (!commandToExecute.action) {
|
||||||
|
return { type: 'no_command' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not used by custom commands but may be in the future.
|
||||||
|
const sessionStats: SessionStatsState = {
|
||||||
|
sessionId: config?.getSessionId(),
|
||||||
|
sessionStartTime: new Date(),
|
||||||
|
metrics: uiTelemetryService.getMetrics(),
|
||||||
|
lastPromptTokenCount: 0,
|
||||||
|
promptCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = new Logger(config?.getSessionId() || '', config?.storage);
|
||||||
|
|
||||||
|
const context: CommandContext = {
|
||||||
|
executionMode,
|
||||||
|
services: {
|
||||||
|
config,
|
||||||
|
settings,
|
||||||
|
git: undefined,
|
||||||
|
logger,
|
||||||
|
},
|
||||||
|
ui: createNonInteractiveUI(),
|
||||||
|
session: {
|
||||||
|
stats: sessionStats,
|
||||||
|
sessionShellAllowlist: new Set(),
|
||||||
|
},
|
||||||
|
invocation: {
|
||||||
|
raw: trimmed,
|
||||||
|
name: commandToExecute.name,
|
||||||
|
args,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await commandToExecute.action(context, args);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
// Command executed but returned no result (e.g., void return)
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Command executed successfully.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different result types
|
||||||
|
return handleCommandResult(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves all available slash commands for the current configuration.
|
* Retrieves all available slash commands for the current configuration.
|
||||||
*
|
*
|
||||||
* @param config The configuration object
|
* @param config The configuration object
|
||||||
* @param settings The loaded settings
|
|
||||||
* @param abortSignal Signal to cancel the loading process
|
* @param abortSignal Signal to cancel the loading process
|
||||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||||
* allowed. If not provided or empty, only file commands are available.
|
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
|
||||||
|
* Pass an empty array to only include file commands.
|
||||||
* @returns A Promise that resolves to an array of SlashCommand objects
|
* @returns A Promise that resolves to an array of SlashCommand objects
|
||||||
*/
|
*/
|
||||||
export const getAvailableCommands = async (
|
export const getAvailableCommands = async (
|
||||||
config: Config,
|
config: Config,
|
||||||
settings: LoadedSettings,
|
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
allowedBuiltinCommandNames?: string[],
|
allowedBuiltinCommandNames: string[] = [
|
||||||
|
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||||
|
],
|
||||||
): Promise<SlashCommand[]> => {
|
): Promise<SlashCommand[]> => {
|
||||||
try {
|
try {
|
||||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const mockPrompt = {
|
|||||||
{ name: 'trail', required: false, description: "The animal's trail." },
|
{ name: 'trail', required: false, description: "The animal's trail." },
|
||||||
],
|
],
|
||||||
invoke: vi.fn().mockResolvedValue({
|
invoke: vi.fn().mockResolvedValue({
|
||||||
messages: [{ content: { text: 'Hello, world!' } }],
|
messages: [{ content: { type: 'text', text: 'Hello, world!' } }],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,10 @@ export class McpPromptLoader implements ICommandLoader {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.messages?.[0]?.content?.['text']) {
|
const firstMessage = result.messages?.[0];
|
||||||
|
const content = firstMessage?.content;
|
||||||
|
|
||||||
|
if (content?.type !== 'text') {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
@@ -134,7 +137,7 @@ export class McpPromptLoader implements ICommandLoader {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'submit_prompt',
|
type: 'submit_prompt',
|
||||||
content: JSON.stringify(result.messages[0].content.text),
|
content: JSON.stringify(content.text),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ describe('ShellProcessor', () => {
|
|||||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||||
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
||||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||||
|
getAllowedTools: vi.fn().mockReturnValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
context = createMockCommandContext({
|
context = createMockCommandContext({
|
||||||
@@ -196,6 +197,35 @@ describe('ShellProcessor', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should NOT throw ConfirmationRequiredError when a command matches allowedTools', async () => {
|
||||||
|
const processor = new ShellProcessor('test-command');
|
||||||
|
const prompt: PromptPipelineContent = createPromptPipelineContent(
|
||||||
|
'Do something dangerous: !{rm -rf /}',
|
||||||
|
);
|
||||||
|
mockCheckCommandPermissions.mockReturnValue({
|
||||||
|
allAllowed: false,
|
||||||
|
disallowedCommands: ['rm -rf /'],
|
||||||
|
});
|
||||||
|
(mockConfig.getAllowedTools as Mock).mockReturnValue([
|
||||||
|
'ShellTool(rm -rf /)',
|
||||||
|
]);
|
||||||
|
mockShellExecute.mockReturnValue({
|
||||||
|
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await processor.process(prompt, context);
|
||||||
|
|
||||||
|
expect(mockShellExecute).toHaveBeenCalledWith(
|
||||||
|
'rm -rf /',
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Object),
|
||||||
|
false,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => {
|
it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => {
|
||||||
const processor = new ShellProcessor('test-command');
|
const processor = new ShellProcessor('test-command');
|
||||||
const prompt: PromptPipelineContent = createPromptPipelineContent(
|
const prompt: PromptPipelineContent = createPromptPipelineContent(
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
import {
|
import {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
checkCommandPermissions,
|
checkCommandPermissions,
|
||||||
|
doesToolInvocationMatch,
|
||||||
escapeShellArg,
|
escapeShellArg,
|
||||||
getShellConfiguration,
|
getShellConfiguration,
|
||||||
ShellExecutionService,
|
ShellExecutionService,
|
||||||
flatMapTextParts,
|
flatMapTextParts,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import type { AnyToolInvocation } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
import type { CommandContext } from '../../ui/commands/types.js';
|
import type { CommandContext } from '../../ui/commands/types.js';
|
||||||
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
|
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
|
||||||
@@ -124,6 +126,15 @@ export class ShellProcessor implements IPromptProcessor {
|
|||||||
// Security check on the final, escaped command string.
|
// Security check on the final, escaped command string.
|
||||||
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
||||||
checkCommandPermissions(command, config, sessionShellAllowlist);
|
checkCommandPermissions(command, config, sessionShellAllowlist);
|
||||||
|
const allowedTools = config.getAllowedTools() || [];
|
||||||
|
const invocation = {
|
||||||
|
params: { command },
|
||||||
|
} as AnyToolInvocation;
|
||||||
|
const isAllowedBySettings = doesToolInvocationMatch(
|
||||||
|
'run_shell_command',
|
||||||
|
invocation,
|
||||||
|
allowedTools,
|
||||||
|
);
|
||||||
|
|
||||||
if (!allAllowed) {
|
if (!allAllowed) {
|
||||||
if (isHardDenial) {
|
if (isHardDenial) {
|
||||||
@@ -132,10 +143,17 @@ export class ShellProcessor implements IPromptProcessor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not a hard denial, respect YOLO mode and auto-approve.
|
// If the command is allowed by settings, skip confirmation.
|
||||||
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
|
if (isAllowedBySettings) {
|
||||||
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If not a hard denial, respect YOLO mode and auto-approve.
|
||||||
|
if (config.getApprovalMode() === ApprovalMode.YOLO) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
import { render } from 'ink-testing-library';
|
import { render } from 'ink-testing-library';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import { LoadedSettings } from '../config/settings.js';
|
import { LoadedSettings } from '../config/settings.js';
|
||||||
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
||||||
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
||||||
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
||||||
|
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
|
||||||
|
|
||||||
const mockSettings = new LoadedSettings(
|
const mockSettings = new LoadedSettings(
|
||||||
{ path: '', settings: {}, originalSettings: {} },
|
{ path: '', settings: {}, originalSettings: {} },
|
||||||
@@ -22,14 +24,24 @@ const mockSettings = new LoadedSettings(
|
|||||||
|
|
||||||
export const renderWithProviders = (
|
export const renderWithProviders = (
|
||||||
component: React.ReactElement,
|
component: React.ReactElement,
|
||||||
{ shellFocus = true, settings = mockSettings } = {},
|
{
|
||||||
|
shellFocus = true,
|
||||||
|
settings = mockSettings,
|
||||||
|
config = undefined,
|
||||||
|
}: {
|
||||||
|
shellFocus?: boolean;
|
||||||
|
settings?: LoadedSettings;
|
||||||
|
config?: Config;
|
||||||
|
} = {},
|
||||||
): ReturnType<typeof render> =>
|
): ReturnType<typeof render> =>
|
||||||
render(
|
render(
|
||||||
<SettingsContext.Provider value={settings}>
|
<SettingsContext.Provider value={settings}>
|
||||||
<ShellFocusContext.Provider value={shellFocus}>
|
<ConfigContext.Provider value={config}>
|
||||||
<KeypressProvider kittyProtocolEnabled={true}>
|
<ShellFocusContext.Provider value={shellFocus}>
|
||||||
{component}
|
<KeypressProvider kittyProtocolEnabled={true}>
|
||||||
</KeypressProvider>
|
{component}
|
||||||
</ShellFocusContext.Provider>
|
</KeypressProvider>
|
||||||
|
</ShellFocusContext.Provider>
|
||||||
|
</ConfigContext.Provider>
|
||||||
</SettingsContext.Provider>,
|
</SettingsContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { LoadedSettings } from '../config/settings.js';
|
import type { LoadedSettings } from '../config/settings.js';
|
||||||
import type { InitializationResult } from '../core/initializer.js';
|
import type { InitializationResult } from '../core/initializer.js';
|
||||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
|
||||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||||
import {
|
import {
|
||||||
UIActionsContext,
|
UIActionsContext,
|
||||||
@@ -56,7 +55,6 @@ vi.mock('./App.js', () => ({
|
|||||||
App: TestContextConsumer,
|
App: TestContextConsumer,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./hooks/useQuotaAndFallback.js');
|
|
||||||
vi.mock('./hooks/useHistoryManager.js');
|
vi.mock('./hooks/useHistoryManager.js');
|
||||||
vi.mock('./hooks/useThemeCommand.js');
|
vi.mock('./hooks/useThemeCommand.js');
|
||||||
vi.mock('./auth/useAuth.js');
|
vi.mock('./auth/useAuth.js');
|
||||||
@@ -122,7 +120,6 @@ describe('AppContainer State Management', () => {
|
|||||||
let mockInitResult: InitializationResult;
|
let mockInitResult: InitializationResult;
|
||||||
|
|
||||||
// Create typed mocks for all hooks
|
// Create typed mocks for all hooks
|
||||||
const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock;
|
|
||||||
const mockedUseHistory = useHistory as Mock;
|
const mockedUseHistory = useHistory as Mock;
|
||||||
const mockedUseThemeCommand = useThemeCommand as Mock;
|
const mockedUseThemeCommand = useThemeCommand as Mock;
|
||||||
const mockedUseAuthCommand = useAuthCommand as Mock;
|
const mockedUseAuthCommand = useAuthCommand as Mock;
|
||||||
@@ -164,10 +161,6 @@ describe('AppContainer State Management', () => {
|
|||||||
capturedUIActions = null!;
|
capturedUIActions = null!;
|
||||||
|
|
||||||
// **Provide a default return value for EVERY mocked hook.**
|
// **Provide a default return value for EVERY mocked hook.**
|
||||||
mockedUseQuotaAndFallback.mockReturnValue({
|
|
||||||
proQuotaRequest: null,
|
|
||||||
handleProQuotaChoice: vi.fn(),
|
|
||||||
});
|
|
||||||
mockedUseHistory.mockReturnValue({
|
mockedUseHistory.mockReturnValue({
|
||||||
history: [],
|
history: [],
|
||||||
addItem: vi.fn(),
|
addItem: vi.fn(),
|
||||||
@@ -567,75 +560,6 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Quota and Fallback Integration', () => {
|
|
||||||
it('passes a null proQuotaRequest to UIStateContext by default', () => {
|
|
||||||
// The default mock from beforeEach already sets proQuotaRequest to null
|
|
||||||
render(
|
|
||||||
<AppContainer
|
|
||||||
config={mockConfig}
|
|
||||||
settings={mockSettings}
|
|
||||||
version="1.0.0"
|
|
||||||
initializationResult={mockInitResult}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert that the context value is as expected
|
|
||||||
expect(capturedUIState.proQuotaRequest).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', () => {
|
|
||||||
// Arrange: Create a mock request object that a UI dialog would receive
|
|
||||||
const mockRequest = {
|
|
||||||
failedModel: 'gemini-pro',
|
|
||||||
fallbackModel: 'gemini-flash',
|
|
||||||
resolve: vi.fn(),
|
|
||||||
};
|
|
||||||
mockedUseQuotaAndFallback.mockReturnValue({
|
|
||||||
proQuotaRequest: mockRequest,
|
|
||||||
handleProQuotaChoice: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act: Render the container
|
|
||||||
render(
|
|
||||||
<AppContainer
|
|
||||||
config={mockConfig}
|
|
||||||
settings={mockSettings}
|
|
||||||
version="1.0.0"
|
|
||||||
initializationResult={mockInitResult}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert: The mock request is correctly passed through the context
|
|
||||||
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the handleProQuotaChoice function to UIActionsContext', () => {
|
|
||||||
// Arrange: Create a mock handler function
|
|
||||||
const mockHandler = vi.fn();
|
|
||||||
mockedUseQuotaAndFallback.mockReturnValue({
|
|
||||||
proQuotaRequest: null,
|
|
||||||
handleProQuotaChoice: mockHandler,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act: Render the container
|
|
||||||
render(
|
|
||||||
<AppContainer
|
|
||||||
config={mockConfig}
|
|
||||||
settings={mockSettings}
|
|
||||||
version="1.0.0"
|
|
||||||
initializationResult={mockInitResult}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert: The action in the context is the mock handler we provided
|
|
||||||
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
|
|
||||||
|
|
||||||
// You can even verify that the plumbed function is callable
|
|
||||||
capturedUIActions.handleProQuotaChoice('auth');
|
|
||||||
expect(mockHandler).toHaveBeenCalledWith('auth');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Terminal Title Update Feature', () => {
|
describe('Terminal Title Update Feature', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset mock stdout for each test
|
// Reset mock stdout for each test
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import {
|
|||||||
type Config,
|
type Config,
|
||||||
type IdeInfo,
|
type IdeInfo,
|
||||||
type IdeContext,
|
type IdeContext,
|
||||||
type UserTierId,
|
|
||||||
DEFAULT_GEMINI_FLASH_MODEL,
|
|
||||||
IdeClient,
|
IdeClient,
|
||||||
ideContextStore,
|
ideContextStore,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
@@ -48,7 +46,6 @@ import { useHistory } from './hooks/useHistoryManager.js';
|
|||||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||||
import { useAuthCommand } from './auth/useAuth.js';
|
import { useAuthCommand } from './auth/useAuth.js';
|
||||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
|
||||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||||
@@ -182,17 +179,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper to determine the effective model, considering the fallback state.
|
// Helper to determine the current model (polled, since Config has no model-change event).
|
||||||
const getEffectiveModel = useCallback(() => {
|
const getCurrentModel = useCallback(() => config.getModel(), [config]);
|
||||||
if (config.isInFallbackMode()) {
|
|
||||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
|
||||||
}
|
|
||||||
return config.getModel();
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
|
const [currentModel, setCurrentModel] = useState(getCurrentModel());
|
||||||
|
|
||||||
const [userTier] = useState<UserTierId | undefined>(undefined);
|
|
||||||
|
|
||||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||||
|
|
||||||
@@ -245,12 +235,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
[historyManager.addItem],
|
[historyManager.addItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Watch for model changes (e.g., from Flash fallback)
|
// Watch for model changes (e.g., user switches model via /model)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkModelChange = () => {
|
const checkModelChange = () => {
|
||||||
const effectiveModel = getEffectiveModel();
|
const model = getCurrentModel();
|
||||||
if (effectiveModel !== currentModel) {
|
if (model !== currentModel) {
|
||||||
setCurrentModel(effectiveModel);
|
setCurrentModel(model);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -258,7 +248,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const interval = setInterval(checkModelChange, 1000); // Check every second
|
const interval = setInterval(checkModelChange, 1000); // Check every second
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [config, currentModel, getEffectiveModel]);
|
}, [config, currentModel, getCurrentModel]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
consoleMessages,
|
consoleMessages,
|
||||||
@@ -367,14 +357,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
cancelAuthentication,
|
cancelAuthentication,
|
||||||
} = useAuthCommand(settings, config, historyManager.addItem);
|
} = useAuthCommand(settings, config, historyManager.addItem);
|
||||||
|
|
||||||
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
|
|
||||||
config,
|
|
||||||
historyManager,
|
|
||||||
userTier,
|
|
||||||
setAuthState,
|
|
||||||
setModelSwitchedFromQuotaError,
|
|
||||||
});
|
|
||||||
|
|
||||||
useInitializationAuthError(initializationResult.authError, onAuthError);
|
useInitializationAuthError(initializationResult.authError, onAuthError);
|
||||||
|
|
||||||
// Sync user tier from config when authentication changes
|
// Sync user tier from config when authentication changes
|
||||||
@@ -388,37 +370,36 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
// Check for enforced auth type mismatch
|
// Check for enforced auth type mismatch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for initialization error first
|
// Check for initialization error first
|
||||||
|
const currentAuthType = config.modelsConfig.getCurrentAuthType();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings.merged.security?.auth?.enforcedType &&
|
settings.merged.security?.auth?.enforcedType &&
|
||||||
settings.merged.security?.auth.selectedType &&
|
currentAuthType &&
|
||||||
settings.merged.security?.auth.enforcedType !==
|
settings.merged.security?.auth.enforcedType !== currentAuthType
|
||||||
settings.merged.security?.auth.selectedType
|
|
||||||
) {
|
) {
|
||||||
onAuthError(
|
onAuthError(
|
||||||
t(
|
t(
|
||||||
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
|
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
|
||||||
{
|
{
|
||||||
enforcedType: settings.merged.security?.auth.enforcedType,
|
enforcedType: String(settings.merged.security?.auth.enforcedType),
|
||||||
currentType: settings.merged.security?.auth.selectedType,
|
currentType: String(currentAuthType),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (!settings.merged.security?.auth?.useExternal) {
|
||||||
settings.merged.security?.auth?.selectedType &&
|
// If no authType is selected yet, allow the auth UI flow to prompt the user.
|
||||||
!settings.merged.security?.auth?.useExternal
|
// Only validate credentials once a concrete authType exists.
|
||||||
) {
|
if (currentAuthType) {
|
||||||
const error = validateAuthMethod(
|
const error = validateAuthMethod(currentAuthType, config);
|
||||||
settings.merged.security.auth.selectedType,
|
if (error) {
|
||||||
);
|
onAuthError(error);
|
||||||
if (error) {
|
}
|
||||||
onAuthError(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
settings.merged.security?.auth?.selectedType,
|
|
||||||
settings.merged.security?.auth?.enforcedType,
|
settings.merged.security?.auth?.enforcedType,
|
||||||
settings.merged.security?.auth?.useExternal,
|
settings.merged.security?.auth?.useExternal,
|
||||||
|
config,
|
||||||
onAuthError,
|
onAuthError,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -752,8 +733,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
!initError &&
|
!initError &&
|
||||||
!isProcessing &&
|
!isProcessing &&
|
||||||
(streamingState === StreamingState.Idle ||
|
(streamingState === StreamingState.Idle ||
|
||||||
streamingState === StreamingState.Responding) &&
|
streamingState === StreamingState.Responding);
|
||||||
!proQuotaRequest;
|
|
||||||
|
|
||||||
const [controlsHeight, setControlsHeight] = useState(0);
|
const [controlsHeight, setControlsHeight] = useState(0);
|
||||||
|
|
||||||
@@ -938,7 +918,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const handleIdePromptComplete = useCallback(
|
const handleIdePromptComplete = useCallback(
|
||||||
(result: IdeIntegrationNudgeResult) => {
|
(result: IdeIntegrationNudgeResult) => {
|
||||||
if (result.userSelection === 'yes') {
|
if (result.userSelection === 'yes') {
|
||||||
handleSlashCommand('/ide install');
|
// Check whether the extension has been pre-installed
|
||||||
|
if (result.isExtensionPreInstalled) {
|
||||||
|
handleSlashCommand('/ide enable');
|
||||||
|
} else {
|
||||||
|
handleSlashCommand('/ide install');
|
||||||
|
}
|
||||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||||
} else if (result.userSelection === 'dismiss') {
|
} else if (result.userSelection === 'dismiss') {
|
||||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||||
@@ -1206,7 +1191,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isAuthenticating ||
|
isAuthenticating ||
|
||||||
isEditorDialogOpen ||
|
isEditorDialogOpen ||
|
||||||
showIdeRestartPrompt ||
|
showIdeRestartPrompt ||
|
||||||
!!proQuotaRequest ||
|
|
||||||
isSubagentCreateDialogOpen ||
|
isSubagentCreateDialogOpen ||
|
||||||
isAgentsManagerDialogOpen ||
|
isAgentsManagerDialogOpen ||
|
||||||
isApprovalModeDialogOpen ||
|
isApprovalModeDialogOpen ||
|
||||||
@@ -1277,8 +1261,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
showWorkspaceMigrationDialog,
|
showWorkspaceMigrationDialog,
|
||||||
workspaceExtensions,
|
workspaceExtensions,
|
||||||
currentModel,
|
currentModel,
|
||||||
userTier,
|
|
||||||
proQuotaRequest,
|
|
||||||
contextFileNames,
|
contextFileNames,
|
||||||
errorCount,
|
errorCount,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
@@ -1367,8 +1349,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
showAutoAcceptIndicator,
|
showAutoAcceptIndicator,
|
||||||
showWorkspaceMigrationDialog,
|
showWorkspaceMigrationDialog,
|
||||||
workspaceExtensions,
|
workspaceExtensions,
|
||||||
userTier,
|
|
||||||
proQuotaRequest,
|
|
||||||
contextFileNames,
|
contextFileNames,
|
||||||
errorCount,
|
errorCount,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
@@ -1430,7 +1410,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
handleClearScreen,
|
handleClearScreen,
|
||||||
onWorkspaceMigrationDialogOpen,
|
onWorkspaceMigrationDialogOpen,
|
||||||
onWorkspaceMigrationDialogClose,
|
onWorkspaceMigrationDialogClose,
|
||||||
handleProQuotaChoice,
|
|
||||||
// Vision switch dialog
|
// Vision switch dialog
|
||||||
handleVisionSwitchSelect,
|
handleVisionSwitchSelect,
|
||||||
// Welcome back dialog
|
// Welcome back dialog
|
||||||
@@ -1468,7 +1447,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
handleClearScreen,
|
handleClearScreen,
|
||||||
onWorkspaceMigrationDialogOpen,
|
onWorkspaceMigrationDialogOpen,
|
||||||
onWorkspaceMigrationDialogClose,
|
onWorkspaceMigrationDialogClose,
|
||||||
handleProQuotaChoice,
|
|
||||||
handleVisionSwitchSelect,
|
handleVisionSwitchSelect,
|
||||||
handleWelcomeBackSelection,
|
handleWelcomeBackSelection,
|
||||||
handleWelcomeBackClose,
|
handleWelcomeBackClose,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function IdeIntegrationNudge({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { displayName: ideName } = ide;
|
const { displayName: ideName } = ide;
|
||||||
|
const isInSandbox = !!process.env['SANDBOX'];
|
||||||
// Assume extension is already installed if the env variables are set.
|
// Assume extension is already installed if the env variables are set.
|
||||||
const isExtensionPreInstalled =
|
const isExtensionPreInstalled =
|
||||||
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
|
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
|
||||||
@@ -70,13 +71,15 @@ export function IdeIntegrationNudge({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const installText = isExtensionPreInstalled
|
const installText = isInSandbox
|
||||||
? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
|
? `Note: In sandbox environments, IDE integration requires manual setup on the host system. If you select Yes, you'll receive instructions on how to set this up.`
|
||||||
ideName ?? 'your editor'
|
: isExtensionPreInstalled
|
||||||
}.`
|
? `If you select Yes, the CLI will connect to your ${
|
||||||
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
|
ideName ?? 'editor'
|
||||||
ideName ?? 'your editor'
|
} and have access to your open files and display diffs directly.`
|
||||||
}.`;
|
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
|
||||||
|
ideName ?? 'your editor'
|
||||||
|
}.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { AuthDialog } from './AuthDialog.js';
|
import { AuthDialog } from './AuthDialog.js';
|
||||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import { renderWithProviders } from '../../test-utils/render.js';
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||||
@@ -43,17 +44,24 @@ const renderAuthDialog = (
|
|||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
uiStateOverrides: Partial<UIState> = {},
|
uiStateOverrides: Partial<UIState> = {},
|
||||||
uiActionsOverrides: Partial<UIActions> = {},
|
uiActionsOverrides: Partial<UIActions> = {},
|
||||||
|
configAuthType: AuthType | undefined = undefined,
|
||||||
|
configApiKey: string | undefined = undefined,
|
||||||
) => {
|
) => {
|
||||||
const uiState = createMockUIState(uiStateOverrides);
|
const uiState = createMockUIState(uiStateOverrides);
|
||||||
const uiActions = createMockUIActions(uiActionsOverrides);
|
const uiActions = createMockUIActions(uiActionsOverrides);
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
getAuthType: vi.fn(() => configAuthType),
|
||||||
|
getContentGeneratorConfig: vi.fn(() => ({ apiKey: configApiKey })),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
return renderWithProviders(
|
return renderWithProviders(
|
||||||
<UIStateContext.Provider value={uiState}>
|
<UIStateContext.Provider value={uiState}>
|
||||||
<UIActionsContext.Provider value={uiActions}>
|
<UIActionsContext.Provider value={uiActions}>
|
||||||
<AuthDialog />
|
<AuthDialog />
|
||||||
</UIActionsContext.Provider>
|
</UIActionsContext.Provider>
|
||||||
</UIStateContext.Provider>,
|
</UIStateContext.Provider>,
|
||||||
{ settings },
|
{ settings, config: mockConfig },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,7 +176,7 @@ describe('AuthDialog', () => {
|
|||||||
|
|
||||||
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
|
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
|
||||||
process.env['GEMINI_API_KEY'] = 'foobar';
|
process.env['GEMINI_API_KEY'] = 'foobar';
|
||||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
|
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
|
||||||
|
|
||||||
const settings: LoadedSettings = new LoadedSettings(
|
const settings: LoadedSettings = new LoadedSettings(
|
||||||
{
|
{
|
||||||
@@ -212,7 +220,7 @@ describe('AuthDialog', () => {
|
|||||||
|
|
||||||
it('should show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to use api key', () => {
|
it('should show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to use api key', () => {
|
||||||
process.env['GEMINI_API_KEY'] = 'foobar';
|
process.env['GEMINI_API_KEY'] = 'foobar';
|
||||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
|
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
|
||||||
|
|
||||||
const settings: LoadedSettings = new LoadedSettings(
|
const settings: LoadedSettings = new LoadedSettings(
|
||||||
{
|
{
|
||||||
@@ -421,6 +429,7 @@ describe('AuthDialog', () => {
|
|||||||
settings,
|
settings,
|
||||||
{},
|
{},
|
||||||
{ handleAuthSelect },
|
{ handleAuthSelect },
|
||||||
|
undefined, // config.getAuthType() returns undefined
|
||||||
);
|
);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
@@ -475,6 +484,7 @@ describe('AuthDialog', () => {
|
|||||||
settings,
|
settings,
|
||||||
{ authError: 'Initial error' },
|
{ authError: 'Initial error' },
|
||||||
{ handleAuthSelect },
|
{ handleAuthSelect },
|
||||||
|
undefined, // config.getAuthType() returns undefined
|
||||||
);
|
);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
@@ -504,12 +514,12 @@ describe('AuthDialog', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
security: { auth: { selectedType: AuthType.USE_OPENAI } },
|
||||||
ui: { customThemes: {} },
|
ui: { customThemes: {} },
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
originalSettings: {
|
originalSettings: {
|
||||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
security: { auth: { selectedType: AuthType.USE_OPENAI } },
|
||||||
ui: { customThemes: {} },
|
ui: { customThemes: {} },
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
@@ -528,6 +538,7 @@ describe('AuthDialog', () => {
|
|||||||
settings,
|
settings,
|
||||||
{},
|
{},
|
||||||
{ handleAuthSelect },
|
{ handleAuthSelect },
|
||||||
|
AuthType.USE_OPENAI, // config.getAuthType() returns USE_OPENAI
|
||||||
);
|
);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
@@ -536,7 +547,7 @@ describe('AuthDialog', () => {
|
|||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// Should call handleAuthSelect with undefined to exit
|
// Should call handleAuthSelect with undefined to exit
|
||||||
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
expect(handleAuthSelect).toHaveBeenCalledWith(undefined);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import type React from 'react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useConfig } from '../contexts/ConfigContext.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
function parseDefaultAuthType(
|
function parseDefaultAuthType(
|
||||||
@@ -32,7 +31,7 @@ function parseDefaultAuthType(
|
|||||||
export function AuthDialog(): React.JSX.Element {
|
export function AuthDialog(): React.JSX.Element {
|
||||||
const { pendingAuthType, authError } = useUIState();
|
const { pendingAuthType, authError } = useUIState();
|
||||||
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
||||||
const settings = useSettings();
|
const config = useConfig();
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
@@ -58,9 +57,10 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
return item.value === pendingAuthType;
|
return item.value === pendingAuthType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: settings.merged.security?.auth?.selectedType
|
// Priority 2: config.getAuthType() - the source of truth
|
||||||
if (settings.merged.security?.auth?.selectedType) {
|
const currentAuthType = config.getAuthType();
|
||||||
return item.value === settings.merged.security?.auth?.selectedType;
|
if (currentAuthType) {
|
||||||
|
return item.value === currentAuthType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
|
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
|
||||||
@@ -76,7 +76,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
|
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
|
||||||
const currentSelectedAuthType =
|
const currentSelectedAuthType =
|
||||||
selectedIndex !== null
|
selectedIndex !== null
|
||||||
? items[selectedIndex]?.value
|
? items[selectedIndex]?.value
|
||||||
@@ -84,7 +84,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
|
|
||||||
const handleAuthSelect = async (authMethod: AuthType) => {
|
const handleAuthSelect = async (authMethod: AuthType) => {
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
await onAuthSelect(authMethod, SettingScope.User);
|
await onAuthSelect(authMethod);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHighlight = (authMethod: AuthType) => {
|
const handleHighlight = (authMethod: AuthType) => {
|
||||||
@@ -100,7 +100,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (settings.merged.security?.auth?.selectedType === undefined) {
|
if (config.getAuthType() === undefined) {
|
||||||
// Prevent exiting if no auth method is set
|
// Prevent exiting if no auth method is set
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
t(
|
t(
|
||||||
@@ -109,7 +109,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onAuthSelect(undefined, SettingScope.User);
|
onAuthSelect(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ isActive: true },
|
{ isActive: true },
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
AuthEvent,
|
AuthEvent,
|
||||||
AuthType,
|
AuthType,
|
||||||
clearCachedCredentialFile,
|
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
logAuth,
|
logAuth,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
|
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||||
import { AuthState, MessageType } from '../types.js';
|
import { AuthState, MessageType } from '../types.js';
|
||||||
@@ -27,8 +27,7 @@ export const useAuthCommand = (
|
|||||||
config: Config,
|
config: Config,
|
||||||
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
||||||
) => {
|
) => {
|
||||||
const unAuthenticated =
|
const unAuthenticated = config.getAuthType() === undefined;
|
||||||
settings.merged.security?.auth?.selectedType === undefined;
|
|
||||||
|
|
||||||
const [authState, setAuthState] = useState<AuthState>(
|
const [authState, setAuthState] = useState<AuthState>(
|
||||||
unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated,
|
unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated,
|
||||||
@@ -81,35 +80,35 @@ export const useAuthCommand = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAuthSuccess = useCallback(
|
const handleAuthSuccess = useCallback(
|
||||||
async (
|
async (authType: AuthType, credentials?: OpenAICredentials) => {
|
||||||
authType: AuthType,
|
|
||||||
scope: SettingScope,
|
|
||||||
credentials?: OpenAICredentials,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
const authTypeScope = getPersistScopeForModelSelection(settings);
|
||||||
|
settings.setValue(
|
||||||
|
authTypeScope,
|
||||||
|
'security.auth.selectedType',
|
||||||
|
authType,
|
||||||
|
);
|
||||||
|
|
||||||
// Only update credentials if not switching to QWEN_OAUTH,
|
// Only update credentials if not switching to QWEN_OAUTH,
|
||||||
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
|
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
|
||||||
if (authType !== AuthType.QWEN_OAUTH && credentials) {
|
if (authType !== AuthType.QWEN_OAUTH && credentials) {
|
||||||
if (credentials?.apiKey != null) {
|
if (credentials?.apiKey != null) {
|
||||||
settings.setValue(
|
settings.setValue(
|
||||||
scope,
|
authTypeScope,
|
||||||
'security.auth.apiKey',
|
'security.auth.apiKey',
|
||||||
credentials.apiKey,
|
credentials.apiKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (credentials?.baseUrl != null) {
|
if (credentials?.baseUrl != null) {
|
||||||
settings.setValue(
|
settings.setValue(
|
||||||
scope,
|
authTypeScope,
|
||||||
'security.auth.baseUrl',
|
'security.auth.baseUrl',
|
||||||
credentials.baseUrl,
|
credentials.baseUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (credentials?.model != null) {
|
if (credentials?.model != null) {
|
||||||
settings.setValue(scope, 'model.name', credentials.model);
|
settings.setValue(authTypeScope, 'model.name', credentials.model);
|
||||||
}
|
}
|
||||||
await clearCachedCredentialFile();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAuthFailure(error);
|
handleAuthFailure(error);
|
||||||
@@ -141,14 +140,10 @@ export const useAuthCommand = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const performAuth = useCallback(
|
const performAuth = useCallback(
|
||||||
async (
|
async (authType: AuthType, credentials?: OpenAICredentials) => {
|
||||||
authType: AuthType,
|
|
||||||
scope: SettingScope,
|
|
||||||
credentials?: OpenAICredentials,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
await config.refreshAuth(authType);
|
await config.refreshAuth(authType);
|
||||||
handleAuthSuccess(authType, scope, credentials);
|
handleAuthSuccess(authType, credentials);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleAuthFailure(e);
|
handleAuthFailure(e);
|
||||||
}
|
}
|
||||||
@@ -156,18 +151,51 @@ export const useAuthCommand = (
|
|||||||
[config, handleAuthSuccess, handleAuthFailure],
|
[config, handleAuthSuccess, handleAuthFailure],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isProviderManagedModel = useCallback(
|
||||||
|
(authType: AuthType, modelId: string | undefined) => {
|
||||||
|
if (!modelId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelProviders = settings.merged.modelProviders as
|
||||||
|
| ModelProvidersConfig
|
||||||
|
| undefined;
|
||||||
|
if (!modelProviders) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const providerModels = modelProviders[authType];
|
||||||
|
if (!Array.isArray(providerModels)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return providerModels.some(
|
||||||
|
(providerModel) => providerModel.id === modelId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[settings],
|
||||||
|
);
|
||||||
|
|
||||||
const handleAuthSelect = useCallback(
|
const handleAuthSelect = useCallback(
|
||||||
async (
|
async (authType: AuthType | undefined, credentials?: OpenAICredentials) => {
|
||||||
authType: AuthType | undefined,
|
|
||||||
scope: SettingScope,
|
|
||||||
credentials?: OpenAICredentials,
|
|
||||||
) => {
|
|
||||||
if (!authType) {
|
if (!authType) {
|
||||||
setIsAuthDialogOpen(false);
|
setIsAuthDialogOpen(false);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
authType === AuthType.USE_OPENAI &&
|
||||||
|
credentials?.model &&
|
||||||
|
isProviderManagedModel(authType, credentials.model)
|
||||||
|
) {
|
||||||
|
onAuthError(
|
||||||
|
t(
|
||||||
|
'Model "{{modelName}}" is managed via settings.modelProviders. Please complete the fields in settings, or use another model id.',
|
||||||
|
{ modelName: credentials.model },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setPendingAuthType(authType);
|
setPendingAuthType(authType);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
setIsAuthDialogOpen(false);
|
setIsAuthDialogOpen(false);
|
||||||
@@ -180,14 +208,14 @@ export const useAuthCommand = (
|
|||||||
baseUrl: credentials.baseUrl,
|
baseUrl: credentials.baseUrl,
|
||||||
model: credentials.model,
|
model: credentials.model,
|
||||||
});
|
});
|
||||||
await performAuth(authType, scope, credentials);
|
await performAuth(authType, credentials);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await performAuth(authType, scope);
|
await performAuth(authType);
|
||||||
},
|
},
|
||||||
[config, performAuth],
|
[config, performAuth, isProviderManagedModel, onAuthError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openAuthDialog = useCallback(() => {
|
const openAuthDialog = useCallback(() => {
|
||||||
@@ -225,16 +253,26 @@ export const useAuthCommand = (
|
|||||||
const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE'];
|
const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE'];
|
||||||
if (
|
if (
|
||||||
defaultAuthType &&
|
defaultAuthType &&
|
||||||
![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes(
|
![
|
||||||
defaultAuthType as AuthType,
|
AuthType.QWEN_OAUTH,
|
||||||
)
|
AuthType.USE_OPENAI,
|
||||||
|
AuthType.USE_ANTHROPIC,
|
||||||
|
AuthType.USE_GEMINI,
|
||||||
|
AuthType.USE_VERTEX_AI,
|
||||||
|
].includes(defaultAuthType as AuthType)
|
||||||
) {
|
) {
|
||||||
onAuthError(
|
onAuthError(
|
||||||
t(
|
t(
|
||||||
'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}',
|
'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}',
|
||||||
{
|
{
|
||||||
value: defaultAuthType,
|
value: defaultAuthType,
|
||||||
validValues: [AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', '),
|
validValues: [
|
||||||
|
AuthType.QWEN_OAUTH,
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
|
AuthType.USE_ANTHROPIC,
|
||||||
|
AuthType.USE_GEMINI,
|
||||||
|
AuthType.USE_VERTEX_AI,
|
||||||
|
].join(', '),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,31 +4,28 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { approvalModeCommand } from './approvalModeCommand.js';
|
import { approvalModeCommand } from './approvalModeCommand.js';
|
||||||
import {
|
import {
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
CommandKind,
|
CommandKind,
|
||||||
type OpenDialogActionReturn,
|
type OpenDialogActionReturn,
|
||||||
|
type MessageActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
|
||||||
|
|
||||||
describe('approvalModeCommand', () => {
|
describe('approvalModeCommand', () => {
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
|
let mockSetApprovalMode: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockSetApprovalMode = vi.fn();
|
||||||
mockContext = createMockCommandContext({
|
mockContext = createMockCommandContext({
|
||||||
services: {
|
services: {
|
||||||
config: {
|
config: {
|
||||||
getApprovalMode: () => 'default',
|
getApprovalMode: () => 'default',
|
||||||
setApprovalMode: () => {},
|
setApprovalMode: mockSetApprovalMode,
|
||||||
},
|
},
|
||||||
settings: {
|
|
||||||
merged: {},
|
|
||||||
setValue: () => {},
|
|
||||||
forScope: () => ({}),
|
|
||||||
} as unknown as LoadedSettings,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -41,7 +38,7 @@ describe('approvalModeCommand', () => {
|
|||||||
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open approval mode dialog when invoked', async () => {
|
it('should open approval mode dialog when invoked without arguments', async () => {
|
||||||
const result = (await approvalModeCommand.action?.(
|
const result = (await approvalModeCommand.action?.(
|
||||||
mockContext,
|
mockContext,
|
||||||
'',
|
'',
|
||||||
@@ -51,16 +48,123 @@ describe('approvalModeCommand', () => {
|
|||||||
expect(result.dialog).toBe('approval-mode');
|
expect(result.dialog).toBe('approval-mode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open approval mode dialog with arguments (ignored)', async () => {
|
it('should open approval mode dialog when invoked with whitespace only', async () => {
|
||||||
const result = (await approvalModeCommand.action?.(
|
const result = (await approvalModeCommand.action?.(
|
||||||
mockContext,
|
mockContext,
|
||||||
'some arguments',
|
' ',
|
||||||
)) as OpenDialogActionReturn;
|
)) as OpenDialogActionReturn;
|
||||||
|
|
||||||
expect(result.type).toBe('dialog');
|
expect(result.type).toBe('dialog');
|
||||||
expect(result.dialog).toBe('approval-mode');
|
expect(result.dialog).toBe('approval-mode');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('direct mode setting (session-only)', () => {
|
||||||
|
it('should set approval mode to "plan" when argument is "plan"', async () => {
|
||||||
|
const result = (await approvalModeCommand.action?.(
|
||||||
|
mockContext,
|
||||||
|
'plan',
|
||||||
|
)) as MessageActionReturn;
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toContain('plan');
|
||||||
|
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set approval mode to "yolo" when argument is "yolo"', async () => {
|
||||||
|
const result = (await approvalModeCommand.action?.(
|
||||||
|
mockContext,
|
||||||
|
'yolo',
|
||||||
|
)) as MessageActionReturn;
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toContain('yolo');
|
||||||
|
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set approval mode to "auto-edit" when argument is "auto-edit"', async () => {
|
||||||
|
const result = (await approvalModeCommand.action?.(
|
||||||
|
mockContext,
|
||||||
|
'auto-edit',
|
||||||
|
)) as MessageActionReturn;
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toContain('auto-edit');
|
||||||
|
expect(mockSetApprovalMode).toHaveBeenCalledWith('auto-edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set approval mode to "default" when argument is "default"', async () => {
|
||||||
|
const result = (await approvalModeCommand.action?.(
|
||||||
|
mockContext,
|
||||||
|
'default',
|
||||||
|
)) as MessageActionReturn;
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toContain('default');
|
||||||
|
expect(mockSetApprovalMode).toHaveBeenCalledWith('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive for mode argument', async () => {
|
||||||
|
const result = (await approvalModeCommand.action?.(
|
||||||
|
mockContext,
|
||||||
|
'YOLO',
|
||||||
|
)) as MessageActionReturn;
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle argument with leading/trailing whitespace', async () => {
|
||||||
|
const result = (await approvalModeCommand.action?.(
|
||||||
|
mockContext,
|
||||||
|
' plan ',
|
||||||
|
)) as MessageActionReturn;
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid mode argument', () => {
|
||||||
|
it('should return error for invalid mode', async () => {
|
||||||
|
const result = (await approvalModeCommand.action?.(
|
||||||
|
mockContext,
|
||||||
|
'invalid-mode',
|
||||||
|
)) as MessageActionReturn;
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
expect(result.messageType).toBe('error');
|
||||||
|
expect(result.content).toContain('invalid-mode');
|
||||||
|
expect(result.content).toContain('plan');
|
||||||
|
expect(result.content).toContain('yolo');
|
||||||
|
expect(mockSetApprovalMode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('untrusted folder handling', () => {
|
||||||
|
it('should return error when setApprovalMode throws (e.g., untrusted folder)', async () => {
|
||||||
|
const errorMessage =
|
||||||
|
'Cannot enable privileged approval modes in an untrusted folder.';
|
||||||
|
mockSetApprovalMode.mockImplementation(() => {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (await approvalModeCommand.action?.(
|
||||||
|
mockContext,
|
||||||
|
'yolo',
|
||||||
|
)) as MessageActionReturn;
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
expect(result.messageType).toBe('error');
|
||||||
|
expect(result.content).toBe(errorMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not have subcommands', () => {
|
it('should not have subcommands', () => {
|
||||||
expect(approvalModeCommand.subCommands).toBeUndefined();
|
expect(approvalModeCommand.subCommands).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,25 @@ import type {
|
|||||||
SlashCommand,
|
SlashCommand,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
OpenDialogActionReturn,
|
OpenDialogActionReturn,
|
||||||
|
MessageActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||||
|
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the argument string and returns the corresponding ApprovalMode if valid.
|
||||||
|
* Returns undefined if the argument is empty or not a valid mode.
|
||||||
|
*/
|
||||||
|
function parseApprovalModeArg(arg: string): ApprovalMode | undefined {
|
||||||
|
const trimmed = arg.trim().toLowerCase();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Match against valid approval modes (case-insensitive)
|
||||||
|
return APPROVAL_MODES.find((mode) => mode.toLowerCase() === trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
export const approvalModeCommand: SlashCommand = {
|
export const approvalModeCommand: SlashCommand = {
|
||||||
name: 'approval-mode',
|
name: 'approval-mode',
|
||||||
@@ -19,10 +35,49 @@ export const approvalModeCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
_context: CommandContext,
|
context: CommandContext,
|
||||||
_args: string,
|
args: string,
|
||||||
): Promise<OpenDialogActionReturn> => ({
|
): Promise<OpenDialogActionReturn | MessageActionReturn> => {
|
||||||
type: 'dialog',
|
const mode = parseApprovalModeArg(args);
|
||||||
dialog: 'approval-mode',
|
|
||||||
}),
|
// If no argument provided, open the dialog
|
||||||
|
if (!args.trim()) {
|
||||||
|
return {
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'approval-mode',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If invalid argument, return error message with valid options
|
||||||
|
if (!mode) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: t('Invalid approval mode "{{arg}}". Valid modes: {{modes}}', {
|
||||||
|
arg: args.trim(),
|
||||||
|
modes: APPROVAL_MODES.join(', '),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the mode for current session only (not persisted)
|
||||||
|
const { config } = context.services;
|
||||||
|
if (config) {
|
||||||
|
try {
|
||||||
|
config.setApprovalMode(mode);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: (e as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: t('Approval mode set to "{{mode}}"', { mode }),
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export const compressCommand: SlashCommand = {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const { ui } = context;
|
const { ui } = context;
|
||||||
if (ui.pendingItem) {
|
const executionMode = context.executionMode ?? 'interactive';
|
||||||
|
|
||||||
|
if (executionMode === 'interactive' && ui.pendingItem) {
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
@@ -40,13 +42,80 @@ export const compressCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const config = context.services.config;
|
||||||
ui.setPendingItem(pendingMessage);
|
const geminiClient = config?.getGeminiClient();
|
||||||
|
if (!config || !geminiClient) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: t('Config not loaded.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const doCompress = async () => {
|
||||||
const promptId = `compress-${Date.now()}`;
|
const promptId = `compress-${Date.now()}`;
|
||||||
const compressed = await context.services.config
|
return await geminiClient.tryCompressChat(promptId, true);
|
||||||
?.getGeminiClient()
|
};
|
||||||
?.tryCompressChat(promptId, true);
|
|
||||||
if (compressed) {
|
if (executionMode === 'acp') {
|
||||||
|
const messages = async function* () {
|
||||||
|
try {
|
||||||
|
yield {
|
||||||
|
messageType: 'info' as const,
|
||||||
|
content: 'Compressing context...',
|
||||||
|
};
|
||||||
|
const compressed = await doCompress();
|
||||||
|
if (!compressed) {
|
||||||
|
yield {
|
||||||
|
messageType: 'error' as const,
|
||||||
|
content: t('Failed to compress chat history.'),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
messageType: 'info' as const,
|
||||||
|
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
yield {
|
||||||
|
messageType: 'error' as const,
|
||||||
|
content: t('Failed to compress chat history: {{error}}', {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { type: 'stream_messages', messages: messages() };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (executionMode === 'interactive') {
|
||||||
|
ui.setPendingItem(pendingMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressed = await doCompress();
|
||||||
|
|
||||||
|
if (!compressed) {
|
||||||
|
if (executionMode === 'interactive') {
|
||||||
|
ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: t('Failed to compress chat history.'),
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: t('Failed to compress chat history.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executionMode === 'interactive') {
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.COMPRESSION,
|
type: MessageType.COMPRESSION,
|
||||||
@@ -59,27 +128,39 @@ export const compressCommand: SlashCommand = {
|
|||||||
} as HistoryItemCompression,
|
} as HistoryItemCompression,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (executionMode === 'interactive') {
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: t('Failed to compress chat history.'),
|
text: t('Failed to compress chat history: {{error}}', {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
ui.addItem(
|
return {
|
||||||
{
|
type: 'message',
|
||||||
type: MessageType.ERROR,
|
messageType: 'error',
|
||||||
text: t('Failed to compress chat history: {{error}}', {
|
content: t('Failed to compress chat history: {{error}}', {
|
||||||
error: e instanceof Error ? e.message : String(e),
|
error: e instanceof Error ? e.message : String(e),
|
||||||
}),
|
}),
|
||||||
},
|
};
|
||||||
Date.now(),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
ui.setPendingItem(null);
|
if (executionMode === 'interactive') {
|
||||||
|
ui.setPendingItem(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||||||
const original = await importOriginal<typeof core>();
|
const original = await importOriginal<typeof core>();
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
getOauthClient: vi.fn(original.getOauthClient),
|
|
||||||
getIdeInstaller: vi.fn(original.getIdeInstaller),
|
getIdeInstaller: vi.fn(original.getIdeInstaller),
|
||||||
IdeClient: {
|
IdeClient: {
|
||||||
getInstance: vi.fn(),
|
getInstance: vi.fn(),
|
||||||
|
|||||||
@@ -191,11 +191,23 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const installer = getIdeInstaller(currentIDE);
|
const installer = getIdeInstaller(currentIDE);
|
||||||
|
const isSandBox = !!process.env['SANDBOX'];
|
||||||
|
if (isSandBox) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: 'info',
|
||||||
|
text: `IDE integration needs to be installed on the host. If you have already installed it, you can directly connect the ide`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!installer) {
|
if (!installer) {
|
||||||
|
const ideName = ideClient.getDetectedIdeDisplayName();
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
|
text: `Automatic installation is not supported for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ import type { SlashCommand, type CommandContext } from './types.js';
|
|||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
loadServerHierarchicalMemory,
|
loadServerHierarchicalMemory,
|
||||||
|
QWEN_DIR,
|
||||||
|
setGeminiMdFilename,
|
||||||
type FileDiscoveryService,
|
type FileDiscoveryService,
|
||||||
type LoadServerHierarchicalMemoryResponse,
|
type LoadServerHierarchicalMemoryResponse,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
@@ -31,7 +36,18 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => {
|
||||||
|
const readFile = vi.fn();
|
||||||
|
return {
|
||||||
|
readFile,
|
||||||
|
default: {
|
||||||
|
readFile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
|
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
|
||||||
|
const mockReadFile = readFile as unknown as Mock;
|
||||||
|
|
||||||
describe('memoryCommand', () => {
|
describe('memoryCommand', () => {
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
@@ -52,6 +68,10 @@ describe('memoryCommand', () => {
|
|||||||
let mockGetGeminiMdFileCount: Mock;
|
let mockGetGeminiMdFileCount: Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setGeminiMdFilename('QWEN.md');
|
||||||
|
mockReadFile.mockReset();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
|
||||||
showCommand = getSubCommand('show');
|
showCommand = getSubCommand('show');
|
||||||
|
|
||||||
mockGetUserMemory = vi.fn();
|
mockGetUserMemory = vi.fn();
|
||||||
@@ -102,6 +122,52 @@ describe('memoryCommand', () => {
|
|||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show project memory from the configured context file', async () => {
|
||||||
|
const projectCommand = showCommand.subCommands?.find(
|
||||||
|
(cmd) => cmd.name === '--project',
|
||||||
|
);
|
||||||
|
if (!projectCommand?.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
setGeminiMdFilename('AGENTS.md');
|
||||||
|
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
|
||||||
|
mockReadFile.mockResolvedValue('project memory');
|
||||||
|
|
||||||
|
await projectCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
const expectedProjectPath = path.join('/test/project', 'AGENTS.md');
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith(expectedProjectPath, 'utf-8');
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: expect.stringContaining(expectedProjectPath),
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show global memory from the configured context file', async () => {
|
||||||
|
const globalCommand = showCommand.subCommands?.find(
|
||||||
|
(cmd) => cmd.name === '--global',
|
||||||
|
);
|
||||||
|
if (!globalCommand?.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
setGeminiMdFilename('AGENTS.md');
|
||||||
|
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
|
||||||
|
mockReadFile.mockResolvedValue('global memory');
|
||||||
|
|
||||||
|
await globalCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
const expectedGlobalPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
|
||||||
|
expect(mockReadFile).toHaveBeenCalledWith(expectedGlobalPath, 'utf-8');
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: expect.stringContaining('Global memory content'),
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/memory add', () => {
|
describe('/memory add', () => {
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
getCurrentGeminiMdFilename,
|
||||||
loadServerHierarchicalMemory,
|
loadServerHierarchicalMemory,
|
||||||
QWEN_DIR,
|
QWEN_DIR,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'os';
|
import os from 'node:os';
|
||||||
import fs from 'fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
@@ -56,7 +57,12 @@ export const memoryCommand: SlashCommand = {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
try {
|
try {
|
||||||
const projectMemoryPath = path.join(process.cwd(), 'QWEN.md');
|
const workingDir =
|
||||||
|
context.services.config?.getWorkingDir?.() ?? process.cwd();
|
||||||
|
const projectMemoryPath = path.join(
|
||||||
|
workingDir,
|
||||||
|
getCurrentGeminiMdFilename(),
|
||||||
|
);
|
||||||
const memoryContent = await fs.readFile(
|
const memoryContent = await fs.readFile(
|
||||||
projectMemoryPath,
|
projectMemoryPath,
|
||||||
'utf-8',
|
'utf-8',
|
||||||
@@ -104,7 +110,7 @@ export const memoryCommand: SlashCommand = {
|
|||||||
const globalMemoryPath = path.join(
|
const globalMemoryPath = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
QWEN_DIR,
|
QWEN_DIR,
|
||||||
'QWEN.md',
|
getCurrentGeminiMdFilename(),
|
||||||
);
|
);
|
||||||
const globalMemoryContent = await fs.readFile(
|
const globalMemoryContent = await fs.readFile(
|
||||||
globalMemoryPath,
|
globalMemoryPath,
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ import {
|
|||||||
type ContentGeneratorConfig,
|
type ContentGeneratorConfig,
|
||||||
type Config,
|
type Config,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import * as availableModelsModule from '../models/availableModels.js';
|
|
||||||
|
|
||||||
// Mock the availableModels module
|
|
||||||
vi.mock('../models/availableModels.js', () => ({
|
|
||||||
getAvailableModelsForAuthType: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Helper function to create a mock config
|
// Helper function to create a mock config
|
||||||
function createMockConfig(
|
function createMockConfig(
|
||||||
@@ -31,9 +25,6 @@ function createMockConfig(
|
|||||||
|
|
||||||
describe('modelCommand', () => {
|
describe('modelCommand', () => {
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
const mockGetAvailableModelsForAuthType = vi.mocked(
|
|
||||||
availableModelsModule.getAvailableModelsForAuthType,
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockContext = createMockCommandContext();
|
mockContext = createMockCommandContext();
|
||||||
@@ -87,10 +78,6 @@ describe('modelCommand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return dialog action for QWEN_OAUTH auth type', async () => {
|
it('should return dialog action for QWEN_OAUTH auth type', async () => {
|
||||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
|
||||||
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const mockConfig = createMockConfig({
|
const mockConfig = createMockConfig({
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
authType: AuthType.QWEN_OAUTH,
|
authType: AuthType.QWEN_OAUTH,
|
||||||
@@ -105,11 +92,7 @@ describe('modelCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
|
it('should return dialog action for USE_OPENAI auth type', async () => {
|
||||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
|
||||||
{ id: 'gpt-4', label: 'gpt-4' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const mockConfig = createMockConfig({
|
const mockConfig = createMockConfig({
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
authType: AuthType.USE_OPENAI,
|
authType: AuthType.USE_OPENAI,
|
||||||
@@ -124,28 +107,7 @@ describe('modelCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error for USE_OPENAI auth type when no model is available', async () => {
|
it('should return dialog action for unsupported auth types', async () => {
|
||||||
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
|
||||||
|
|
||||||
const mockConfig = createMockConfig({
|
|
||||||
model: 'test-model',
|
|
||||||
authType: AuthType.USE_OPENAI,
|
|
||||||
});
|
|
||||||
mockContext.services.config = mockConfig as Config;
|
|
||||||
|
|
||||||
const result = await modelCommand.action!(mockContext, '');
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'error',
|
|
||||||
content:
|
|
||||||
'No models available for the current authentication type (openai).',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error for unsupported auth types', async () => {
|
|
||||||
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
|
||||||
|
|
||||||
const mockConfig = createMockConfig({
|
const mockConfig = createMockConfig({
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
|
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
|
||||||
@@ -155,10 +117,8 @@ describe('modelCommand', () => {
|
|||||||
const result = await modelCommand.action!(mockContext, '');
|
const result = await modelCommand.action!(mockContext, '');
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'dialog',
|
||||||
messageType: 'error',
|
dialog: 'model',
|
||||||
content:
|
|
||||||
'No models available for the current authentication type (UNSUPPORTED_AUTH_TYPE).',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
MessageActionReturn,
|
MessageActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
|
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const modelCommand: SlashCommand = {
|
export const modelCommand: SlashCommand = {
|
||||||
@@ -30,7 +29,7 @@ export const modelCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Configuration not available.',
|
content: t('Configuration not available.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,22 +51,6 @@ export const modelCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableModels = getAvailableModelsForAuthType(authType);
|
|
||||||
|
|
||||||
if (availableModels.length === 0) {
|
|
||||||
return {
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'error',
|
|
||||||
content: t(
|
|
||||||
'No models available for the current authentication type ({{authType}}).',
|
|
||||||
{
|
|
||||||
authType,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger model selection dialog
|
|
||||||
return {
|
return {
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
dialog: 'model',
|
dialog: 'model',
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const summaryCommand: SlashCommand = {
|
|||||||
action: async (context): Promise<SlashCommandActionReturn> => {
|
action: async (context): Promise<SlashCommandActionReturn> => {
|
||||||
const { config } = context.services;
|
const { config } = context.services;
|
||||||
const { ui } = context;
|
const { ui } = context;
|
||||||
|
const executionMode = context.executionMode ?? 'interactive';
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -43,8 +45,8 @@ export const summaryCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already generating summary
|
// Check if already generating summary (interactive UI only)
|
||||||
if (ui.pendingItem) {
|
if (executionMode === 'interactive' && ui.pendingItem) {
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'error' as const,
|
type: 'error' as const,
|
||||||
@@ -63,29 +65,22 @@ export const summaryCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const getChatHistory = () => {
|
||||||
// Get the current chat history
|
|
||||||
const chat = geminiClient.getChat();
|
const chat = geminiClient.getChat();
|
||||||
const history = chat.getHistory();
|
return chat.getHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateChatHistory = (
|
||||||
|
history: ReturnType<typeof getChatHistory>,
|
||||||
|
) => {
|
||||||
if (history.length <= 2) {
|
if (history.length <= 2) {
|
||||||
return {
|
throw new Error(t('No conversation found to summarize.'));
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: t('No conversation found to summarize.'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Show loading state
|
const generateSummaryMarkdown = async (
|
||||||
const pendingMessage: HistoryItemSummary = {
|
history: ReturnType<typeof getChatHistory>,
|
||||||
type: 'summary',
|
): Promise<string> => {
|
||||||
summary: {
|
|
||||||
isPending: true,
|
|
||||||
stage: 'generating',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
ui.setPendingItem(pendingMessage);
|
|
||||||
|
|
||||||
// Build the conversation context for summary generation
|
// Build the conversation context for summary generation
|
||||||
const conversationContext = history.map((message) => ({
|
const conversationContext = history.map((message) => ({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
@@ -121,19 +116,21 @@ export const summaryCommand: SlashCommand = {
|
|||||||
|
|
||||||
if (!markdownSummary) {
|
if (!markdownSummary) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Failed to generate summary - no text content received from LLM response',
|
t(
|
||||||
|
'Failed to generate summary - no text content received from LLM response',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update loading message to show saving progress
|
return markdownSummary;
|
||||||
ui.setPendingItem({
|
};
|
||||||
type: 'summary',
|
|
||||||
summary: {
|
|
||||||
isPending: true,
|
|
||||||
stage: 'saving',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const saveSummaryToDisk = async (
|
||||||
|
markdownSummary: string,
|
||||||
|
): Promise<{
|
||||||
|
filePathForDisplay: string;
|
||||||
|
fullPath: string;
|
||||||
|
}> => {
|
||||||
// Ensure .qwen directory exists
|
// Ensure .qwen directory exists
|
||||||
const projectRoot = config.getProjectRoot();
|
const projectRoot = config.getProjectRoot();
|
||||||
const qwenDir = path.join(projectRoot, '.qwen');
|
const qwenDir = path.join(projectRoot, '.qwen');
|
||||||
@@ -155,45 +152,163 @@ export const summaryCommand: SlashCommand = {
|
|||||||
|
|
||||||
await fsPromises.writeFile(summaryPath, summaryContent, 'utf8');
|
await fsPromises.writeFile(summaryPath, summaryContent, 'utf8');
|
||||||
|
|
||||||
// Clear pending item and show success message
|
return {
|
||||||
|
filePathForDisplay: '.qwen/PROJECT_SUMMARY.md',
|
||||||
|
fullPath: summaryPath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitInteractivePending = (stage: 'generating' | 'saving') => {
|
||||||
|
if (executionMode !== 'interactive') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pendingMessage: HistoryItemSummary = {
|
||||||
|
type: 'summary',
|
||||||
|
summary: {
|
||||||
|
isPending: true,
|
||||||
|
stage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ui.setPendingItem(pendingMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeInteractive = (filePathForDisplay: string) => {
|
||||||
|
if (executionMode !== 'interactive') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
ui.setPendingItem(null);
|
ui.setPendingItem(null);
|
||||||
const completedSummaryItem: HistoryItemSummary = {
|
const completedSummaryItem: HistoryItemSummary = {
|
||||||
type: 'summary',
|
type: 'summary',
|
||||||
summary: {
|
summary: {
|
||||||
isPending: false,
|
isPending: false,
|
||||||
stage: 'completed',
|
stage: 'completed',
|
||||||
filePath: '.qwen/PROJECT_SUMMARY.md',
|
filePath: filePathForDisplay,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ui.addItem(completedSummaryItem, Date.now());
|
ui.addItem(completedSummaryItem, Date.now());
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
const formatErrorMessage = (error: unknown): string =>
|
||||||
type: 'message',
|
t('Failed to generate project context summary: {{error}}', {
|
||||||
messageType: 'info',
|
error: error instanceof Error ? error.message : String(error),
|
||||||
content: '', // Empty content since we show the message in UI component
|
});
|
||||||
};
|
|
||||||
} catch (error) {
|
const failInteractive = (error: unknown) => {
|
||||||
// Clear pending item on error
|
if (executionMode !== 'interactive') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
ui.setPendingItem(null);
|
ui.setPendingItem(null);
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'error' as const,
|
type: 'error' as const,
|
||||||
text: `❌ ${t(
|
text: `❌ ${formatErrorMessage(error)}`,
|
||||||
'Failed to generate project context summary: {{error}}',
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
},
|
|
||||||
)}`,
|
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSuccessMessage = (filePathForDisplay: string): string =>
|
||||||
|
t('Saved project summary to {{filePathForDisplay}}.', {
|
||||||
|
filePathForDisplay,
|
||||||
|
});
|
||||||
|
|
||||||
|
const returnNoConversationMessage = (): SlashCommandActionReturn => {
|
||||||
|
const msg = t('No conversation found to summarize.');
|
||||||
|
if (executionMode === 'acp') {
|
||||||
|
const messages = async function* () {
|
||||||
|
yield {
|
||||||
|
messageType: 'info' as const,
|
||||||
|
content: msg,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
type: 'stream_messages',
|
||||||
|
messages: messages(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: msg,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeSummaryGeneration = async (
|
||||||
|
history: ReturnType<typeof getChatHistory>,
|
||||||
|
): Promise<{
|
||||||
|
markdownSummary: string;
|
||||||
|
filePathForDisplay: string;
|
||||||
|
}> => {
|
||||||
|
emitInteractivePending('generating');
|
||||||
|
const markdownSummary = await generateSummaryMarkdown(history);
|
||||||
|
emitInteractivePending('saving');
|
||||||
|
const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary);
|
||||||
|
completeInteractive(filePathForDisplay);
|
||||||
|
return { markdownSummary, filePathForDisplay };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate chat history once at the beginning
|
||||||
|
const history = getChatHistory();
|
||||||
|
try {
|
||||||
|
validateChatHistory(history);
|
||||||
|
} catch (_error) {
|
||||||
|
return returnNoConversationMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executionMode === 'acp') {
|
||||||
|
const messages = async function* () {
|
||||||
|
try {
|
||||||
|
yield {
|
||||||
|
messageType: 'info' as const,
|
||||||
|
content: t('Generating project summary...'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { filePathForDisplay } =
|
||||||
|
await executeSummaryGeneration(history);
|
||||||
|
|
||||||
|
yield {
|
||||||
|
messageType: 'info' as const,
|
||||||
|
content: formatSuccessMessage(filePathForDisplay),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
failInteractive(error);
|
||||||
|
yield {
|
||||||
|
messageType: 'error' as const,
|
||||||
|
content: formatErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'stream_messages',
|
||||||
|
messages: messages(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { filePathForDisplay } = await executeSummaryGeneration(history);
|
||||||
|
|
||||||
|
if (executionMode === 'non_interactive') {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: formatSuccessMessage(filePathForDisplay),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive mode: UI components already display progress and completion.
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
failInteractive(error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: t('Failed to generate project context summary: {{error}}', {
|
content: formatErrorMessage(error),
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ import type {
|
|||||||
|
|
||||||
// Grouped dependencies for clarity and easier mocking
|
// Grouped dependencies for clarity and easier mocking
|
||||||
export interface CommandContext {
|
export interface CommandContext {
|
||||||
|
/**
|
||||||
|
* Execution mode for the current invocation.
|
||||||
|
*
|
||||||
|
* - interactive: React/Ink UI mode
|
||||||
|
* - non_interactive: non-interactive CLI mode (text/json)
|
||||||
|
* - acp: ACP/Zed integration mode
|
||||||
|
*/
|
||||||
|
executionMode?: 'interactive' | 'non_interactive' | 'acp';
|
||||||
// Invocation properties for when commands are called.
|
// Invocation properties for when commands are called.
|
||||||
invocation?: {
|
invocation?: {
|
||||||
/** The raw, untrimmed input string from the user. */
|
/** The raw, untrimmed input string from the user. */
|
||||||
@@ -108,6 +116,19 @@ export interface MessageActionReturn {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The return type for a command action that streams multiple messages.
|
||||||
|
* Used for long-running operations that need to send progress updates.
|
||||||
|
*/
|
||||||
|
export interface StreamMessagesActionReturn {
|
||||||
|
type: 'stream_messages';
|
||||||
|
messages: AsyncGenerator<
|
||||||
|
{ messageType: 'info' | 'error'; content: string },
|
||||||
|
void,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The return type for a command action that needs to open a dialog.
|
* The return type for a command action that needs to open a dialog.
|
||||||
*/
|
*/
|
||||||
@@ -174,6 +195,7 @@ export interface ConfirmActionReturn {
|
|||||||
export type SlashCommandActionReturn =
|
export type SlashCommandActionReturn =
|
||||||
| ToolActionReturn
|
| ToolActionReturn
|
||||||
| MessageActionReturn
|
| MessageActionReturn
|
||||||
|
| StreamMessagesActionReturn
|
||||||
| QuitActionReturn
|
| QuitActionReturn
|
||||||
| OpenDialogActionReturn
|
| OpenDialogActionReturn
|
||||||
| LoadHistoryActionReturn
|
| LoadHistoryActionReturn
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function ApprovalModeDialog({
|
|||||||
}: ApprovalModeDialogProps): React.JSX.Element {
|
}: ApprovalModeDialogProps): React.JSX.Element {
|
||||||
// Start with User scope by default
|
// Start with User scope by default
|
||||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||||
SettingScope.User,
|
SettingScope.Workspace,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track the currently highlighted approval mode
|
// Track the currently highlighted approval mode
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { AuthDialog } from '../auth/AuthDialog.js';
|
|||||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||||
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
||||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
|
||||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||||
import { ModelDialog } from './ModelDialog.js';
|
import { ModelDialog } from './ModelDialog.js';
|
||||||
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
|
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
|
||||||
@@ -26,7 +25,6 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
|||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
import { useConfig } from '../contexts/ConfigContext.js';
|
import { useConfig } from '../contexts/ConfigContext.js';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
|
||||||
import { AuthState } from '../types.js';
|
import { AuthState } from '../types.js';
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -87,15 +85,6 @@ export const DialogManager = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (uiState.proQuotaRequest) {
|
|
||||||
return (
|
|
||||||
<ProQuotaDialog
|
|
||||||
failedModel={uiState.proQuotaRequest.failedModel}
|
|
||||||
fallbackModel={uiState.proQuotaRequest.fallbackModel}
|
|
||||||
onChoice={uiActions.handleProQuotaChoice}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (uiState.shouldShowIdePrompt) {
|
if (uiState.shouldShowIdePrompt) {
|
||||||
return (
|
return (
|
||||||
<IdeIntegrationNudge
|
<IdeIntegrationNudge
|
||||||
@@ -212,7 +201,7 @@ export const DialogManager = ({
|
|||||||
return (
|
return (
|
||||||
<OpenAIKeyPrompt
|
<OpenAIKeyPrompt
|
||||||
onSubmit={(apiKey, baseUrl, model) => {
|
onSubmit={(apiKey, baseUrl, model) => {
|
||||||
uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
|
uiActions.handleAuthSelect(AuthType.USE_OPENAI, {
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { ModelDialog } from './ModelDialog.js';
|
|||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
|
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import {
|
import {
|
||||||
AVAILABLE_MODELS_QWEN,
|
AVAILABLE_MODELS_QWEN,
|
||||||
MAINLINE_CODER,
|
MAINLINE_CODER,
|
||||||
@@ -36,18 +40,29 @@ const renderComponent = (
|
|||||||
};
|
};
|
||||||
const combinedProps = { ...defaultProps, ...props };
|
const combinedProps = { ...defaultProps, ...props };
|
||||||
|
|
||||||
|
const mockSettings = {
|
||||||
|
isTrusted: true,
|
||||||
|
user: { settings: {} },
|
||||||
|
workspace: { settings: {} },
|
||||||
|
setValue: vi.fn(),
|
||||||
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
const mockConfig = contextValue
|
const mockConfig = contextValue
|
||||||
? ({
|
? ({
|
||||||
// --- Functions used by ModelDialog ---
|
// --- Functions used by ModelDialog ---
|
||||||
getModel: vi.fn(() => MAINLINE_CODER),
|
getModel: vi.fn(() => MAINLINE_CODER),
|
||||||
setModel: vi.fn(),
|
setModel: vi.fn().mockResolvedValue(undefined),
|
||||||
|
switchModel: vi.fn().mockResolvedValue(undefined),
|
||||||
getAuthType: vi.fn(() => 'qwen-oauth'),
|
getAuthType: vi.fn(() => 'qwen-oauth'),
|
||||||
|
|
||||||
// --- Functions used by ClearcutLogger ---
|
// --- Functions used by ClearcutLogger ---
|
||||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||||
getSessionId: vi.fn(() => 'mock-session-id'),
|
getSessionId: vi.fn(() => 'mock-session-id'),
|
||||||
getDebugMode: vi.fn(() => false),
|
getDebugMode: vi.fn(() => false),
|
||||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
|
getContentGeneratorConfig: vi.fn(() => ({
|
||||||
|
authType: AuthType.QWEN_OAUTH,
|
||||||
|
model: MAINLINE_CODER,
|
||||||
|
})),
|
||||||
getUseSmartEdit: vi.fn(() => false),
|
getUseSmartEdit: vi.fn(() => false),
|
||||||
getUseModelRouter: vi.fn(() => false),
|
getUseModelRouter: vi.fn(() => false),
|
||||||
getProxy: vi.fn(() => undefined),
|
getProxy: vi.fn(() => undefined),
|
||||||
@@ -58,21 +73,27 @@ const renderComponent = (
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const renderResult = render(
|
const renderResult = render(
|
||||||
<ConfigContext.Provider value={mockConfig}>
|
<SettingsContext.Provider value={mockSettings}>
|
||||||
<ModelDialog {...combinedProps} />
|
<ConfigContext.Provider value={mockConfig}>
|
||||||
</ConfigContext.Provider>,
|
<ModelDialog {...combinedProps} />
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
</SettingsContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...renderResult,
|
...renderResult,
|
||||||
props: combinedProps,
|
props: combinedProps,
|
||||||
mockConfig,
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('<ModelDialog />', () => {
|
describe('<ModelDialog />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Ensure env-based fallback models don't leak into this suite from the developer environment.
|
||||||
|
delete process.env['OPENAI_MODEL'];
|
||||||
|
delete process.env['ANTHROPIC_MODEL'];
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -91,8 +112,12 @@ describe('<ModelDialog />', () => {
|
|||||||
|
|
||||||
const props = mockedSelect.mock.calls[0][0];
|
const props = mockedSelect.mock.calls[0][0];
|
||||||
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
|
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
|
||||||
expect(props.items[0].value).toBe(MAINLINE_CODER);
|
expect(props.items[0].value).toBe(
|
||||||
expect(props.items[1].value).toBe(MAINLINE_VLM);
|
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`,
|
||||||
|
);
|
||||||
|
expect(props.items[1].value).toBe(
|
||||||
|
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
|
||||||
|
);
|
||||||
expect(props.showNumbers).toBe(true);
|
expect(props.showNumbers).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,16 +164,93 @@ describe('<ModelDialog />', () => {
|
|||||||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
|
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => {
|
||||||
const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
|
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||||
|
|
||||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||||
expect(childOnSelect).toBeDefined();
|
expect(childOnSelect).toBeDefined();
|
||||||
|
|
||||||
childOnSelect(MAINLINE_CODER);
|
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
||||||
|
|
||||||
// Assert against the default mock provided by renderComponent
|
expect(mockConfig?.switchModel).toHaveBeenCalledWith(
|
||||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
|
AuthType.QWEN_OAUTH,
|
||||||
|
MAINLINE_CODER,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
reason: 'user_manual',
|
||||||
|
context: 'Model switched via /model dialog',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||||
|
SettingScope.User,
|
||||||
|
'model.name',
|
||||||
|
MAINLINE_CODER,
|
||||||
|
);
|
||||||
|
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||||
|
SettingScope.User,
|
||||||
|
'security.auth.selectedType',
|
||||||
|
AuthType.QWEN_OAUTH,
|
||||||
|
);
|
||||||
|
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls config.switchModel and persists authType+model when selecting a different authType', async () => {
|
||||||
|
const switchModel = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const getAuthType = vi.fn(() => AuthType.USE_OPENAI);
|
||||||
|
const getAvailableModelsForAuthType = vi.fn((t: AuthType) => {
|
||||||
|
if (t === AuthType.USE_OPENAI) {
|
||||||
|
return [{ id: 'gpt-4', label: 'GPT-4', authType: t }];
|
||||||
|
}
|
||||||
|
if (t === AuthType.QWEN_OAUTH) {
|
||||||
|
return AVAILABLE_MODELS_QWEN.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
label: m.label,
|
||||||
|
authType: AuthType.QWEN_OAUTH,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockConfigWithSwitchAuthType = {
|
||||||
|
getAuthType,
|
||||||
|
getModel: vi.fn(() => 'gpt-4'),
|
||||||
|
getContentGeneratorConfig: vi.fn(() => ({
|
||||||
|
authType: AuthType.QWEN_OAUTH,
|
||||||
|
model: MAINLINE_CODER,
|
||||||
|
})),
|
||||||
|
// Add switchModel to the mock object (not the type)
|
||||||
|
switchModel,
|
||||||
|
getAvailableModelsForAuthType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { props, mockSettings } = renderComponent(
|
||||||
|
{},
|
||||||
|
// Cast to Config to bypass type checking, matching the runtime behavior
|
||||||
|
mockConfigWithSwitchAuthType as unknown as Partial<Config>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||||
|
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
||||||
|
|
||||||
|
expect(switchModel).toHaveBeenCalledWith(
|
||||||
|
AuthType.QWEN_OAUTH,
|
||||||
|
MAINLINE_CODER,
|
||||||
|
{ requireCachedCredentials: true },
|
||||||
|
{
|
||||||
|
reason: 'user_manual',
|
||||||
|
context: 'AuthType+model switched via /model dialog',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||||
|
SettingScope.User,
|
||||||
|
'model.name',
|
||||||
|
MAINLINE_CODER,
|
||||||
|
);
|
||||||
|
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||||
|
SettingScope.User,
|
||||||
|
'security.auth.selectedType',
|
||||||
|
AuthType.QWEN_OAUTH,
|
||||||
|
);
|
||||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,17 +295,25 @@ describe('<ModelDialog />', () => {
|
|||||||
it('updates initialIndex when config context changes', () => {
|
it('updates initialIndex when config context changes', () => {
|
||||||
const mockGetModel = vi.fn(() => MAINLINE_CODER);
|
const mockGetModel = vi.fn(() => MAINLINE_CODER);
|
||||||
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
|
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
|
||||||
|
const mockSettings = {
|
||||||
|
isTrusted: true,
|
||||||
|
user: { settings: {} },
|
||||||
|
workspace: { settings: {} },
|
||||||
|
setValue: vi.fn(),
|
||||||
|
} as unknown as LoadedSettings;
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<ConfigContext.Provider
|
<SettingsContext.Provider value={mockSettings}>
|
||||||
value={
|
<ConfigContext.Provider
|
||||||
{
|
value={
|
||||||
getModel: mockGetModel,
|
{
|
||||||
getAuthType: mockGetAuthType,
|
getModel: mockGetModel,
|
||||||
} as unknown as Config
|
getAuthType: mockGetAuthType,
|
||||||
}
|
} as unknown as Config
|
||||||
>
|
}
|
||||||
<ModelDialog onClose={vi.fn()} />
|
>
|
||||||
</ConfigContext.Provider>,
|
<ModelDialog onClose={vi.fn()} />
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
</SettingsContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||||
@@ -215,9 +325,11 @@ describe('<ModelDialog />', () => {
|
|||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<ConfigContext.Provider value={newMockConfig}>
|
<SettingsContext.Provider value={mockSettings}>
|
||||||
<ModelDialog onClose={vi.fn()} />
|
<ConfigContext.Provider value={newMockConfig}>
|
||||||
</ConfigContext.Provider>,
|
<ModelDialog onClose={vi.fn()} />
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
</SettingsContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should be called at least twice: initial render + re-render after context change
|
// Should be called at least twice: initial render + re-render after context change
|
||||||
|
|||||||
@@ -5,52 +5,210 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
ModelSlashCommandEvent,
|
ModelSlashCommandEvent,
|
||||||
logModelSlashCommand,
|
logModelSlashCommand,
|
||||||
|
type ContentGeneratorConfig,
|
||||||
|
type ContentGeneratorConfigSource,
|
||||||
|
type ContentGeneratorConfigSources,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
|
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||||
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
import {
|
import {
|
||||||
getAvailableModelsForAuthType,
|
getAvailableModelsForAuthType,
|
||||||
MAINLINE_CODER,
|
MAINLINE_CODER,
|
||||||
} from '../models/availableModels.js';
|
} from '../models/availableModels.js';
|
||||||
|
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface ModelDialogProps {
|
interface ModelDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSourceBadge(
|
||||||
|
source: ContentGeneratorConfigSource | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!source) return undefined;
|
||||||
|
|
||||||
|
switch (source.kind) {
|
||||||
|
case 'cli':
|
||||||
|
return source.detail ? `CLI ${source.detail}` : 'CLI';
|
||||||
|
case 'env':
|
||||||
|
return source.envKey ? `ENV ${source.envKey}` : 'ENV';
|
||||||
|
case 'settings':
|
||||||
|
return source.settingsPath
|
||||||
|
? `Settings ${source.settingsPath}`
|
||||||
|
: 'Settings';
|
||||||
|
case 'modelProviders': {
|
||||||
|
const suffix =
|
||||||
|
source.authType && source.modelId
|
||||||
|
? `${source.authType}:${source.modelId}`
|
||||||
|
: source.authType
|
||||||
|
? `${source.authType}`
|
||||||
|
: source.modelId
|
||||||
|
? `${source.modelId}`
|
||||||
|
: '';
|
||||||
|
return suffix ? `ModelProviders ${suffix}` : 'ModelProviders';
|
||||||
|
}
|
||||||
|
case 'default':
|
||||||
|
return source.detail ? `Default ${source.detail}` : 'Default';
|
||||||
|
case 'computed':
|
||||||
|
return source.detail ? `Computed ${source.detail}` : 'Computed';
|
||||||
|
case 'programmatic':
|
||||||
|
return source.detail ? `Programmatic ${source.detail}` : 'Programmatic';
|
||||||
|
case 'unknown':
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources {
|
||||||
|
if (!config) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const maybe = config as {
|
||||||
|
getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources;
|
||||||
|
};
|
||||||
|
return maybe.getContentGeneratorConfigSources?.() ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskApiKey(apiKey: string | undefined): string {
|
||||||
|
if (!apiKey) return '(not set)';
|
||||||
|
const trimmed = apiKey.trim();
|
||||||
|
if (trimmed.length === 0) return '(not set)';
|
||||||
|
if (trimmed.length <= 6) return '***';
|
||||||
|
const head = trimmed.slice(0, 3);
|
||||||
|
const tail = trimmed.slice(-4);
|
||||||
|
return `${head}…${tail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistModelSelection(
|
||||||
|
settings: ReturnType<typeof useSettings>,
|
||||||
|
modelId: string,
|
||||||
|
): void {
|
||||||
|
const scope = getPersistScopeForModelSelection(settings);
|
||||||
|
settings.setValue(scope, 'model.name', modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAuthTypeSelection(
|
||||||
|
settings: ReturnType<typeof useSettings>,
|
||||||
|
authType: AuthType,
|
||||||
|
): void {
|
||||||
|
const scope = getPersistScopeForModelSelection(settings);
|
||||||
|
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
badge,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
badge?: string;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Box minWidth={12} flexShrink={0}>
|
||||||
|
<Text color={theme.text.secondary}>{label}:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} flexDirection="row" flexWrap="wrap">
|
||||||
|
<Text>{value}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{badge ? (
|
||||||
|
<Box>
|
||||||
|
<Box minWidth={12} flexShrink={0}>
|
||||||
|
<Text> </Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<Text color={theme.text.secondary}>{badge}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||||
const config = useContext(ConfigContext);
|
const config = useContext(ConfigContext);
|
||||||
|
const uiState = useContext(UIStateContext);
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
// Get auth type from config, default to QWEN_OAUTH if not available
|
// Local error state for displaying errors within the dialog
|
||||||
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Get available models based on auth type
|
const authType = config?.getAuthType();
|
||||||
const availableModels = useMemo(
|
const effectiveConfig =
|
||||||
() => getAvailableModelsForAuthType(authType),
|
(config?.getContentGeneratorConfig?.() as
|
||||||
[authType],
|
| ContentGeneratorConfig
|
||||||
);
|
| undefined) ?? undefined;
|
||||||
|
const sources = readSourcesFromConfig(config);
|
||||||
|
|
||||||
|
const availableModelEntries = useMemo(() => {
|
||||||
|
const allAuthTypes = Object.values(AuthType) as AuthType[];
|
||||||
|
const modelsByAuthType = allAuthTypes
|
||||||
|
.map((t) => ({
|
||||||
|
authType: t,
|
||||||
|
models: getAvailableModelsForAuthType(t, config ?? undefined),
|
||||||
|
}))
|
||||||
|
.filter((x) => x.models.length > 0);
|
||||||
|
|
||||||
|
// Fixed order: qwen-oauth first, then others in a stable order
|
||||||
|
const authTypeOrder: AuthType[] = [
|
||||||
|
AuthType.QWEN_OAUTH,
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
|
AuthType.USE_ANTHROPIC,
|
||||||
|
AuthType.USE_GEMINI,
|
||||||
|
AuthType.USE_VERTEX_AI,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter to only include authTypes that have models
|
||||||
|
const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType));
|
||||||
|
const orderedAuthTypes = authTypeOrder.filter((t) =>
|
||||||
|
availableAuthTypes.has(t),
|
||||||
|
);
|
||||||
|
|
||||||
|
return orderedAuthTypes.flatMap((t) => {
|
||||||
|
const models =
|
||||||
|
modelsByAuthType.find((x) => x.authType === t)?.models ?? [];
|
||||||
|
return models.map((m) => ({ authType: t, model: m }));
|
||||||
|
});
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
const MODEL_OPTIONS = useMemo(
|
const MODEL_OPTIONS = useMemo(
|
||||||
() =>
|
() =>
|
||||||
availableModels.map((model) => ({
|
availableModelEntries.map(({ authType: t2, model }) => {
|
||||||
value: model.id,
|
const value = `${t2}::${model.id}`;
|
||||||
title: model.label,
|
const title = (
|
||||||
description: model.description || '',
|
<Text>
|
||||||
key: model.id,
|
<Text bold color={theme.text.accent}>
|
||||||
})),
|
[{t2}]
|
||||||
[availableModels],
|
</Text>
|
||||||
|
<Text>{` ${model.label}`}</Text>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
const description = model.description || '';
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
key: value,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[availableModelEntries],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine the Preferred Model (read once when the dialog opens).
|
const preferredModelId = config?.getModel() || MAINLINE_CODER;
|
||||||
const preferredModel = config?.getModel() || MAINLINE_CODER;
|
const preferredKey = authType ? `${authType}::${preferredModelId}` : '';
|
||||||
|
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
@@ -61,25 +219,83 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
|||||||
{ isActive: true },
|
{ isActive: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate the initial index based on the preferred model.
|
const initialIndex = useMemo(() => {
|
||||||
const initialIndex = useMemo(
|
const index = MODEL_OPTIONS.findIndex(
|
||||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
|
(option) => option.value === preferredKey,
|
||||||
[MODEL_OPTIONS, preferredModel],
|
);
|
||||||
);
|
return index === -1 ? 0 : index;
|
||||||
|
}, [MODEL_OPTIONS, preferredKey]);
|
||||||
|
|
||||||
// Handle selection internally (Autonomous Dialog).
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(model: string) => {
|
async (selected: string) => {
|
||||||
|
// Clear any previous error
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
const sep = '::';
|
||||||
|
const idx = selected.indexOf(sep);
|
||||||
|
const selectedAuthType = (
|
||||||
|
idx >= 0 ? selected.slice(0, idx) : authType
|
||||||
|
) as AuthType;
|
||||||
|
const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected;
|
||||||
|
|
||||||
if (config) {
|
if (config) {
|
||||||
config.setModel(model);
|
try {
|
||||||
const event = new ModelSlashCommandEvent(model);
|
await config.switchModel(
|
||||||
|
selectedAuthType,
|
||||||
|
modelId,
|
||||||
|
selectedAuthType !== authType &&
|
||||||
|
selectedAuthType === AuthType.QWEN_OAUTH
|
||||||
|
? { requireCachedCredentials: true }
|
||||||
|
: undefined,
|
||||||
|
{
|
||||||
|
reason: 'user_manual',
|
||||||
|
context:
|
||||||
|
selectedAuthType === authType
|
||||||
|
? 'Model switched via /model dialog'
|
||||||
|
: 'AuthType+model switched via /model dialog',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const baseErrorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
setErrorMessage(
|
||||||
|
`Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const event = new ModelSlashCommandEvent(modelId);
|
||||||
logModelSlashCommand(config, event);
|
logModelSlashCommand(config, event);
|
||||||
|
|
||||||
|
const after = config.getContentGeneratorConfig?.() as
|
||||||
|
| ContentGeneratorConfig
|
||||||
|
| undefined;
|
||||||
|
const effectiveAuthType =
|
||||||
|
after?.authType ?? selectedAuthType ?? authType;
|
||||||
|
const effectiveModelId = after?.model ?? modelId;
|
||||||
|
|
||||||
|
persistModelSelection(settings, effectiveModelId);
|
||||||
|
persistAuthTypeSelection(settings, effectiveAuthType);
|
||||||
|
|
||||||
|
const baseUrl = after?.baseUrl ?? '(default)';
|
||||||
|
const maskedKey = maskApiKey(after?.apiKey);
|
||||||
|
uiState?.historyManager.addItem(
|
||||||
|
{
|
||||||
|
type: 'info',
|
||||||
|
text:
|
||||||
|
`authType: ${effectiveAuthType}\n` +
|
||||||
|
`Using model: ${effectiveModelId}\n` +
|
||||||
|
`Base URL: ${baseUrl}\n` +
|
||||||
|
`API key: ${maskedKey}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
[config, onClose],
|
[authType, config, onClose, settings, uiState, setErrorMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasModels = MODEL_OPTIONS.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
@@ -89,14 +305,73 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
|||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Text bold>{t('Select Model')}</Text>
|
<Text bold>{t('Select Model')}</Text>
|
||||||
<Box marginTop={1}>
|
|
||||||
<DescriptiveRadioButtonSelect
|
<Box marginTop={1} flexDirection="column">
|
||||||
items={MODEL_OPTIONS}
|
<Text color={theme.text.secondary}>
|
||||||
onSelect={handleSelect}
|
{t('Current (effective) configuration')}
|
||||||
initialIndex={initialIndex}
|
</Text>
|
||||||
showNumbers={true}
|
<Box flexDirection="column" marginTop={1}>
|
||||||
/>
|
<ConfigRow label="AuthType" value={authType} />
|
||||||
|
<ConfigRow
|
||||||
|
label="Model"
|
||||||
|
value={effectiveConfig?.model ?? config?.getModel?.() ?? ''}
|
||||||
|
badge={formatSourceBadge(sources['model'])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{authType !== AuthType.QWEN_OAUTH && (
|
||||||
|
<>
|
||||||
|
<ConfigRow
|
||||||
|
label="Base URL"
|
||||||
|
value={effectiveConfig?.baseUrl ?? ''}
|
||||||
|
badge={formatSourceBadge(sources['baseUrl'])}
|
||||||
|
/>
|
||||||
|
<ConfigRow
|
||||||
|
label="API Key"
|
||||||
|
value={effectiveConfig?.apiKey ? t('(set)') : t('(not set)')}
|
||||||
|
badge={formatSourceBadge(sources['apiKey'])}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{!hasModels ? (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={theme.status.warning}>
|
||||||
|
{t(
|
||||||
|
'No models available for the current authentication type ({{authType}}).',
|
||||||
|
{
|
||||||
|
authType: authType ? String(authType) : t('(none)'),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{t(
|
||||||
|
'Please configure models in settings.modelProviders or use environment variables.',
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<DescriptiveRadioButtonSelect
|
||||||
|
items={MODEL_OPTIONS}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
initialIndex={initialIndex}
|
||||||
|
showNumbers={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Box marginTop={1} flexDirection="column" paddingX={1}>
|
||||||
|
<Text color={theme.status.error} wrap="wrap">
|
||||||
|
✕ {errorMessage}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
|
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { render } from 'ink-testing-library';
|
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
|
||||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
|
||||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
|
||||||
|
|
||||||
// Mock the child component to make it easier to test the parent
|
|
||||||
vi.mock('./shared/RadioButtonSelect.js', () => ({
|
|
||||||
RadioButtonSelect: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('ProQuotaDialog', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with correct title and options', () => {
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<ProQuotaDialog
|
|
||||||
failedModel="gemini-2.5-pro"
|
|
||||||
fallbackModel="gemini-2.5-flash"
|
|
||||||
onChoice={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const output = lastFrame();
|
|
||||||
expect(output).toContain('Pro quota limit reached for gemini-2.5-pro.');
|
|
||||||
|
|
||||||
// Check that RadioButtonSelect was called with the correct items
|
|
||||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: 'Change auth (executes the /auth command)',
|
|
||||||
value: 'auth',
|
|
||||||
key: 'auth',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: `Continue with gemini-2.5-flash`,
|
|
||||||
value: 'continue',
|
|
||||||
key: 'continue',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onChoice with "auth" when "Change auth" is selected', () => {
|
|
||||||
const mockOnChoice = vi.fn();
|
|
||||||
render(
|
|
||||||
<ProQuotaDialog
|
|
||||||
failedModel="gemini-2.5-pro"
|
|
||||||
fallbackModel="gemini-2.5-flash"
|
|
||||||
onChoice={mockOnChoice}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the onSelect function passed to RadioButtonSelect
|
|
||||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
|
||||||
|
|
||||||
// Simulate the selection
|
|
||||||
onSelect('auth');
|
|
||||||
|
|
||||||
expect(mockOnChoice).toHaveBeenCalledWith('auth');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onChoice with "continue" when "Continue with flash" is selected', () => {
|
|
||||||
const mockOnChoice = vi.fn();
|
|
||||||
render(
|
|
||||||
<ProQuotaDialog
|
|
||||||
failedModel="gemini-2.5-pro"
|
|
||||||
fallbackModel="gemini-2.5-flash"
|
|
||||||
onChoice={mockOnChoice}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the onSelect function passed to RadioButtonSelect
|
|
||||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
|
||||||
|
|
||||||
// Simulate the selection
|
|
||||||
onSelect('continue');
|
|
||||||
|
|
||||||
expect(mockOnChoice).toHaveBeenCalledWith('continue');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type React from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
|
||||||
import { theme } from '../semantic-colors.js';
|
|
||||||
import { t } from '../../i18n/index.js';
|
|
||||||
|
|
||||||
interface ProQuotaDialogProps {
|
|
||||||
failedModel: string;
|
|
||||||
fallbackModel: string;
|
|
||||||
onChoice: (choice: 'auth' | 'continue') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProQuotaDialog({
|
|
||||||
failedModel,
|
|
||||||
fallbackModel,
|
|
||||||
onChoice,
|
|
||||||
}: ProQuotaDialogProps): React.JSX.Element {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
label: t('Change auth (executes the /auth command)'),
|
|
||||||
value: 'auth' as const,
|
|
||||||
key: 'auth',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('Continue with {{model}}', { model: fallbackModel }),
|
|
||||||
value: 'continue' as const,
|
|
||||||
key: 'continue',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSelect = (choice: 'auth' | 'continue') => {
|
|
||||||
onChoice(choice);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box borderStyle="round" flexDirection="column" paddingX={1}>
|
|
||||||
<Text bold color={theme.status.warning}>
|
|
||||||
{t('Pro quota limit reached for {{model}}.', { model: failedModel })}
|
|
||||||
</Text>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<RadioButtonSelect
|
|
||||||
items={items}
|
|
||||||
initialIndex={1}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -87,7 +87,13 @@ export async function showResumeSessionPicker(
|
|||||||
let selectedId: string | undefined;
|
let selectedId: string | undefined;
|
||||||
|
|
||||||
const { unmount, waitUntilExit } = render(
|
const { unmount, waitUntilExit } = render(
|
||||||
<KeypressProvider kittyProtocolEnabled={false}>
|
<KeypressProvider
|
||||||
|
kittyProtocolEnabled={false}
|
||||||
|
pasteWorkaround={
|
||||||
|
process.platform === 'win32' ||
|
||||||
|
parseInt(process.versions.node.split('.')[0], 10) < 20
|
||||||
|
}
|
||||||
|
>
|
||||||
<StandalonePickerScreen
|
<StandalonePickerScreen
|
||||||
sessionService={sessionService}
|
sessionService={sessionService}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { BaseSelectionList } from './BaseSelectionList.js';
|
|||||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||||
|
|
||||||
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
||||||
title: string;
|
title: React.ReactNode;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export interface UIActions {
|
|||||||
) => void;
|
) => void;
|
||||||
handleAuthSelect: (
|
handleAuthSelect: (
|
||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
scope: SettingScope,
|
|
||||||
credentials?: OpenAICredentials,
|
credentials?: OpenAICredentials,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
setAuthState: (state: AuthState) => void;
|
setAuthState: (state: AuthState) => void;
|
||||||
@@ -55,7 +54,6 @@ export interface UIActions {
|
|||||||
handleClearScreen: () => void;
|
handleClearScreen: () => void;
|
||||||
onWorkspaceMigrationDialogOpen: () => void;
|
onWorkspaceMigrationDialogOpen: () => void;
|
||||||
onWorkspaceMigrationDialogClose: () => void;
|
onWorkspaceMigrationDialogClose: () => void;
|
||||||
handleProQuotaChoice: (choice: 'auth' | 'continue') => void;
|
|
||||||
// Vision switch dialog
|
// Vision switch dialog
|
||||||
handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void;
|
handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void;
|
||||||
// Welcome back dialog
|
// Welcome back dialog
|
||||||
|
|||||||
@@ -22,21 +22,13 @@ import type {
|
|||||||
AuthType,
|
AuthType,
|
||||||
IdeContext,
|
IdeContext,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
UserTierId,
|
|
||||||
IdeInfo,
|
IdeInfo,
|
||||||
FallbackIntent,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { DOMElement } from 'ink';
|
import type { DOMElement } from 'ink';
|
||||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||||
import type { ExtensionUpdateState } from '../state/extensions.js';
|
import type { ExtensionUpdateState } from '../state/extensions.js';
|
||||||
import type { UpdateObject } from '../utils/updateCheck.js';
|
import type { UpdateObject } from '../utils/updateCheck.js';
|
||||||
|
|
||||||
export interface ProQuotaDialogRequest {
|
|
||||||
failedModel: string;
|
|
||||||
fallbackModel: string;
|
|
||||||
resolve: (intent: FallbackIntent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||||
|
|
||||||
@@ -99,8 +91,6 @@ export interface UIState {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
workspaceExtensions: any[]; // Extension[]
|
workspaceExtensions: any[]; // Extension[]
|
||||||
// Quota-related state
|
// Quota-related state
|
||||||
userTier: UserTierId | undefined;
|
|
||||||
proQuotaRequest: ProQuotaDialogRequest | null;
|
|
||||||
currentModel: string;
|
currentModel: string;
|
||||||
contextFileNames: string[];
|
contextFileNames: string[];
|
||||||
errorCount: number;
|
errorCount: number;
|
||||||
|
|||||||
@@ -520,6 +520,13 @@ export const useSlashCommandProcessor = (
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'stream_messages': {
|
||||||
|
// stream_messages is only used in ACP/Zed integration mode
|
||||||
|
// and should not be returned in interactive UI mode
|
||||||
|
throw new Error(
|
||||||
|
'stream_messages result type is not supported in interactive mode',
|
||||||
|
);
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
const unhandled: never = result;
|
const unhandled: never = result;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export interface DialogCloseOptions {
|
|||||||
isAuthDialogOpen: boolean;
|
isAuthDialogOpen: boolean;
|
||||||
handleAuthSelect: (
|
handleAuthSelect: (
|
||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
scope: SettingScope,
|
|
||||||
credentials?: OpenAICredentials,
|
credentials?: OpenAICredentials,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
pendingAuthType: AuthType | undefined;
|
pendingAuthType: AuthType | undefined;
|
||||||
|
|||||||
@@ -1323,7 +1323,7 @@ describe('useGeminiStream', () => {
|
|||||||
it('should call parseAndFormatApiError with the correct authType on stream initialization failure', async () => {
|
it('should call parseAndFormatApiError with the correct authType on stream initialization failure', async () => {
|
||||||
// 1. Setup
|
// 1. Setup
|
||||||
const mockError = new Error('Rate limit exceeded');
|
const mockError = new Error('Rate limit exceeded');
|
||||||
const mockAuthType = AuthType.LOGIN_WITH_GOOGLE;
|
const mockAuthType = AuthType.USE_VERTEX_AI;
|
||||||
mockParseAndFormatApiError.mockClear();
|
mockParseAndFormatApiError.mockClear();
|
||||||
mockSendMessageStream.mockReturnValue(
|
mockSendMessageStream.mockReturnValue(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
@@ -1374,9 +1374,6 @@ describe('useGeminiStream', () => {
|
|||||||
expect(mockParseAndFormatApiError).toHaveBeenCalledWith(
|
expect(mockParseAndFormatApiError).toHaveBeenCalledWith(
|
||||||
'Rate limit exceeded',
|
'Rate limit exceeded',
|
||||||
mockAuthType,
|
mockAuthType,
|
||||||
undefined,
|
|
||||||
'gemini-2.5-pro',
|
|
||||||
'gemini-2.5-flash',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2493,9 +2490,6 @@ describe('useGeminiStream', () => {
|
|||||||
expect(mockParseAndFormatApiError).toHaveBeenCalledWith(
|
expect(mockParseAndFormatApiError).toHaveBeenCalledWith(
|
||||||
{ message: 'Test error' },
|
{ message: 'Test error' },
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
undefined,
|
|
||||||
'gemini-2.5-pro',
|
|
||||||
'gemini-2.5-flash',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
GitService,
|
GitService,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
UserPromptEvent,
|
UserPromptEvent,
|
||||||
DEFAULT_GEMINI_FLASH_MODEL,
|
|
||||||
logConversationFinishedEvent,
|
logConversationFinishedEvent,
|
||||||
ConversationFinishedEvent,
|
ConversationFinishedEvent,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
@@ -527,10 +526,15 @@ export const useGeminiStream = (
|
|||||||
return currentThoughtBuffer;
|
return currentThoughtBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newThoughtBuffer = currentThoughtBuffer + thoughtText;
|
let newThoughtBuffer = currentThoughtBuffer + thoughtText;
|
||||||
|
|
||||||
|
const pendingType = pendingHistoryItemRef.current?.type;
|
||||||
|
const isPendingThought =
|
||||||
|
pendingType === 'gemini_thought' ||
|
||||||
|
pendingType === 'gemini_thought_content';
|
||||||
|
|
||||||
// If we're not already showing a thought, start a new one
|
// If we're not already showing a thought, start a new one
|
||||||
if (pendingHistoryItemRef.current?.type !== 'gemini_thought') {
|
if (!isPendingThought) {
|
||||||
// If there's a pending non-thought item, finalize it first
|
// If there's a pending non-thought item, finalize it first
|
||||||
if (pendingHistoryItemRef.current) {
|
if (pendingHistoryItemRef.current) {
|
||||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||||
@@ -538,11 +542,37 @@ export const useGeminiStream = (
|
|||||||
setPendingHistoryItem({ type: 'gemini_thought', text: '' });
|
setPendingHistoryItem({ type: 'gemini_thought', text: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the existing thought message with accumulated content
|
// Split large thought messages for better rendering performance (same rationale
|
||||||
setPendingHistoryItem({
|
// as regular content streaming). This helps avoid terminal flicker caused by
|
||||||
type: 'gemini_thought',
|
// constantly re-rendering an ever-growing "pending" block.
|
||||||
text: newThoughtBuffer,
|
const splitPoint = findLastSafeSplitPoint(newThoughtBuffer);
|
||||||
});
|
const nextPendingType: 'gemini_thought' | 'gemini_thought_content' =
|
||||||
|
isPendingThought && pendingType === 'gemini_thought_content'
|
||||||
|
? 'gemini_thought_content'
|
||||||
|
: 'gemini_thought';
|
||||||
|
|
||||||
|
if (splitPoint === newThoughtBuffer.length) {
|
||||||
|
// Update the existing thought message with accumulated content
|
||||||
|
setPendingHistoryItem({
|
||||||
|
type: nextPendingType,
|
||||||
|
text: newThoughtBuffer,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const beforeText = newThoughtBuffer.substring(0, splitPoint);
|
||||||
|
const afterText = newThoughtBuffer.substring(splitPoint);
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type: nextPendingType,
|
||||||
|
text: beforeText,
|
||||||
|
},
|
||||||
|
userMessageTimestamp,
|
||||||
|
);
|
||||||
|
setPendingHistoryItem({
|
||||||
|
type: 'gemini_thought_content',
|
||||||
|
text: afterText,
|
||||||
|
});
|
||||||
|
newThoughtBuffer = afterText;
|
||||||
|
}
|
||||||
|
|
||||||
// Also update the thought state for the loading indicator
|
// Also update the thought state for the loading indicator
|
||||||
mergeThought(eventValue);
|
mergeThought(eventValue);
|
||||||
@@ -600,9 +630,6 @@ export const useGeminiStream = (
|
|||||||
text: parseAndFormatApiError(
|
text: parseAndFormatApiError(
|
||||||
eventValue.error,
|
eventValue.error,
|
||||||
config.getContentGeneratorConfig()?.authType,
|
config.getContentGeneratorConfig()?.authType,
|
||||||
undefined,
|
|
||||||
config.getModel(),
|
|
||||||
DEFAULT_GEMINI_FLASH_MODEL,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
@@ -654,6 +681,9 @@ export const useGeminiStream = (
|
|||||||
'Response stopped due to image safety violations.',
|
'Response stopped due to image safety violations.',
|
||||||
[FinishReason.UNEXPECTED_TOOL_CALL]:
|
[FinishReason.UNEXPECTED_TOOL_CALL]:
|
||||||
'Response stopped due to unexpected tool call.',
|
'Response stopped due to unexpected tool call.',
|
||||||
|
[FinishReason.IMAGE_PROHIBITED_CONTENT]:
|
||||||
|
'Response stopped due to image prohibited content.',
|
||||||
|
[FinishReason.NO_IMAGE]: 'Response stopped due to no image.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const message = finishReasonMessages[finishReason];
|
const message = finishReasonMessages[finishReason];
|
||||||
@@ -770,11 +800,17 @@ export const useGeminiStream = (
|
|||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case ServerGeminiEventType.Thought:
|
case ServerGeminiEventType.Thought:
|
||||||
thoughtBuffer = handleThoughtEvent(
|
// If the thought has a subject, it's a discrete status update rather than
|
||||||
event.value,
|
// a streamed textual thought, so we update the thought state directly.
|
||||||
thoughtBuffer,
|
if (event.value.subject) {
|
||||||
userMessageTimestamp,
|
setThought(event.value);
|
||||||
);
|
} else {
|
||||||
|
thoughtBuffer = handleThoughtEvent(
|
||||||
|
event.value,
|
||||||
|
thoughtBuffer,
|
||||||
|
userMessageTimestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case ServerGeminiEventType.Content:
|
case ServerGeminiEventType.Content:
|
||||||
geminiMessageBuffer = handleContentEvent(
|
geminiMessageBuffer = handleContentEvent(
|
||||||
@@ -845,6 +881,7 @@ export const useGeminiStream = (
|
|||||||
handleMaxSessionTurnsEvent,
|
handleMaxSessionTurnsEvent,
|
||||||
handleSessionTokenLimitExceededEvent,
|
handleSessionTokenLimitExceededEvent,
|
||||||
handleCitationEvent,
|
handleCitationEvent,
|
||||||
|
setThought,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -875,7 +912,7 @@ export const useGeminiStream = (
|
|||||||
// Reset quota error flag when starting a new query (not a continuation)
|
// Reset quota error flag when starting a new query (not a continuation)
|
||||||
if (!options?.isContinuation) {
|
if (!options?.isContinuation) {
|
||||||
setModelSwitchedFromQuotaError(false);
|
setModelSwitchedFromQuotaError(false);
|
||||||
config.setQuotaErrorOccurred(false);
|
// No quota-error / fallback routing mechanism currently; keep state minimal.
|
||||||
}
|
}
|
||||||
|
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
@@ -987,9 +1024,6 @@ export const useGeminiStream = (
|
|||||||
text: parseAndFormatApiError(
|
text: parseAndFormatApiError(
|
||||||
getErrorMessage(error) || 'Unknown error',
|
getErrorMessage(error) || 'Unknown error',
|
||||||
config.getContentGeneratorConfig()?.authType,
|
config.getContentGeneratorConfig()?.authType,
|
||||||
undefined,
|
|
||||||
config.getModel(),
|
|
||||||
DEFAULT_GEMINI_FLASH_MODEL,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
|
|||||||
@@ -1,21 +1,58 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useStdin } from 'ink';
|
import { useStdin } from 'ink';
|
||||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
editorCommands,
|
||||||
|
commandExists as coreCommandExists,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { spawnSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for command existence checks to avoid repeated execSync calls.
|
||||||
|
*/
|
||||||
|
const commandExistsCache = new Map<string, boolean>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a command exists in the system with caching.
|
||||||
|
* Results are cached to improve performance in test environments.
|
||||||
|
*/
|
||||||
|
function commandExists(cmd: string): boolean {
|
||||||
|
if (commandExistsCache.has(cmd)) {
|
||||||
|
return commandExistsCache.get(cmd)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = coreCommandExists(cmd);
|
||||||
|
commandExistsCache.set(cmd, exists);
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get the actual executable command for an editor type.
|
||||||
|
*/
|
||||||
|
function getExecutableCommand(editorType: EditorType): string {
|
||||||
|
const commandConfig = editorCommands[editorType];
|
||||||
|
const commands =
|
||||||
|
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
||||||
|
|
||||||
|
const availableCommand = commands.find((cmd) => commandExists(cmd));
|
||||||
|
|
||||||
|
if (!availableCommand) {
|
||||||
|
throw new Error(
|
||||||
|
`No available editor command found for ${editorType}. ` +
|
||||||
|
`Tried: ${commands.join(', ')}. ` +
|
||||||
|
`Please install one of these editors or set a different preferredEditor in settings.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableCommand;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the editor command to use based on user preferences and platform.
|
* Determines the editor command to use based on user preferences and platform.
|
||||||
*/
|
*/
|
||||||
function getEditorCommand(preferredEditor?: EditorType): string {
|
function getEditorCommand(preferredEditor?: EditorType): string {
|
||||||
if (preferredEditor) {
|
if (preferredEditor) {
|
||||||
return preferredEditor;
|
return getExecutableCommand(preferredEditor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform-specific defaults with UI preference for macOS
|
// Platform-specific defaults with UI preference for macOS
|
||||||
@@ -63,8 +100,14 @@ export function useLaunchEditor() {
|
|||||||
try {
|
try {
|
||||||
setRawMode?.(false);
|
setRawMode?.(false);
|
||||||
|
|
||||||
|
// On Windows, .cmd and .bat files need shell: true
|
||||||
|
const needsShell =
|
||||||
|
process.platform === 'win32' &&
|
||||||
|
(editorCommand.endsWith('.cmd') || editorCommand.endsWith('.bat'));
|
||||||
|
|
||||||
const { status, error } = spawnSync(editorCommand, editorArgs, {
|
const { status, error } = spawnSync(editorCommand, editorArgs, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
|
shell: needsShell,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|||||||
@@ -1,391 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
vi,
|
|
||||||
describe,
|
|
||||||
it,
|
|
||||||
expect,
|
|
||||||
beforeEach,
|
|
||||||
afterEach,
|
|
||||||
type Mock,
|
|
||||||
} from 'vitest';
|
|
||||||
import { act, renderHook } from '@testing-library/react';
|
|
||||||
import {
|
|
||||||
type Config,
|
|
||||||
type FallbackModelHandler,
|
|
||||||
UserTierId,
|
|
||||||
AuthType,
|
|
||||||
isGenericQuotaExceededError,
|
|
||||||
isProQuotaExceededError,
|
|
||||||
makeFakeConfig,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { useQuotaAndFallback } from './useQuotaAndFallback.js';
|
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
|
||||||
import { AuthState, MessageType } from '../types.js';
|
|
||||||
|
|
||||||
// Mock the error checking functions from the core package to control test scenarios
|
|
||||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|
||||||
const original =
|
|
||||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
|
||||||
return {
|
|
||||||
...original,
|
|
||||||
isGenericQuotaExceededError: vi.fn(),
|
|
||||||
isProQuotaExceededError: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use a type alias for SpyInstance as it's not directly exported
|
|
||||||
type SpyInstance = ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
describe('useQuotaAndFallback', () => {
|
|
||||||
let mockConfig: Config;
|
|
||||||
let mockHistoryManager: UseHistoryManagerReturn;
|
|
||||||
let mockSetAuthState: Mock;
|
|
||||||
let mockSetModelSwitchedFromQuotaError: Mock;
|
|
||||||
let setFallbackHandlerSpy: SpyInstance;
|
|
||||||
|
|
||||||
const mockedIsGenericQuotaExceededError = isGenericQuotaExceededError as Mock;
|
|
||||||
const mockedIsProQuotaExceededError = isProQuotaExceededError as Mock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockConfig = makeFakeConfig();
|
|
||||||
|
|
||||||
// Spy on the method that requires the private field and mock its return.
|
|
||||||
// This is cleaner than modifying the config class for tests.
|
|
||||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
|
||||||
model: 'test-model',
|
|
||||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
|
||||||
});
|
|
||||||
|
|
||||||
mockHistoryManager = {
|
|
||||||
addItem: vi.fn(),
|
|
||||||
history: [],
|
|
||||||
updateItem: vi.fn(),
|
|
||||||
clearItems: vi.fn(),
|
|
||||||
loadHistory: vi.fn(),
|
|
||||||
};
|
|
||||||
mockSetAuthState = vi.fn();
|
|
||||||
mockSetModelSwitchedFromQuotaError = vi.fn();
|
|
||||||
|
|
||||||
setFallbackHandlerSpy = vi.spyOn(mockConfig, 'setFallbackModelHandler');
|
|
||||||
vi.spyOn(mockConfig, 'setQuotaErrorOccurred');
|
|
||||||
|
|
||||||
mockedIsGenericQuotaExceededError.mockReturnValue(false);
|
|
||||||
mockedIsProQuotaExceededError.mockReturnValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should register a fallback handler on initialization', () => {
|
|
||||||
renderHook(() =>
|
|
||||||
useQuotaAndFallback({
|
|
||||||
config: mockConfig,
|
|
||||||
historyManager: mockHistoryManager,
|
|
||||||
userTier: UserTierId.FREE,
|
|
||||||
setAuthState: mockSetAuthState,
|
|
||||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(setFallbackHandlerSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setFallbackHandlerSpy.mock.calls[0][0]).toBeInstanceOf(Function);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Fallback Handler Logic', () => {
|
|
||||||
// Helper function to render the hook and extract the registered handler
|
|
||||||
const getRegisteredHandler = (
|
|
||||||
userTier: UserTierId = UserTierId.FREE,
|
|
||||||
): FallbackModelHandler => {
|
|
||||||
renderHook(
|
|
||||||
(props) =>
|
|
||||||
useQuotaAndFallback({
|
|
||||||
config: mockConfig,
|
|
||||||
historyManager: mockHistoryManager,
|
|
||||||
userTier: props.userTier,
|
|
||||||
setAuthState: mockSetAuthState,
|
|
||||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
|
||||||
}),
|
|
||||||
{ initialProps: { userTier } },
|
|
||||||
);
|
|
||||||
return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler;
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should return null and take no action if already in fallback mode', async () => {
|
|
||||||
vi.spyOn(mockConfig, 'isInFallbackMode').mockReturnValue(true);
|
|
||||||
const handler = getRegisteredHandler();
|
|
||||||
const result = await handler('gemini-pro', 'gemini-flash', new Error());
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null and take no action if authType is not LOGIN_WITH_GOOGLE', async () => {
|
|
||||||
// Override the default mock from beforeEach for this specific test
|
|
||||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
|
||||||
model: 'test-model',
|
|
||||||
authType: AuthType.USE_GEMINI,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handler = getRegisteredHandler();
|
|
||||||
const result = await handler('gemini-pro', 'gemini-flash', new Error());
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Automatic Fallback Scenarios', () => {
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
errorType: 'generic',
|
|
||||||
tier: UserTierId.FREE,
|
|
||||||
expectedMessageSnippets: [
|
|
||||||
'Automatically switching from model-A to model-B',
|
|
||||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
errorType: 'generic',
|
|
||||||
tier: UserTierId.STANDARD, // Paid tier
|
|
||||||
expectedMessageSnippets: [
|
|
||||||
'Automatically switching from model-A to model-B',
|
|
||||||
'switch to using a paid API key from AI Studio',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
errorType: 'other',
|
|
||||||
tier: UserTierId.FREE,
|
|
||||||
expectedMessageSnippets: [
|
|
||||||
'Automatically switching from model-A to model-B for faster responses',
|
|
||||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
errorType: 'other',
|
|
||||||
tier: UserTierId.LEGACY, // Paid tier
|
|
||||||
expectedMessageSnippets: [
|
|
||||||
'Automatically switching from model-A to model-B for faster responses',
|
|
||||||
'switch to using a paid API key from AI Studio',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { errorType, tier, expectedMessageSnippets } of testCases) {
|
|
||||||
it(`should handle ${errorType} error for ${tier} tier correctly`, async () => {
|
|
||||||
mockedIsGenericQuotaExceededError.mockReturnValue(
|
|
||||||
errorType === 'generic',
|
|
||||||
);
|
|
||||||
|
|
||||||
const handler = getRegisteredHandler(tier);
|
|
||||||
const result = await handler(
|
|
||||||
'model-A',
|
|
||||||
'model-B',
|
|
||||||
new Error('quota exceeded'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Automatic fallbacks should return 'stop'
|
|
||||||
expect(result).toBe('stop');
|
|
||||||
|
|
||||||
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ type: MessageType.INFO }),
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
|
|
||||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
|
||||||
.text;
|
|
||||||
for (const snippet of expectedMessageSnippets) {
|
|
||||||
expect(message).toContain(snippet);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mockSetModelSwitchedFromQuotaError).toHaveBeenCalledWith(true);
|
|
||||||
expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Interactive Fallback (Pro Quota Error)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockedIsProQuotaExceededError.mockReturnValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set an interactive request and wait for user choice', async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useQuotaAndFallback({
|
|
||||||
config: mockConfig,
|
|
||||||
historyManager: mockHistoryManager,
|
|
||||||
userTier: UserTierId.FREE,
|
|
||||||
setAuthState: mockSetAuthState,
|
|
||||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handler = setFallbackHandlerSpy.mock
|
|
||||||
.calls[0][0] as FallbackModelHandler;
|
|
||||||
|
|
||||||
// Call the handler but do not await it, to check the intermediate state
|
|
||||||
const promise = handler(
|
|
||||||
'gemini-pro',
|
|
||||||
'gemini-flash',
|
|
||||||
new Error('pro quota'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await act(async () => {});
|
|
||||||
|
|
||||||
// The hook should now have a pending request for the UI to handle
|
|
||||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
|
||||||
expect(result.current.proQuotaRequest?.failedModel).toBe('gemini-pro');
|
|
||||||
|
|
||||||
// Simulate the user choosing to continue with the fallback model
|
|
||||||
act(() => {
|
|
||||||
result.current.handleProQuotaChoice('continue');
|
|
||||||
});
|
|
||||||
|
|
||||||
// The original promise from the handler should now resolve
|
|
||||||
const intent = await promise;
|
|
||||||
expect(intent).toBe('retry');
|
|
||||||
|
|
||||||
// The pending request should be cleared from the state
|
|
||||||
expect(result.current.proQuotaRequest).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle race conditions by stopping subsequent requests', async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useQuotaAndFallback({
|
|
||||||
config: mockConfig,
|
|
||||||
historyManager: mockHistoryManager,
|
|
||||||
userTier: UserTierId.FREE,
|
|
||||||
setAuthState: mockSetAuthState,
|
|
||||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handler = setFallbackHandlerSpy.mock
|
|
||||||
.calls[0][0] as FallbackModelHandler;
|
|
||||||
|
|
||||||
const promise1 = handler(
|
|
||||||
'gemini-pro',
|
|
||||||
'gemini-flash',
|
|
||||||
new Error('pro quota 1'),
|
|
||||||
);
|
|
||||||
await act(async () => {});
|
|
||||||
|
|
||||||
const firstRequest = result.current.proQuotaRequest;
|
|
||||||
expect(firstRequest).not.toBeNull();
|
|
||||||
|
|
||||||
const result2 = await handler(
|
|
||||||
'gemini-pro',
|
|
||||||
'gemini-flash',
|
|
||||||
new Error('pro quota 2'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The lock should have stopped the second request
|
|
||||||
expect(result2).toBe('stop');
|
|
||||||
expect(result.current.proQuotaRequest).toBe(firstRequest);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleProQuotaChoice('continue');
|
|
||||||
});
|
|
||||||
|
|
||||||
const intent1 = await promise1;
|
|
||||||
expect(intent1).toBe('retry');
|
|
||||||
expect(result.current.proQuotaRequest).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('handleProQuotaChoice', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockedIsProQuotaExceededError.mockReturnValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing if there is no pending pro quota request', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useQuotaAndFallback({
|
|
||||||
config: mockConfig,
|
|
||||||
historyManager: mockHistoryManager,
|
|
||||||
userTier: UserTierId.FREE,
|
|
||||||
setAuthState: mockSetAuthState,
|
|
||||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleProQuotaChoice('auth');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSetAuthState).not.toHaveBeenCalled();
|
|
||||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve intent to "auth" and trigger auth state update', async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useQuotaAndFallback({
|
|
||||||
config: mockConfig,
|
|
||||||
historyManager: mockHistoryManager,
|
|
||||||
userTier: UserTierId.FREE,
|
|
||||||
setAuthState: mockSetAuthState,
|
|
||||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handler = setFallbackHandlerSpy.mock
|
|
||||||
.calls[0][0] as FallbackModelHandler;
|
|
||||||
const promise = handler(
|
|
||||||
'gemini-pro',
|
|
||||||
'gemini-flash',
|
|
||||||
new Error('pro quota'),
|
|
||||||
);
|
|
||||||
await act(async () => {}); // Allow state to update
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleProQuotaChoice('auth');
|
|
||||||
});
|
|
||||||
|
|
||||||
const intent = await promise;
|
|
||||||
expect(intent).toBe('auth');
|
|
||||||
expect(mockSetAuthState).toHaveBeenCalledWith(AuthState.Updating);
|
|
||||||
expect(result.current.proQuotaRequest).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve intent to "retry" and add info message on continue', async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useQuotaAndFallback({
|
|
||||||
config: mockConfig,
|
|
||||||
historyManager: mockHistoryManager,
|
|
||||||
userTier: UserTierId.FREE,
|
|
||||||
setAuthState: mockSetAuthState,
|
|
||||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handler = setFallbackHandlerSpy.mock
|
|
||||||
.calls[0][0] as FallbackModelHandler;
|
|
||||||
// The first `addItem` call is for the initial quota error message
|
|
||||||
const promise = handler(
|
|
||||||
'gemini-pro',
|
|
||||||
'gemini-flash',
|
|
||||||
new Error('pro quota'),
|
|
||||||
);
|
|
||||||
await act(async () => {}); // Allow state to update
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleProQuotaChoice('continue');
|
|
||||||
});
|
|
||||||
|
|
||||||
const intent = await promise;
|
|
||||||
expect(intent).toBe('retry');
|
|
||||||
expect(result.current.proQuotaRequest).toBeNull();
|
|
||||||
|
|
||||||
// Check for the second "Switched to fallback model" message
|
|
||||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
|
||||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[1][0];
|
|
||||||
expect(lastCall.type).toBe(MessageType.INFO);
|
|
||||||
expect(lastCall.text).toContain('Switched to fallback model.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
AuthType,
|
|
||||||
type Config,
|
|
||||||
type FallbackModelHandler,
|
|
||||||
type FallbackIntent,
|
|
||||||
isGenericQuotaExceededError,
|
|
||||||
isProQuotaExceededError,
|
|
||||||
UserTierId,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
|
|
||||||
import { AuthState, MessageType } from '../types.js';
|
|
||||||
import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js';
|
|
||||||
|
|
||||||
interface UseQuotaAndFallbackArgs {
|
|
||||||
config: Config;
|
|
||||||
historyManager: UseHistoryManagerReturn;
|
|
||||||
userTier: UserTierId | undefined;
|
|
||||||
setAuthState: (state: AuthState) => void;
|
|
||||||
setModelSwitchedFromQuotaError: (value: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useQuotaAndFallback({
|
|
||||||
config,
|
|
||||||
historyManager,
|
|
||||||
userTier,
|
|
||||||
setAuthState,
|
|
||||||
setModelSwitchedFromQuotaError,
|
|
||||||
}: UseQuotaAndFallbackArgs) {
|
|
||||||
const [proQuotaRequest, setProQuotaRequest] =
|
|
||||||
useState<ProQuotaDialogRequest | null>(null);
|
|
||||||
const isDialogPending = useRef(false);
|
|
||||||
|
|
||||||
// Set up Flash fallback handler
|
|
||||||
useEffect(() => {
|
|
||||||
const fallbackHandler: FallbackModelHandler = async (
|
|
||||||
failedModel,
|
|
||||||
fallbackModel,
|
|
||||||
error,
|
|
||||||
): Promise<FallbackIntent | null> => {
|
|
||||||
if (config.isInFallbackMode()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallbacks are currently only handled for OAuth users.
|
|
||||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
|
||||||
if (
|
|
||||||
!contentGeneratorConfig ||
|
|
||||||
contentGeneratorConfig.authType !== AuthType.LOGIN_WITH_GOOGLE
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
|
|
||||||
const isPaidTier =
|
|
||||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
|
||||||
|
|
||||||
let message: string;
|
|
||||||
|
|
||||||
if (error && isProQuotaExceededError(error)) {
|
|
||||||
// Pro Quota specific messages (Interactive)
|
|
||||||
if (isPaidTier) {
|
|
||||||
message = `⚡ You have reached your daily ${failedModel} quota limit.
|
|
||||||
⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
|
|
||||||
⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
|
||||||
} else {
|
|
||||||
message = `⚡ You have reached your daily ${failedModel} quota limit.
|
|
||||||
⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
|
|
||||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
|
||||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
|
||||||
⚡ You can switch authentication methods by typing /auth`;
|
|
||||||
}
|
|
||||||
} else if (error && isGenericQuotaExceededError(error)) {
|
|
||||||
// Generic Quota (Automatic fallback)
|
|
||||||
const actionMessage = `⚡ You have reached your daily quota limit.\n⚡ Automatically switching from ${failedModel} to ${fallbackModel} for the remainder of this session.`;
|
|
||||||
|
|
||||||
if (isPaidTier) {
|
|
||||||
message = `${actionMessage}
|
|
||||||
⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
|
||||||
} else {
|
|
||||||
message = `${actionMessage}
|
|
||||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
|
||||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
|
||||||
⚡ You can switch authentication methods by typing /auth`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Consecutive 429s or other errors (Automatic fallback)
|
|
||||||
const actionMessage = `⚡ Automatically switching from ${failedModel} to ${fallbackModel} for faster responses for the remainder of this session.`;
|
|
||||||
|
|
||||||
if (isPaidTier) {
|
|
||||||
message = `${actionMessage}
|
|
||||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${failedModel} quota limit
|
|
||||||
⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
|
||||||
} else {
|
|
||||||
message = `${actionMessage}
|
|
||||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${failedModel} quota limit
|
|
||||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
|
||||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
|
||||||
⚡ You can switch authentication methods by typing /auth`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add message to UI history
|
|
||||||
historyManager.addItem(
|
|
||||||
{
|
|
||||||
type: MessageType.INFO,
|
|
||||||
text: message,
|
|
||||||
},
|
|
||||||
Date.now(),
|
|
||||||
);
|
|
||||||
|
|
||||||
setModelSwitchedFromQuotaError(true);
|
|
||||||
config.setQuotaErrorOccurred(true);
|
|
||||||
|
|
||||||
// Interactive Fallback for Pro quota
|
|
||||||
if (error && isProQuotaExceededError(error)) {
|
|
||||||
if (isDialogPending.current) {
|
|
||||||
return 'stop'; // A dialog is already active, so just stop this request.
|
|
||||||
}
|
|
||||||
isDialogPending.current = true;
|
|
||||||
|
|
||||||
const intent: FallbackIntent = await new Promise<FallbackIntent>(
|
|
||||||
(resolve) => {
|
|
||||||
setProQuotaRequest({
|
|
||||||
failedModel,
|
|
||||||
fallbackModel,
|
|
||||||
resolve,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return intent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'stop';
|
|
||||||
};
|
|
||||||
|
|
||||||
config.setFallbackModelHandler(fallbackHandler);
|
|
||||||
}, [config, historyManager, userTier, setModelSwitchedFromQuotaError]);
|
|
||||||
|
|
||||||
const handleProQuotaChoice = useCallback(
|
|
||||||
(choice: 'auth' | 'continue') => {
|
|
||||||
if (!proQuotaRequest) return;
|
|
||||||
|
|
||||||
const intent: FallbackIntent = choice === 'auth' ? 'auth' : 'retry';
|
|
||||||
proQuotaRequest.resolve(intent);
|
|
||||||
setProQuotaRequest(null);
|
|
||||||
isDialogPending.current = false; // Reset the flag here
|
|
||||||
|
|
||||||
if (choice === 'auth') {
|
|
||||||
setAuthState(AuthState.Updating);
|
|
||||||
} else {
|
|
||||||
historyManager.addItem(
|
|
||||||
{
|
|
||||||
type: MessageType.INFO,
|
|
||||||
text: 'Switched to fallback model. Tip: Press Ctrl+P (or Up Arrow) to recall your previous prompt and submit it again if you wish.',
|
|
||||||
},
|
|
||||||
Date.now(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[proQuotaRequest, setAuthState, historyManager],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
proQuotaRequest,
|
|
||||||
handleProQuotaChoice,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -411,7 +411,7 @@ describe('useQwenAuth', () => {
|
|||||||
expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle');
|
expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||||
|
|
||||||
const { result: oauthResult } = renderHook(() =>
|
const { result: oauthResult } = renderHook(() =>
|
||||||
useQwenAuth(AuthType.LOGIN_WITH_GOOGLE, true),
|
useQwenAuth(AuthType.USE_OPENAI, true),
|
||||||
);
|
);
|
||||||
expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle');
|
expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const mockConfig = {
|
|||||||
getAllowedTools: vi.fn(() => []),
|
getAllowedTools: vi.fn(() => []),
|
||||||
getContentGeneratorConfig: () => ({
|
getContentGeneratorConfig: () => ({
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
authType: 'oauth-personal',
|
authType: 'gemini',
|
||||||
}),
|
}),
|
||||||
getUseSmartEdit: () => false,
|
getUseSmartEdit: () => false,
|
||||||
getUseModelRouter: () => false,
|
getUseModelRouter: () => false,
|
||||||
|
|||||||
205
packages/cli/src/ui/models/availableModels.test.ts
Normal file
205
packages/cli/src/ui/models/availableModels.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
getAvailableModelsForAuthType,
|
||||||
|
getFilteredQwenModels,
|
||||||
|
getOpenAIAvailableModelFromEnv,
|
||||||
|
isVisionModel,
|
||||||
|
getDefaultVisionModel,
|
||||||
|
AVAILABLE_MODELS_QWEN,
|
||||||
|
MAINLINE_VLM,
|
||||||
|
MAINLINE_CODER,
|
||||||
|
} from './availableModels.js';
|
||||||
|
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
|
describe('availableModels', () => {
|
||||||
|
describe('AVAILABLE_MODELS_QWEN', () => {
|
||||||
|
it('should include coder model', () => {
|
||||||
|
const coderModel = AVAILABLE_MODELS_QWEN.find(
|
||||||
|
(m) => m.id === MAINLINE_CODER,
|
||||||
|
);
|
||||||
|
expect(coderModel).toBeDefined();
|
||||||
|
expect(coderModel?.isVision).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include vision model', () => {
|
||||||
|
const visionModel = AVAILABLE_MODELS_QWEN.find(
|
||||||
|
(m) => m.id === MAINLINE_VLM,
|
||||||
|
);
|
||||||
|
expect(visionModel).toBeDefined();
|
||||||
|
expect(visionModel?.isVision).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFilteredQwenModels', () => {
|
||||||
|
it('should return all models when vision preview is enabled', () => {
|
||||||
|
const models = getFilteredQwenModels(true);
|
||||||
|
expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out vision models when preview is disabled', () => {
|
||||||
|
const models = getFilteredQwenModels(false);
|
||||||
|
expect(models.every((m) => !m.isVision)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOpenAIAvailableModelFromEnv', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when OPENAI_MODEL is not set', () => {
|
||||||
|
delete process.env['OPENAI_MODEL'];
|
||||||
|
expect(getOpenAIAvailableModelFromEnv()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return model from OPENAI_MODEL env var', () => {
|
||||||
|
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
|
||||||
|
const model = getOpenAIAvailableModelFromEnv();
|
||||||
|
expect(model?.id).toBe('gpt-4-turbo');
|
||||||
|
expect(model?.label).toBe('gpt-4-turbo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from env var', () => {
|
||||||
|
process.env['OPENAI_MODEL'] = ' gpt-4 ';
|
||||||
|
const model = getOpenAIAvailableModelFromEnv();
|
||||||
|
expect(model?.id).toBe('gpt-4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAvailableModelsForAuthType', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return hard-coded qwen models for qwen-oauth', () => {
|
||||||
|
const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH);
|
||||||
|
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return hard-coded qwen models even when config is provided', () => {
|
||||||
|
const mockConfig = {
|
||||||
|
getAvailableModels: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([
|
||||||
|
{ id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH },
|
||||||
|
]),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
const models = getAvailableModelsForAuthType(
|
||||||
|
AuthType.QWEN_OAUTH,
|
||||||
|
mockConfig,
|
||||||
|
);
|
||||||
|
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use config.getAvailableModels for openai authType when available', () => {
|
||||||
|
const mockModels = [
|
||||||
|
{
|
||||||
|
id: 'gpt-4',
|
||||||
|
label: 'GPT-4',
|
||||||
|
description: 'Test',
|
||||||
|
authType: AuthType.USE_OPENAI,
|
||||||
|
isVision: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const getAvailableModelsForAuthType = vi.fn().mockReturnValue(mockModels);
|
||||||
|
const mockConfigWithMethod = {
|
||||||
|
// Prefer the newer API when available.
|
||||||
|
getAvailableModelsForAuthType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const models = getAvailableModelsForAuthType(
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
|
mockConfigWithMethod as unknown as Config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getAvailableModelsForAuthType).toHaveBeenCalled();
|
||||||
|
expect(models[0].id).toBe('gpt-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to env var for openai when config returns empty', () => {
|
||||||
|
process.env['OPENAI_MODEL'] = 'fallback-model';
|
||||||
|
const mockConfig = {
|
||||||
|
getAvailableModelsForAuthType: vi.fn().mockReturnValue([]),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
const models = getAvailableModelsForAuthType(
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
|
mockConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to env var for openai when config throws', () => {
|
||||||
|
process.env['OPENAI_MODEL'] = 'fallback-model';
|
||||||
|
const mockConfig = {
|
||||||
|
getAvailableModelsForAuthType: vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Registry not initialized');
|
||||||
|
}),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
const models = getAvailableModelsForAuthType(
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
|
mockConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return env model for openai without config', () => {
|
||||||
|
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
|
||||||
|
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
|
||||||
|
expect(models[0].id).toBe('gpt-4-turbo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for openai without config or env', () => {
|
||||||
|
delete process.env['OPENAI_MODEL'];
|
||||||
|
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for other auth types', () => {
|
||||||
|
const models = getAvailableModelsForAuthType(AuthType.USE_GEMINI);
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isVisionModel', () => {
|
||||||
|
it('should return true for vision model', () => {
|
||||||
|
expect(isVisionModel(MAINLINE_VLM)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-vision model', () => {
|
||||||
|
expect(isVisionModel(MAINLINE_CODER)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for unknown model', () => {
|
||||||
|
expect(isVisionModel('unknown-model')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDefaultVisionModel', () => {
|
||||||
|
it('should return the vision model ID', () => {
|
||||||
|
expect(getDefaultVisionModel()).toBe(MAINLINE_VLM);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,12 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
|
import {
|
||||||
|
AuthType,
|
||||||
|
DEFAULT_QWEN_MODEL,
|
||||||
|
type Config,
|
||||||
|
type AvailableModel as CoreAvailableModel,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export type AvailableModel = {
|
export type AvailableModel = {
|
||||||
@@ -57,27 +62,91 @@ export function getFilteredQwenModels(
|
|||||||
*/
|
*/
|
||||||
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
|
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
|
||||||
const id = process.env['OPENAI_MODEL']?.trim();
|
const id = process.env['OPENAI_MODEL']?.trim();
|
||||||
return id ? { id, label: id } : null;
|
return id
|
||||||
|
? {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
get description() {
|
||||||
|
return t('Configured via OPENAI_MODEL environment variable');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAnthropicAvailableModelFromEnv(): AvailableModel | null {
|
||||||
|
const id = process.env['ANTHROPIC_MODEL']?.trim();
|
||||||
|
return id
|
||||||
|
? {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
get description() {
|
||||||
|
return t('Configured via ANTHROPIC_MODEL environment variable');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert core AvailableModel to CLI AvailableModel format
|
||||||
|
*/
|
||||||
|
function convertCoreModelToCliModel(
|
||||||
|
coreModel: CoreAvailableModel,
|
||||||
|
): AvailableModel {
|
||||||
|
return {
|
||||||
|
id: coreModel.id,
|
||||||
|
label: coreModel.label,
|
||||||
|
description: coreModel.description,
|
||||||
|
isVision: coreModel.isVision ?? coreModel.capabilities?.vision ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available models for the given authType.
|
||||||
|
*
|
||||||
|
* If a Config object is provided, uses config.getAvailableModelsForAuthType().
|
||||||
|
* For qwen-oauth, always returns the hard-coded models.
|
||||||
|
* Falls back to environment variables only when no config is provided.
|
||||||
|
*/
|
||||||
export function getAvailableModelsForAuthType(
|
export function getAvailableModelsForAuthType(
|
||||||
authType: AuthType,
|
authType: AuthType,
|
||||||
|
config?: Config,
|
||||||
): AvailableModel[] {
|
): AvailableModel[] {
|
||||||
|
// For qwen-oauth, always use hard-coded models, this aligns with the API gateway.
|
||||||
|
if (authType === AuthType.QWEN_OAUTH) {
|
||||||
|
return AVAILABLE_MODELS_QWEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use config's model registry when available
|
||||||
|
if (config) {
|
||||||
|
try {
|
||||||
|
const models = config.getAvailableModelsForAuthType(authType);
|
||||||
|
if (models.length > 0) {
|
||||||
|
return models.map(convertCoreModelToCliModel);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If config throws (e.g., not initialized), return empty array
|
||||||
|
}
|
||||||
|
// When a Config object is provided, we intentionally do NOT fall back to env-based
|
||||||
|
// "raw" models. These may reflect the currently effective config but should not be
|
||||||
|
// presented as selectable options in /model.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to environment variables for specific auth types (no config provided)
|
||||||
switch (authType) {
|
switch (authType) {
|
||||||
case AuthType.QWEN_OAUTH:
|
|
||||||
return AVAILABLE_MODELS_QWEN;
|
|
||||||
case AuthType.USE_OPENAI: {
|
case AuthType.USE_OPENAI: {
|
||||||
const openAIModel = getOpenAIAvailableModelFromEnv();
|
const openAIModel = getOpenAIAvailableModelFromEnv();
|
||||||
return openAIModel ? [openAIModel] : [];
|
return openAIModel ? [openAIModel] : [];
|
||||||
}
|
}
|
||||||
|
case AuthType.USE_ANTHROPIC: {
|
||||||
|
const anthropicModel = getAnthropicAvailableModelFromEnv();
|
||||||
|
return anthropicModel ? [anthropicModel] : [];
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// For other auth types, return empty array for now
|
|
||||||
// This can be expanded later according to the design doc
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
/**
|
/**
|
||||||
* Hard code the default vision model as a string literal,
|
* Hard code the default vision model as a string literal,
|
||||||
* until our coding model supports multimodal.
|
* until our coding model supports multimodal.
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ const makeConfig = (tools: Record<string, AnyDeclarativeTool>) =>
|
|||||||
getToolRegistry: () => ({
|
getToolRegistry: () => ({
|
||||||
getTool: (name: string) => tools[name],
|
getTool: (name: string) => tools[name],
|
||||||
}),
|
}),
|
||||||
|
getContentGenerator: () => ({
|
||||||
|
// Default to showing full thinking content during resume unless explicitly
|
||||||
|
// summarized; tests don't care about summarized thinking behavior.
|
||||||
|
useSummarizedThinking: () => false,
|
||||||
|
}),
|
||||||
}) as unknown as Config;
|
}) as unknown as Config;
|
||||||
|
|
||||||
describe('resumeHistoryUtils', () => {
|
describe('resumeHistoryUtils', () => {
|
||||||
|
|||||||
@@ -204,7 +204,11 @@ function convertToHistoryItems(
|
|||||||
const parts = record.message?.parts as Part[] | undefined;
|
const parts = record.message?.parts as Part[] | undefined;
|
||||||
|
|
||||||
// Extract thought content
|
// Extract thought content
|
||||||
const thoughtText = extractThoughtTextFromParts(parts);
|
const thoughtText = !config
|
||||||
|
.getContentGenerator()
|
||||||
|
.useSummarizedThinking()
|
||||||
|
? extractThoughtTextFromParts(parts)
|
||||||
|
: '';
|
||||||
|
|
||||||
// Extract text content (non-function-call, non-thought)
|
// Extract text content (non-function-call, non-thought)
|
||||||
const text = extractTextFromParts(parts);
|
const text = extractTextFromParts(parts);
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
import { vi, type Mock, type MockInstance } from 'vitest';
|
import { vi, type Mock, type MockInstance } from 'vitest';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
import {
|
||||||
|
OutputFormat,
|
||||||
|
FatalInputError,
|
||||||
|
ToolErrorType,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
handleError,
|
handleError,
|
||||||
@@ -65,6 +69,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||||||
describe('errors', () => {
|
describe('errors', () => {
|
||||||
let mockConfig: Config;
|
let mockConfig: Config;
|
||||||
let processExitSpy: MockInstance;
|
let processExitSpy: MockInstance;
|
||||||
|
let processStderrWriteSpy: MockInstance;
|
||||||
let consoleErrorSpy: MockInstance;
|
let consoleErrorSpy: MockInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -74,6 +79,11 @@ describe('errors', () => {
|
|||||||
// Mock console.error
|
// Mock console.error
|
||||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Mock process.stderr.write
|
||||||
|
processStderrWriteSpy = vi
|
||||||
|
.spyOn(process.stderr, 'write')
|
||||||
|
.mockImplementation(() => true);
|
||||||
|
|
||||||
// Mock process.exit to throw instead of actually exiting
|
// Mock process.exit to throw instead of actually exiting
|
||||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||||
throw new Error(`process.exit called with code: ${code}`);
|
throw new Error(`process.exit called with code: ${code}`);
|
||||||
@@ -84,11 +94,13 @@ describe('errors', () => {
|
|||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
||||||
getDebugMode: vi.fn().mockReturnValue(true),
|
getDebugMode: vi.fn().mockReturnValue(true),
|
||||||
|
isInteractive: vi.fn().mockReturnValue(false),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
|
processStderrWriteSpy.mockRestore();
|
||||||
processExitSpy.mockRestore();
|
processExitSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -432,6 +444,87 @@ describe('errors', () => {
|
|||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('permission denied warnings', () => {
|
||||||
|
it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => {
|
||||||
|
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||||
|
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.TEXT);
|
||||||
|
|
||||||
|
handleToolError(
|
||||||
|
toolName,
|
||||||
|
toolError,
|
||||||
|
mockConfig,
|
||||||
|
ToolErrorType.EXECUTION_DENIED,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(processStderrWriteSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'Warning: Tool "test-tool" requires user approval',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(processStderrWriteSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('use the -y flag (YOLO mode)'),
|
||||||
|
);
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show warning when EXECUTION_DENIED in interactive mode', () => {
|
||||||
|
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||||
|
(mockConfig.isInteractive as Mock).mockReturnValue(true);
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.TEXT);
|
||||||
|
|
||||||
|
handleToolError(
|
||||||
|
toolName,
|
||||||
|
toolError,
|
||||||
|
mockConfig,
|
||||||
|
ToolErrorType.EXECUTION_DENIED,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show warning when EXECUTION_DENIED in JSON mode', () => {
|
||||||
|
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||||
|
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.JSON);
|
||||||
|
|
||||||
|
handleToolError(
|
||||||
|
toolName,
|
||||||
|
toolError,
|
||||||
|
mockConfig,
|
||||||
|
ToolErrorType.EXECUTION_DENIED,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show warning for non-EXECUTION_DENIED errors', () => {
|
||||||
|
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||||
|
(mockConfig.isInteractive as Mock).mockReturnValue(false);
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.TEXT);
|
||||||
|
|
||||||
|
handleToolError(
|
||||||
|
toolName,
|
||||||
|
toolError,
|
||||||
|
mockConfig,
|
||||||
|
ToolErrorType.FILE_NOT_FOUND,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(processStderrWriteSpy).not.toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleCancellationError', () => {
|
describe('handleCancellationError', () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
parseAndFormatApiError,
|
parseAndFormatApiError,
|
||||||
FatalTurnLimitedError,
|
FatalTurnLimitedError,
|
||||||
FatalCancellationError,
|
FatalCancellationError,
|
||||||
|
ToolErrorType,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
export function getErrorMessage(error: unknown): string {
|
export function getErrorMessage(error: unknown): string {
|
||||||
@@ -102,10 +103,24 @@ export function handleToolError(
|
|||||||
toolName: string,
|
toolName: string,
|
||||||
toolError: Error,
|
toolError: Error,
|
||||||
config: Config,
|
config: Config,
|
||||||
_errorCode?: string | number,
|
errorCode?: string | number,
|
||||||
resultDisplay?: string,
|
resultDisplay?: string,
|
||||||
): void {
|
): void {
|
||||||
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
|
// Check if this is a permission denied error in non-interactive mode
|
||||||
|
const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED;
|
||||||
|
const isNonInteractive = !config.isInteractive();
|
||||||
|
const isTextMode = config.getOutputFormat() === OutputFormat.TEXT;
|
||||||
|
|
||||||
|
// Show warning for permission denied errors in non-interactive text mode
|
||||||
|
if (isExecutionDenied && isNonInteractive && isTextMode) {
|
||||||
|
const warningMessage =
|
||||||
|
`Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` +
|
||||||
|
`To enable automatic tool execution, use the -y flag (YOLO mode):\n` +
|
||||||
|
`Example: qwen -p 'your prompt' -y\n\n`;
|
||||||
|
process.stderr.write(warningMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always log detailed error in debug mode
|
||||||
if (config.getDebugMode()) {
|
if (config.getDebugMode()) {
|
||||||
console.error(
|
console.error(
|
||||||
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
||||||
|
|||||||
133
packages/cli/src/utils/modelConfigUtils.ts
Normal file
133
packages/cli/src/utils/modelConfigUtils.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuthType,
|
||||||
|
type ContentGeneratorConfig,
|
||||||
|
type ContentGeneratorConfigSources,
|
||||||
|
resolveModelConfig,
|
||||||
|
type ModelConfigSourcesInput,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import type { Settings } from '../config/settings.js';
|
||||||
|
|
||||||
|
export interface CliGenerationConfigInputs {
|
||||||
|
argv: {
|
||||||
|
model?: string | undefined;
|
||||||
|
openaiApiKey?: string | undefined;
|
||||||
|
openaiBaseUrl?: string | undefined;
|
||||||
|
openaiLogging?: boolean | undefined;
|
||||||
|
openaiLoggingDir?: string | undefined;
|
||||||
|
};
|
||||||
|
settings: Settings;
|
||||||
|
selectedAuthType: AuthType | undefined;
|
||||||
|
/**
|
||||||
|
* Injectable env for testability. Defaults to process.env at callsites.
|
||||||
|
*/
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCliGenerationConfig {
|
||||||
|
/** The resolved model id (may be empty string if not resolvable at CLI layer) */
|
||||||
|
model: string;
|
||||||
|
/** API key for OpenAI-compatible auth */
|
||||||
|
apiKey: string;
|
||||||
|
/** Base URL for OpenAI-compatible auth */
|
||||||
|
baseUrl: string;
|
||||||
|
/** The full generation config to pass to core Config */
|
||||||
|
generationConfig: Partial<ContentGeneratorConfig>;
|
||||||
|
/** Source attribution for each resolved field */
|
||||||
|
sources: ContentGeneratorConfigSources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthTypeFromEnv(): AuthType | undefined {
|
||||||
|
if (process.env['OPENAI_API_KEY']) {
|
||||||
|
return AuthType.USE_OPENAI;
|
||||||
|
}
|
||||||
|
if (process.env['QWEN_OAUTH']) {
|
||||||
|
return AuthType.QWEN_OAUTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env['GEMINI_API_KEY']) {
|
||||||
|
return AuthType.USE_GEMINI;
|
||||||
|
}
|
||||||
|
if (process.env['GOOGLE_API_KEY']) {
|
||||||
|
return AuthType.USE_VERTEX_AI;
|
||||||
|
}
|
||||||
|
if (process.env['ANTHROPIC_API_KEY']) {
|
||||||
|
return AuthType.USE_ANTHROPIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified resolver for CLI generation config.
|
||||||
|
*
|
||||||
|
* Precedence (for OpenAI auth):
|
||||||
|
* - model: argv.model > OPENAI_MODEL > QWEN_MODEL > settings.model.name
|
||||||
|
* - apiKey: argv.openaiApiKey > OPENAI_API_KEY > settings.security.auth.apiKey
|
||||||
|
* - baseUrl: argv.openaiBaseUrl > OPENAI_BASE_URL > settings.security.auth.baseUrl
|
||||||
|
*
|
||||||
|
* For non-OpenAI auth, only argv.model override is respected at CLI layer.
|
||||||
|
*/
|
||||||
|
export function resolveCliGenerationConfig(
|
||||||
|
inputs: CliGenerationConfigInputs,
|
||||||
|
): ResolvedCliGenerationConfig {
|
||||||
|
const { argv, settings, selectedAuthType } = inputs;
|
||||||
|
const env = inputs.env ?? (process.env as Record<string, string | undefined>);
|
||||||
|
|
||||||
|
const authType = selectedAuthType;
|
||||||
|
|
||||||
|
const configSources: ModelConfigSourcesInput = {
|
||||||
|
authType,
|
||||||
|
cli: {
|
||||||
|
model: argv.model,
|
||||||
|
apiKey: argv.openaiApiKey,
|
||||||
|
baseUrl: argv.openaiBaseUrl,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
model: settings.model?.name,
|
||||||
|
apiKey: settings.security?.auth?.apiKey,
|
||||||
|
baseUrl: settings.security?.auth?.baseUrl,
|
||||||
|
generationConfig: settings.model?.generationConfig as
|
||||||
|
| Partial<ContentGeneratorConfig>
|
||||||
|
| undefined,
|
||||||
|
},
|
||||||
|
env,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = resolveModelConfig(configSources);
|
||||||
|
|
||||||
|
// Log warnings if any
|
||||||
|
for (const warning of resolved.warnings) {
|
||||||
|
console.warn(`[modelProviderUtils] ${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve OpenAI logging config (CLI-specific, not part of core resolver)
|
||||||
|
const enableOpenAILogging =
|
||||||
|
(typeof argv.openaiLogging === 'undefined'
|
||||||
|
? settings.model?.enableOpenAILogging
|
||||||
|
: argv.openaiLogging) ?? false;
|
||||||
|
|
||||||
|
const openAILoggingDir =
|
||||||
|
argv.openaiLoggingDir || settings.model?.openAILoggingDir;
|
||||||
|
|
||||||
|
// Build the full generation config
|
||||||
|
// Note: we merge the resolved config with logging settings
|
||||||
|
const generationConfig: Partial<ContentGeneratorConfig> = {
|
||||||
|
...resolved.config,
|
||||||
|
enableOpenAILogging,
|
||||||
|
openAILoggingDir,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: resolved.config.model || '',
|
||||||
|
apiKey: resolved.config.apiKey || '',
|
||||||
|
baseUrl: resolved.config.baseUrl || '',
|
||||||
|
generationConfig,
|
||||||
|
sources: resolved.sources,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user