mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge branch 'main' into docs/add-mcp-quick-start-guides
This commit is contained in:
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -1,7 +0,0 @@
|
||||
# By default, require reviews from the release approvers for all files.
|
||||
* @google-gemini/gemini-cli-askmode-approvers
|
||||
|
||||
# The following files don't need reviews from the release approvers.
|
||||
# These patterns override the rule above.
|
||||
**/*.md
|
||||
/docs/
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: 'Bug Report'
|
||||
description: 'Report a bug to help us improve Qwen Code'
|
||||
labels: ['kind/bug', 'status/need-triage']
|
||||
labels: ['type/bug', 'status/needs-triage']
|
||||
body:
|
||||
- type: 'markdown'
|
||||
attributes:
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
4
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: 'Feature Request'
|
||||
description: 'Suggest an idea for this project'
|
||||
labels:
|
||||
- 'kind/enhancement'
|
||||
- 'status/need-triage'
|
||||
- 'type/feature-request'
|
||||
- 'status/needs-triage'
|
||||
body:
|
||||
- type: 'markdown'
|
||||
attributes:
|
||||
|
||||
49
.github/scripts/pr-triage.sh
vendored
49
.github/scripts/pr-triage.sh
vendored
@@ -19,42 +19,20 @@ process_pr() {
|
||||
local PR_NUMBER=$1
|
||||
echo "🔄 Processing PR #${PR_NUMBER}"
|
||||
|
||||
# Get PR body with error handling
|
||||
local PR_BODY
|
||||
if ! PR_BODY=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json body -q .body 2>/dev/null); then
|
||||
echo " ⚠️ Could not fetch PR #${PR_NUMBER} details"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Look for issue references using multiple patterns
|
||||
local ISSUE_NUMBER=""
|
||||
|
||||
# Pattern 1: Direct reference like #123
|
||||
if [[ -z "${ISSUE_NUMBER}" ]]; then
|
||||
ISSUE_NUMBER=$(echo "${PR_BODY}" | grep -oE '#[0-9]+' | head -1 | sed 's/#//' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
# Pattern 2: Closes/Fixes/Resolves patterns (case-insensitive)
|
||||
if [[ -z "${ISSUE_NUMBER}" ]]; then
|
||||
ISSUE_NUMBER=$(echo "${PR_BODY}" | grep -iE '(closes?|fixes?|resolves?) #[0-9]+' | grep -oE '#[0-9]+' | head -1 | sed 's/#//' 2>/dev/null || echo "")
|
||||
# Get closing issue number with error handling
|
||||
local ISSUE_NUMBER
|
||||
if ! ISSUE_NUMBER=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json closingIssuesReferences -q '.closingIssuesReferences.nodes[0].number' 2>/dev/null); then
|
||||
echo " ⚠️ Could not fetch closing issue for PR #${PR_NUMBER}"
|
||||
fi
|
||||
|
||||
if [[ -z "${ISSUE_NUMBER}" ]]; then
|
||||
echo "⚠️ No linked issue found for PR #${PR_NUMBER}, adding status/need-issue label"
|
||||
if ! gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label "status/need-issue" 2>/dev/null; then
|
||||
echo " ⚠️ Failed to add label (may already exist or have permission issues)"
|
||||
fi
|
||||
# Add PR number to the list
|
||||
if [[ -z "${PRS_NEEDING_COMMENT}" ]]; then
|
||||
PRS_NEEDING_COMMENT="${PR_NUMBER}"
|
||||
else
|
||||
PRS_NEEDING_COMMENT="${PRS_NEEDING_COMMENT},${PR_NUMBER}"
|
||||
fi
|
||||
echo "needs_comment=true" >> "${GITHUB_OUTPUT}"
|
||||
echo "ℹ️ No linked issue found for PR #${PR_NUMBER} - this is acceptable for independent contributions"
|
||||
# We no longer require PRs to have linked issues
|
||||
# Independent valuable contributions are encouraged
|
||||
else
|
||||
echo "🔗 Found linked issue #${ISSUE_NUMBER}"
|
||||
|
||||
# Remove status/need-issue label if present
|
||||
# Remove status/need-issue label if present (legacy cleanup)
|
||||
if ! gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --remove-label "status/need-issue" 2>/dev/null; then
|
||||
echo " status/need-issue label not present or could not be removed"
|
||||
fi
|
||||
@@ -99,7 +77,7 @@ process_pr() {
|
||||
local LABELS_TO_REMOVE=""
|
||||
for label in "${PR_LABEL_ARRAY[@]}"; do
|
||||
if [[ -n "${label}" ]] && [[ " ${ISSUE_LABEL_ARRAY[*]} " != *" ${label} "* ]]; then
|
||||
# Don't remove status/need-issue since we already handled it
|
||||
# Don't remove status/need-issue since we already handled it (legacy cleanup)
|
||||
if [[ "${label}" != "status/need-issue" ]]; then
|
||||
if [[ -z "${LABELS_TO_REMOVE}" ]]; then
|
||||
LABELS_TO_REMOVE="${label}"
|
||||
@@ -118,14 +96,7 @@ process_pr() {
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${LABELS_TO_REMOVE}" ]]; then
|
||||
echo "➖ Removing labels: ${LABELS_TO_REMOVE}"
|
||||
if ! gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --remove-label "${LABELS_TO_REMOVE}" 2>/dev/null; then
|
||||
echo " ⚠️ Failed to remove some labels"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${LABELS_TO_ADD}" ]] && [[ -z "${LABELS_TO_REMOVE}" ]]; then
|
||||
if [[ -z "${LABELS_TO_ADD}" ]]; then
|
||||
echo "✅ Labels already synchronized"
|
||||
fi
|
||||
echo "needs_comment=false" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
201
.github/workflows/check-issue-completeness.yml
vendored
Normal file
201
.github/workflows/check-issue-completeness.yml
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
name: 'Check Issue Completeness'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- 'opened'
|
||||
- 'edited'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'write'
|
||||
|
||||
jobs:
|
||||
check-issue-info:
|
||||
timeout-minutes: 2
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' && contains(github.event.issue.labels.*.name, 'type/bug') }}
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Check for Client Information'
|
||||
id: 'check_info'
|
||||
env:
|
||||
ISSUE_BODY: '${{ github.event.issue.body }}'
|
||||
run: |-
|
||||
echo "Checking issue body for required information..."
|
||||
|
||||
# Convert issue body to lowercase for case-insensitive matching
|
||||
ISSUE_BODY_LOWER=$(echo "$ISSUE_BODY" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Initialize flags
|
||||
HAS_VERSION=false
|
||||
HAS_OS_INFO=false
|
||||
HAS_AUTH_METHOD=false
|
||||
HAS_ABOUT_OUTPUT=false
|
||||
MISSING_INFO=()
|
||||
|
||||
# Check for /about command output by looking for its characteristic fields
|
||||
# The /about output contains: CLI Version, Git Commit, Model, Sandbox, OS, Auth Method
|
||||
if echo "$ISSUE_BODY_LOWER" | grep -qE 'cli version.*[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
HAS_ABOUT_OUTPUT=true
|
||||
HAS_VERSION=true
|
||||
fi
|
||||
|
||||
# If full /about output is not detected, check individual components
|
||||
if [ "$HAS_ABOUT_OUTPUT" = false ]; then
|
||||
# Check for version information (various formats)
|
||||
if echo "$ISSUE_BODY_LOWER" | grep -qE '(cli version|version|v)[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
HAS_VERSION=true
|
||||
fi
|
||||
|
||||
# Check for OS information
|
||||
if echo "$ISSUE_BODY_LOWER" | grep -qE '(^os[[:space:]]|macos|windows|linux|ubuntu|debian|fedora|arch|darwin|win32|platform)'; then
|
||||
HAS_OS_INFO=true
|
||||
fi
|
||||
|
||||
# Check for Auth Method information
|
||||
if echo "$ISSUE_BODY_LOWER" | grep -qE '(auth method|authentication|login|qwen-oauth|api.?config|oauth)'; then
|
||||
HAS_AUTH_METHOD=true
|
||||
fi
|
||||
else
|
||||
# If /about output is present, assume it contains OS and auth info
|
||||
HAS_OS_INFO=true
|
||||
HAS_AUTH_METHOD=true
|
||||
fi
|
||||
|
||||
# Determine what's missing
|
||||
if [ "$HAS_ABOUT_OUTPUT" = false ]; then
|
||||
if [ "$HAS_VERSION" = false ]; then
|
||||
MISSING_INFO+=("Qwen Code version")
|
||||
fi
|
||||
if [ "$HAS_OS_INFO" = false ]; then
|
||||
MISSING_INFO+=("operating system information")
|
||||
fi
|
||||
if [ "$HAS_AUTH_METHOD" = false ]; then
|
||||
MISSING_INFO+=("authentication/login method")
|
||||
fi
|
||||
# Suggest providing /about output for completeness
|
||||
if [ "$HAS_VERSION" = false ] || [ "$HAS_OS_INFO" = false ] || [ "$HAS_AUTH_METHOD" = false ]; then
|
||||
MISSING_INFO+=("full output of the \`/about\` command (recommended)")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set output variables
|
||||
if [ ${#MISSING_INFO[@]} -eq 0 ]; then
|
||||
echo "info_complete=true" >> "$GITHUB_OUTPUT"
|
||||
echo "All required information is present."
|
||||
else
|
||||
echo "info_complete=false" >> "$GITHUB_OUTPUT"
|
||||
# Join array elements with comma
|
||||
MISSING_LIST=$(IFS=','; echo "${MISSING_INFO[*]}")
|
||||
echo "missing_info=$MISSING_LIST" >> "$GITHUB_OUTPUT"
|
||||
echo "Missing information: $MISSING_LIST"
|
||||
fi
|
||||
|
||||
- name: 'Comment on Issue if Information is Missing'
|
||||
if: |-
|
||||
${{ steps.check_info.outputs.info_complete == 'false' }}
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7
|
||||
env:
|
||||
MISSING_INFO: '${{ steps.check_info.outputs.missing_info }}'
|
||||
with:
|
||||
github-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
const missingInfo = process.env.MISSING_INFO.split(',');
|
||||
const missingList = missingInfo.map(item => `- ${item}`).join('\n');
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.data.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('Missing Required Information')
|
||||
);
|
||||
|
||||
const commentBody = `### ⚠️ Missing Required Information
|
||||
|
||||
Thank you for reporting this issue! To help us investigate and resolve this problem more effectively, we need some additional information:
|
||||
|
||||
${missingList}
|
||||
|
||||
### How to provide this information:
|
||||
|
||||
Please run the following command and paste the complete output:
|
||||
|
||||
\`\`\`bash
|
||||
qwen
|
||||
# Then in the interactive CLI, run:
|
||||
/about
|
||||
\`\`\`
|
||||
|
||||
The output should look like:
|
||||
\`\`\`
|
||||
CLI Version 0.0.14
|
||||
Git Commit 9a0cb64a
|
||||
Model coder-model
|
||||
Sandbox no sandbox
|
||||
OS darwin
|
||||
Auth Method qwen-oauth
|
||||
\`\`\`
|
||||
|
||||
Once you provide this information, we'll be able to assist you better. Thank you! 🙏`;
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Updated existing comment about missing information.');
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Created new comment about missing information.');
|
||||
}
|
||||
|
||||
- name: 'Add status/need-information Label'
|
||||
if: |-
|
||||
${{ steps.check_info.outputs.info_complete == 'false' }}
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7
|
||||
with:
|
||||
github-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['status/need-information']
|
||||
});
|
||||
console.log('Added status/need-information label.');
|
||||
|
||||
- name: 'Remove status/need-information Label if Complete'
|
||||
if: |-
|
||||
${{ steps.check_info.outputs.info_complete == 'true' }}
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'status/need-information'
|
||||
});
|
||||
console.log('Removed status/need-information label as information is now complete.');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log('Label not found on issue, nothing to remove.');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
221
.github/workflows/ci.yml
vendored
221
.github/workflows/ci.yml
vendored
@@ -12,6 +12,13 @@ on:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch_ref:
|
||||
description: 'Branch to run on'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: 'string'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
@@ -33,222 +40,48 @@ env:
|
||||
YAMLLINT_VERSION: '1.35.1'
|
||||
|
||||
jobs:
|
||||
#
|
||||
# Lint: GitHub Actions
|
||||
#
|
||||
lint_github_actions:
|
||||
name: 'Lint (GitHub Actions)'
|
||||
lint:
|
||||
name: 'Lint'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 'Install shellcheck' # Actionlint uses shellcheck
|
||||
run: |-
|
||||
mkdir -p "${RUNNER_TEMP}/shellcheck"
|
||||
curl -sSLo "${RUNNER_TEMP}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
|
||||
tar -xf "${RUNNER_TEMP}/.shellcheck.txz" -C "${RUNNER_TEMP}/shellcheck" --strip-components=1
|
||||
echo "${RUNNER_TEMP}/shellcheck" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: 'Install actionlint'
|
||||
run: |-
|
||||
mkdir -p "${RUNNER_TEMP}/actionlint"
|
||||
curl -sSLo "${RUNNER_TEMP}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
tar -xzf "${RUNNER_TEMP}/.actionlint.tgz" -C "${RUNNER_TEMP}/actionlint"
|
||||
echo "${RUNNER_TEMP}/actionlint" >> "${GITHUB_PATH}"
|
||||
|
||||
# For actionlint, we specifically ignore shellcheck rules that are
|
||||
# annoying or unhelpful. See the shellcheck action for a description.
|
||||
- name: 'Run actionlint'
|
||||
run: |-
|
||||
actionlint \
|
||||
-color \
|
||||
-format '{{range $err := .}}::error file={{$err.Filepath}},line={{$err.Line}},col={{$err.Column}}::{{$err.Filepath}}@{{$err.Line}} {{$err.Message}}%0A```%0A{{replace $err.Snippet "\\n" "%0A"}}%0A```\n{{end}}' \
|
||||
-ignore 'SC2002:' \
|
||||
-ignore 'SC2016:' \
|
||||
-ignore 'SC2129:' \
|
||||
-ignore 'label ".+" is unknown'
|
||||
|
||||
- name: 'Run ratchet'
|
||||
uses: 'sethvargo/ratchet@8b4ca256dbed184350608a3023620f267f0a5253' # ratchet:sethvargo/ratchet@v0.11.4
|
||||
with:
|
||||
files: |-
|
||||
.github/workflows/*.yml
|
||||
.github/actions/**/*.yml
|
||||
|
||||
#
|
||||
# Lint: Javascript
|
||||
#
|
||||
lint_javascript:
|
||||
name: 'Lint (Javascript)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: 'Configure npm for rate limiting'
|
||||
run: |-
|
||||
npm config set fetch-retry-mintimeout 20000
|
||||
npm config set fetch-retry-maxtimeout 120000
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-timeout 300000
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: |-
|
||||
npm ci --prefer-offline --no-audit --progress=false
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Run formatter check'
|
||||
run: |-
|
||||
npm run format
|
||||
git diff --exit-code
|
||||
- name: 'Check lockfile'
|
||||
run: 'npm run check:lockfile'
|
||||
|
||||
- name: 'Run linter'
|
||||
run: |-
|
||||
npm run lint:ci
|
||||
- name: 'Install linters'
|
||||
run: 'node scripts/lint.js --setup'
|
||||
|
||||
- name: 'Run linter on integration tests'
|
||||
run: |-
|
||||
npx eslint integration-tests --max-warnings 0
|
||||
- name: 'Run ESLint'
|
||||
run: 'node scripts/lint.js --eslint'
|
||||
|
||||
- name: 'Run formatter on integration tests'
|
||||
run: |-
|
||||
npx prettier --check integration-tests
|
||||
git diff --exit-code
|
||||
- name: 'Run actionlint'
|
||||
run: 'node scripts/lint.js --actionlint'
|
||||
|
||||
- name: 'Build project'
|
||||
run: |-
|
||||
npm run build
|
||||
|
||||
- name: 'Run type check'
|
||||
run: |-
|
||||
npm run typecheck
|
||||
|
||||
#
|
||||
# Lint: Shell
|
||||
#
|
||||
lint_shell:
|
||||
name: 'Lint (Shell)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 'Install shellcheck'
|
||||
run: |-
|
||||
mkdir -p "${RUNNER_TEMP}/shellcheck"
|
||||
curl -sSLo "${RUNNER_TEMP}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
|
||||
tar -xf "${RUNNER_TEMP}/.shellcheck.txz" -C "${RUNNER_TEMP}/shellcheck" --strip-components=1
|
||||
echo "${RUNNER_TEMP}/shellcheck" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: 'Install shellcheck problem matcher'
|
||||
run: |-
|
||||
cat > "${RUNNER_TEMP}/shellcheck/problem-matcher-lint-shell.json" <<"EOF"
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "lint_shell",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.*):(\\\\d+):(\\\\d+):\\\\s+(?:fatal\\\\s+)?(warning|error):\\\\s+(.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"severity": 4,
|
||||
"message": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
echo "::add-matcher::${RUNNER_TEMP}/shellcheck/problem-matcher-lint-shell.json"
|
||||
|
||||
# Note that only warning and error severity show up in the github files
|
||||
# page. So we replace 'style' and 'note' with 'warning' to make it show
|
||||
# up.
|
||||
#
|
||||
# We also try and find all bash scripts even if they don't have an
|
||||
# explicit extension.
|
||||
#
|
||||
# We explicitly ignore the following rules:
|
||||
#
|
||||
# - SC2002: This rule suggests using "cmd < file" instead of "cat | cmd".
|
||||
# While < is more efficient, pipes are much more readable and expected.
|
||||
#
|
||||
# - SC2129: This rule suggests grouping multiple writes to a file in
|
||||
# braces like "{ cmd1; cmd2; } >> file". This is unexpected and less
|
||||
# readable.
|
||||
#
|
||||
# - SC2310: This is an optional warning that only appears with "set -e"
|
||||
# and when a command is used as a conditional.
|
||||
- name: 'Run shellcheck'
|
||||
run: |-
|
||||
git ls-files | grep -E '^([^.]+|.*\.(sh|zsh|bash))$' | grep -v '^integration-tests/terminal-bench/ci-tasks/' | xargs file --mime-type \
|
||||
| grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \
|
||||
| xargs shellcheck \
|
||||
--check-sourced \
|
||||
--enable=all \
|
||||
--exclude=SC2002,SC2129,SC2310 \
|
||||
--severity=style \
|
||||
--format=gcc \
|
||||
--color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g'
|
||||
|
||||
#
|
||||
# Lint: YAML
|
||||
#
|
||||
lint_yaml:
|
||||
name: 'Lint (YAML)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 'Setup Python'
|
||||
uses: 'actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065' # ratchet:actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3'
|
||||
|
||||
- name: 'Install yamllint'
|
||||
run: |-
|
||||
pip install --user "yamllint==${YAMLLINT_VERSION}"
|
||||
run: 'node scripts/lint.js --shellcheck'
|
||||
|
||||
- name: 'Run yamllint'
|
||||
run: |-
|
||||
git ls-files | grep -E '\.(yaml|yml)' | grep -v '^integration-tests/terminal-bench/ci-tasks/' | xargs yamllint --format github
|
||||
run: 'node scripts/lint.js --yamllint'
|
||||
|
||||
#
|
||||
# Lint: All
|
||||
#
|
||||
# This is a virtual job that other jobs depend on to wait for all linters to
|
||||
# finish. It's also used to ensure linting happens on CI via required
|
||||
# workflows.
|
||||
lint:
|
||||
name: 'Lint'
|
||||
needs:
|
||||
- 'lint_github_actions'
|
||||
- 'lint_javascript'
|
||||
- 'lint_shell'
|
||||
- 'lint_yaml'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- run: |-
|
||||
echo 'All linters finished!'
|
||||
- name: 'Run Prettier'
|
||||
run: 'node scripts/lint.js --prettier'
|
||||
|
||||
- name: 'Run sensitive keyword linter'
|
||||
run: 'node scripts/lint.js --sensitive-keywords'
|
||||
|
||||
#
|
||||
# Test: Node
|
||||
|
||||
@@ -50,7 +50,8 @@ jobs:
|
||||
|
||||
// Search for open issues already assigned to the commenter in this repo
|
||||
const { data: assignedIssues } = await github.rest.search.issuesAndPullRequests({
|
||||
q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`
|
||||
q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`,
|
||||
advanced_search: true
|
||||
});
|
||||
|
||||
if (assignedIssues.total_count >= MAX_ISSUES_ASSIGNED) {
|
||||
|
||||
@@ -16,7 +16,7 @@ on:
|
||||
type: 'number'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.event.issue.number }}'
|
||||
group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
@@ -29,6 +29,7 @@ permissions:
|
||||
issues: 'write'
|
||||
statuses: 'write'
|
||||
packages: 'read'
|
||||
actions: 'write' # Required for cancelling a workflow run
|
||||
|
||||
jobs:
|
||||
triage-issue:
|
||||
@@ -62,29 +63,29 @@ jobs:
|
||||
## Role
|
||||
|
||||
You are an issue triage assistant. Analyze the current GitHub issue
|
||||
and identify the most appropriate existing labels. Use the available
|
||||
and identify the most appropriate existing labels by only using the provided data. Use the available
|
||||
tools to gather information; do not ask for information to be
|
||||
provided. Do not remove labels titled help wanted or good first issue.
|
||||
provided. Do not remove the following labels titled maintainer, help wanted or good first issue.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
|
||||
2. Use shell command `echo` to check the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
|
||||
3. Ignore any existing priorities or tags on the issue. Just report your findings.
|
||||
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
|
||||
4. Select the most relevant labels from the existing labels, focusing on type/*, category/*, scope/*, status/* and priority/*. For category/* and type/* limit yourself to only the single most applicable label in each case.
|
||||
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`.
|
||||
7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label.
|
||||
8. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label.
|
||||
9. Use Area definitions mentioned below to help you narrow down issues.
|
||||
9. Use Category and Scope definitions mentioned below to help you narrow down issues.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only use labels that already exist in the repository
|
||||
- Do not add comments or modify the issue content
|
||||
- Triage only the current issue
|
||||
- Identify only one area/ label
|
||||
- Identify only one kind/ label
|
||||
- Identify all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these
|
||||
- Identify only one category/ label
|
||||
- Identify only one type/ label
|
||||
- Identify all applicable scope/*, status/* and priority/* labels based on the issue content. It's ok to have multiple of these
|
||||
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario
|
||||
- Reference all shell variables as "${VAR}" (with quotes and braces)
|
||||
- Output only valid JSON format
|
||||
@@ -92,23 +93,29 @@ jobs:
|
||||
|
||||
Categorization Guidelines:
|
||||
P0: Critical / Blocker
|
||||
- A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself.
|
||||
- A P0 bug is a catastrophic failure that demands immediate attention.
|
||||
- To be a P0 it means almost all users are running into this issue and it is blocking users from being able to use the product.
|
||||
- You would see this in the form of many comments from different developers on the bug.
|
||||
- It represents a complete showstopper for a significant portion of users or for the development process itself.
|
||||
Impact:
|
||||
- Blocks development or testing for the entire team.
|
||||
- Major security vulnerability that could compromise user data or system integrity.
|
||||
- Causes data loss or corruption with no workaround.
|
||||
- Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? Is it preventing contributors from contributing to the repository or is it a release blocker?
|
||||
Qualifier: Is the main function of the software broken?
|
||||
Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
|
||||
Example: The qwen auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
|
||||
P1: High
|
||||
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1.
|
||||
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature.
|
||||
- While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1.
|
||||
- Once again this would be affecting many users.
|
||||
- You would see this in the form of comments from different developers on the bug.
|
||||
Impact:
|
||||
- A core feature is broken or behaving incorrectly for a large number of users or large number of use cases.
|
||||
- Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases.
|
||||
- Severe performance degradation making the application frustratingly slow.
|
||||
- No straightforward workaround exists, or the workaround is difficult and non-obvious.
|
||||
Qualifier: Is a key feature unusable or giving very wrong results?
|
||||
Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable.
|
||||
Example: Qwen Code enters a loop when making read-many-files tool call. I am unable to break out of the loop and qwen doesn't follow instructions subsequently.
|
||||
P2: Medium
|
||||
- A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality.
|
||||
Impact:
|
||||
@@ -127,45 +134,55 @@ jobs:
|
||||
Things you should know:
|
||||
- If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue
|
||||
- This product is designed to use different models eg.. using pro, downgrading to flash etc. when users report that they dont expect the model to change those would be categorized as feature requests.
|
||||
Definition of Areas
|
||||
area/ux:
|
||||
- Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance.
|
||||
- I am seeing my screen flicker when using Gemini CLI
|
||||
- I am seeing the output malformed
|
||||
- Theme changes aren't taking effect
|
||||
- My keyboard inputs arent' being recognzied
|
||||
area/platform:
|
||||
- Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework.
|
||||
area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features.
|
||||
area/models:
|
||||
- i am not getting a response that is reasonable or expected. this can include things like
|
||||
- I am calling a tool and the tool is not performing as expected.
|
||||
- i am expecting a tool to be called and it is not getting called ,
|
||||
- Including experience when using
|
||||
- built-in tools (e.g., web search, code interpreter, read file, writefile, etc..),
|
||||
- Function calling issues should be under this area
|
||||
- i am getting responses from the model that are malformed.
|
||||
- Issues concerning Gemini quality of response and inference,
|
||||
- Issues talking about unnecessary token consumption.
|
||||
- Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues.
|
||||
- Memory compression
|
||||
- unexpected responses,
|
||||
- poor quality of generated code
|
||||
area/tools:
|
||||
- These are primarily issues related to Model Context Protocol
|
||||
- These are issues that mention MCP support
|
||||
- feature requests asking for support for new tools.
|
||||
area/core: Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality
|
||||
area/contribution: Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure.
|
||||
area/authentication: Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc..
|
||||
area/security-privacy: Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access.
|
||||
area/extensibility: Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc..
|
||||
area/performance: Issues focused on model performance
|
||||
- Issues with running out of capacity,
|
||||
- 429 errors etc..
|
||||
- could also pertain to latency,
|
||||
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
|
||||
- Switching models from one to the other unexpectedly.
|
||||
Definition of Categories and Scopes
|
||||
|
||||
category/cli: Command line interface and interaction
|
||||
- Issues with interactive CLI features, command parsing, keyboard shortcuts
|
||||
- Related scopes: scope/commands, scope/interactive, scope/non-interactive, scope/keybindings
|
||||
|
||||
category/core: Core engine and logic
|
||||
- Issues with fundamental components, content generation, session management
|
||||
- Related scopes: scope/content-generation, scope/token-management, scope/session-management, scope/model-switching
|
||||
|
||||
category/ui: User interface and display
|
||||
- Issues with themes, UI components, rendering, markdown display
|
||||
- Related scopes: scope/themes, scope/components, scope/rendering, scope/markdown
|
||||
|
||||
category/authentication: Authentication and authorization
|
||||
- Issues with login flows, API keys, OAuth, credential storage
|
||||
- Related scopes: scope/oauth, scope/api-keys, scope/token-storage
|
||||
|
||||
category/tools: Tool integration and execution
|
||||
- Issues with MCP, shell execution, file operations, web search, memory, git integration
|
||||
- Related scopes: scope/mcp, scope/shell, scope/file-operations, scope/web-search, scope/memory, scope/git
|
||||
|
||||
category/configuration: Configuration management
|
||||
- Issues with settings, extensions, trusted folders, sandbox configuration
|
||||
- Related scopes: scope/settings, scope/extensions, scope/trusted-folders, scope/sandbox
|
||||
|
||||
category/integration: External integrations
|
||||
- Issues with IDE integration, VSCode extension, Zed integration, GitHub Actions
|
||||
- Related scopes: scope/ide, scope/vscode, scope/zed, scope/github-actions
|
||||
|
||||
category/platform: Platform compatibility
|
||||
- Issues with installation, OS compatibility, packaging
|
||||
- Related scopes: scope/installation, scope/macos, scope/windows, scope/linux, scope/packaging
|
||||
|
||||
category/performance: Performance and optimization
|
||||
- Issues with latency, memory usage, model performance, caching
|
||||
- Related scopes: scope/latency, scope/memory-usage, scope/model-performance, scope/caching
|
||||
|
||||
category/security: Security and privacy
|
||||
- Issues with data privacy, credential security, vulnerabilities
|
||||
- Related scopes: scope/data-privacy, scope/credential-security, scope/vulnerability
|
||||
|
||||
category/telemetry: Telemetry and analytics
|
||||
- Issues with metrics collection, logging, analytics
|
||||
- Related scopes: scope/metrics, scope/logging, scope/analytics
|
||||
|
||||
category/development: Development experience
|
||||
- Issues with build system, testing, CI/CD, documentation
|
||||
- Related scopes: scope/build-system, scope/testing, scope/ci-cd, scope/documentation
|
||||
|
||||
- name: 'Post Issue Analysis Failure Comment'
|
||||
if: |-
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
echo '🏷️ Finding issues that need triage...'
|
||||
NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue label:"status/needs-triage"' --json number,title,body)"
|
||||
--search "is:open is:issue label:\"status/need-triage\"" --limit 1000 --json number,title,body)"
|
||||
|
||||
echo '🔄 Merging and deduplicating issues...'
|
||||
ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
|
||||
@@ -84,16 +84,16 @@ jobs:
|
||||
2. Use shell command `echo` to check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
|
||||
3. Review the issue title, body and any comments provided in the environment variables.
|
||||
4. Ignore any existing priorities or tags on the issue.
|
||||
5. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*.
|
||||
5. Select the most relevant labels from the existing labels, focusing on type/*, category/*, scope/*, status/* and priority/*.
|
||||
6. Get the list of labels already on the issue using `gh issue view ISSUE_NUMBER --repo ${{ github.repository }} --json labels -t '{{range .labels}}{{.name}}{{"\n"}}{{end}}'
|
||||
7. For area/* and kind/* limit yourself to only the single most applicable label in each case.
|
||||
7. For category/* and type/* limit yourself to only the single most applicable label in each case.
|
||||
8. Give me a single short paragraph about why you are selecting each label in the process. use the format Issue ID: , Title, Label applied:, Label removed, ovearll explanation
|
||||
9. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands:
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"`
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"`
|
||||
- Continue for each label separately
|
||||
- IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed.
|
||||
- Make sure after you apply labels there is only one area/* and one kind/* label per issue.
|
||||
- Make sure after you apply labels there is only one category/* and one type/* label per issue.
|
||||
- To do this look for labels found in step 6 that no longer apply remove them one at a time using
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name1"`
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name2"`
|
||||
@@ -111,15 +111,18 @@ jobs:
|
||||
- Do not include any explanation or additional text, just the JSON
|
||||
- Only use labels that already exist in the repository.
|
||||
- Do not add comments or modify the issue content.
|
||||
- Do not remove labels titled help wanted or good first issue.
|
||||
- Do not remove the following labels maintainer, help wanted or good first issue.
|
||||
- Triage only the current issue.
|
||||
- Identify only one area/ label
|
||||
- Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
|
||||
- Identify all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
|
||||
- Identify only one category/ label
|
||||
- Identify only one type/ label (Do not apply type/duplicate or type/parent-issue)
|
||||
- Identify all applicable scope/*, status/* and priority/* labels based on the issue content. It's ok to have multiple of these.
|
||||
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
|
||||
Categorization Guidelines:
|
||||
P0: Critical / Blocker
|
||||
- A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself.
|
||||
- A P0 bug is a catastrophic failure that demands immediate attention.
|
||||
- To be a P0 it means almost all users are running into this issue and it is blocking users from being able to use the product.
|
||||
- You would see this in the form of many comments from different developers on the bug.
|
||||
- It represents a complete showstopper for a significant portion of users or for the development process itself.
|
||||
Impact:
|
||||
- Blocks development or testing for the entire team.
|
||||
- Major security vulnerability that could compromise user data or system integrity.
|
||||
@@ -129,15 +132,17 @@ jobs:
|
||||
Qualifier: Is the main function of the software broken?
|
||||
Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
|
||||
P1: High
|
||||
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution.
|
||||
- Feature requests are almost never P1.
|
||||
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature.
|
||||
- While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1.
|
||||
- Once again this would be affecting many users.
|
||||
- You would see this in the form of comments from different developers on the bug.
|
||||
Impact:
|
||||
- A core feature is broken or behaving incorrectly for a large number of users or large number of use cases.
|
||||
- Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases.
|
||||
- Severe performance degradation making the application frustratingly slow.
|
||||
- No straightforward workaround exists, or the workaround is difficult and non-obvious.
|
||||
Qualifier: Is a key feature unusable or giving very wrong results?
|
||||
Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable.
|
||||
Example: Gemini CLI enters a loop when making read-many-files tool call. I am unable to break out of the loop and gemini doesn't follow instructions subsequently.
|
||||
P2: Medium
|
||||
- A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality.
|
||||
Impact:
|
||||
@@ -157,48 +162,52 @@ jobs:
|
||||
- If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue
|
||||
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
|
||||
- When users report that they dont expect the model to change those would be categorized as feature requests.
|
||||
Definition of Areas
|
||||
area/ux:
|
||||
- Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance.
|
||||
- I am seeing my screen flicker when using Gemini CLI
|
||||
- I am seeing the output malformed
|
||||
- Theme changes aren't taking effect
|
||||
- My keyboard inputs arent' being recognzied
|
||||
area/platform:
|
||||
- Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework.
|
||||
area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features.
|
||||
area/models:
|
||||
- i am not getting a response that is reasonable or expected. this can include things like
|
||||
- I am calling a tool and the tool is not performing as expected.
|
||||
- i am expecting a tool to be called and it is not getting called ,
|
||||
- Including experience when using
|
||||
- built-in tools (e.g., web search, code interpreter, read file, writefile, etc..),
|
||||
- Function calling issues should be under this area
|
||||
- i am getting responses from the model that are malformed.
|
||||
- Issues concerning Gemini quality of response and inference,
|
||||
- Issues talking about unnecessary token consumption.
|
||||
- Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues.
|
||||
- Memory compression
|
||||
- unexpected responses,
|
||||
- poor quality of generated code
|
||||
area/tools:
|
||||
- These are primarily issues related to Model Context Protocol
|
||||
- These are issues that mention MCP support
|
||||
- feature requests asking for support for new tools.
|
||||
area/core:
|
||||
- Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality
|
||||
area/contribution:
|
||||
- Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure.
|
||||
area/authentication:
|
||||
- Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc..
|
||||
area/security-privacy:
|
||||
- Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access.
|
||||
area/extensibility:
|
||||
- Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc..
|
||||
area/performance:
|
||||
- Issues focused on model performance
|
||||
- Issues with running out of capacity,
|
||||
- 429 errors etc..
|
||||
- could also pertain to latency,
|
||||
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
|
||||
- Switching models from one to the other unexpectedly.
|
||||
Definition of Categories and Scopes
|
||||
|
||||
category/cli: Command line interface and interaction
|
||||
- Issues with interactive CLI features, command parsing, keyboard shortcuts
|
||||
- Related scopes: scope/commands, scope/interactive, scope/non-interactive, scope/keybindings
|
||||
|
||||
category/core: Core engine and logic
|
||||
- Issues with fundamental components, content generation, session management
|
||||
- Related scopes: scope/content-generation, scope/token-management, scope/session-management, scope/model-switching
|
||||
|
||||
category/ui: User interface and display
|
||||
- Issues with themes, UI components, rendering, markdown display
|
||||
- Related scopes: scope/themes, scope/components, scope/rendering, scope/markdown
|
||||
|
||||
category/authentication: Authentication and authorization
|
||||
- Issues with login flows, API keys, OAuth, credential storage
|
||||
- Related scopes: scope/oauth, scope/api-keys, scope/token-storage
|
||||
|
||||
category/tools: Tool integration and execution
|
||||
- Issues with MCP, shell execution, file operations, web search, memory, git integration
|
||||
- Related scopes: scope/mcp, scope/shell, scope/file-operations, scope/web-search, scope/memory, scope/git
|
||||
|
||||
category/configuration: Configuration management
|
||||
- Issues with settings, extensions, trusted folders, sandbox configuration
|
||||
- Related scopes: scope/settings, scope/extensions, scope/trusted-folders, scope/sandbox
|
||||
|
||||
category/integration: External integrations
|
||||
- Issues with IDE integration, VSCode extension, Zed integration, GitHub Actions
|
||||
- Related scopes: scope/ide, scope/vscode, scope/zed, scope/github-actions
|
||||
|
||||
category/platform: Platform compatibility
|
||||
- Issues with installation, OS compatibility, packaging
|
||||
- Related scopes: scope/installation, scope/macos, scope/windows, scope/linux, scope/packaging
|
||||
|
||||
category/performance: Performance and optimization
|
||||
- Issues with latency, memory usage, model performance, caching
|
||||
- Related scopes: scope/latency, scope/memory-usage, scope/model-performance, scope/caching
|
||||
|
||||
category/security: Security and privacy
|
||||
- Issues with data privacy, credential security, vulnerabilities
|
||||
- Related scopes: scope/data-privacy, scope/credential-security, scope/vulnerability
|
||||
|
||||
category/telemetry: Telemetry and analytics
|
||||
- Issues with metrics collection, logging, analytics
|
||||
- Related scopes: scope/metrics, scope/logging, scope/analytics
|
||||
|
||||
category/development: Development experience
|
||||
- Issues with build system, testing, CI/CD, documentation
|
||||
- Related scopes: scope/build-system, scope/testing, scope/ci-cd, scope/documentation
|
||||
237
.github/workflows/release-sdk.yml
vendored
Normal file
237
.github/workflows/release-sdk.yml
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
name: 'Release SDK'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.'
|
||||
required: false
|
||||
type: 'string'
|
||||
ref:
|
||||
description: 'The branch or ref (full git sha) to release from.'
|
||||
required: true
|
||||
type: 'string'
|
||||
default: 'main'
|
||||
dry_run:
|
||||
description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.'
|
||||
required: true
|
||||
type: 'boolean'
|
||||
default: true
|
||||
create_nightly_release:
|
||||
description: 'Auto apply the nightly release tag, input version is ignored.'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
create_preview_release:
|
||||
description: 'Auto apply the preview release tag, input version is ignored.'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
force_skip_tests:
|
||||
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
release-sdk:
|
||||
runs-on: 'ubuntu-latest'
|
||||
environment:
|
||||
name: 'production-release'
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
permissions:
|
||||
contents: 'write'
|
||||
packages: 'write'
|
||||
id-token: 'write'
|
||||
issues: 'write'
|
||||
outputs:
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set booleans for simplified logic'
|
||||
env:
|
||||
CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}'
|
||||
CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
|
||||
DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}'
|
||||
id: 'vars'
|
||||
run: |-
|
||||
is_nightly="false"
|
||||
if [[ "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then
|
||||
is_nightly="true"
|
||||
fi
|
||||
echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
is_preview="false"
|
||||
if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
|
||||
is_preview="true"
|
||||
fi
|
||||
echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
is_dry_run="false"
|
||||
if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
|
||||
is_dry_run="true"
|
||||
fi
|
||||
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
run: |-
|
||||
npm ci
|
||||
|
||||
- name: 'Get the version'
|
||||
id: 'version'
|
||||
run: |
|
||||
VERSION_ARGS=()
|
||||
if [[ "${IS_NIGHTLY}" == "true" ]]; then
|
||||
VERSION_ARGS+=(--type=nightly)
|
||||
elif [[ "${IS_PREVIEW}" == "true" ]]; then
|
||||
VERSION_ARGS+=(--type=preview)
|
||||
if [[ -n "${MANUAL_VERSION}" ]]; then
|
||||
VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}")
|
||||
fi
|
||||
else
|
||||
VERSION_ARGS+=(--type=stable)
|
||||
if [[ -n "${MANUAL_VERSION}" ]]; then
|
||||
VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}")
|
||||
fi
|
||||
fi
|
||||
|
||||
VERSION_JSON=$(node packages/sdk-typescript/scripts/get-release-version.js "${VERSION_ARGS[@]}")
|
||||
echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT"
|
||||
echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
run: |
|
||||
npm run test:ci
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Build CLI for Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run build
|
||||
npm run bundle
|
||||
|
||||
- name: 'Run SDK Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run test:integration:sdk:sandbox:none
|
||||
npm run test:integration:sdk:sandbox:docker
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Configure Git User'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: 'Create and switch to a release branch'
|
||||
id: 'release_branch'
|
||||
env:
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}"
|
||||
git switch -c "${BRANCH_NAME}"
|
||||
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Update package version'
|
||||
working-directory: 'packages/sdk-typescript'
|
||||
env:
|
||||
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:
|
||||
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
git add packages/sdk-typescript/package.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "No version changes to commit"
|
||||
else
|
||||
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
|
||||
fi
|
||||
if [[ "${IS_DRY_RUN}" == "false" ]]; then
|
||||
echo "Pushing release branch to remote..."
|
||||
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'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
|
||||
run: |-
|
||||
gh release create "sdk-typescript-${RELEASE_TAG}" \
|
||||
--target "$RELEASE_BRANCH" \
|
||||
--title "SDK TypeScript Release ${RELEASE_TAG}" \
|
||||
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
|
||||
--generate-notes
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
${{ failure() }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}"
|
||||
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |-
|
||||
gh issue create \
|
||||
--title "SDK Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
|
||||
--body "The SDK release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||
53
.github/workflows/release.yml
vendored
53
.github/workflows/release.yml
vendored
@@ -101,15 +101,27 @@ jobs:
|
||||
- name: 'Get the version'
|
||||
id: 'version'
|
||||
run: |
|
||||
VERSION_JSON=$(node scripts/get-release-version.js)
|
||||
VERSION_ARGS=()
|
||||
if [[ "${IS_NIGHTLY}" == "true" ]]; then
|
||||
VERSION_ARGS+=(--type=nightly)
|
||||
elif [[ "${IS_PREVIEW}" == "true" ]]; then
|
||||
VERSION_ARGS+=(--type=preview)
|
||||
if [[ -n "${MANUAL_VERSION}" ]]; then
|
||||
VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}")
|
||||
fi
|
||||
else
|
||||
VERSION_ARGS+=(--type=stable)
|
||||
if [[ -n "${MANUAL_VERSION}" ]]; then
|
||||
VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}")
|
||||
fi
|
||||
fi
|
||||
|
||||
VERSION_JSON=$(node scripts/get-release-version.js "${VERSION_ARGS[@]}")
|
||||
echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT"
|
||||
echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Get the previous tag for release notes generation
|
||||
CURRENT_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)
|
||||
PREVIOUS_TAG=$(node scripts/get-previous-tag.js "$CURRENT_TAG" || echo "")
|
||||
echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
|
||||
@@ -155,7 +167,11 @@ jobs:
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
git add package.json package-lock.json packages/*/package.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "No version changes to commit"
|
||||
else
|
||||
git commit -m "chore(release): ${RELEASE_TAG}"
|
||||
fi
|
||||
if [[ "${IS_DRY_RUN}" == "false" ]]; then
|
||||
echo "Pushing release branch to remote..."
|
||||
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
|
||||
@@ -163,9 +179,9 @@ jobs:
|
||||
echo "Dry run enabled. Skipping push."
|
||||
fi
|
||||
|
||||
- name: 'Build and Prepare Packages'
|
||||
- name: 'Build Bundle and Prepare Package'
|
||||
run: |-
|
||||
npm run build:packages
|
||||
npm run bundle
|
||||
npm run prepare:package
|
||||
|
||||
- name: 'Configure npm for publishing'
|
||||
@@ -175,20 +191,10 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@qwen-code'
|
||||
|
||||
- name: 'Publish @qwen-code/qwen-code-core'
|
||||
run: |-
|
||||
npm publish --workspace=@qwen-code/qwen-code-core --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: 'Install latest core package'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' }}
|
||||
run: 'npm install @qwen-code/qwen-code-core@${{ steps.version.outputs.RELEASE_VERSION }} --workspace=@qwen-code/qwen-code --save-exact'
|
||||
|
||||
- name: 'Publish @qwen-code/qwen-code'
|
||||
working-directory: 'dist'
|
||||
run: |-
|
||||
npm publish --workspace=@qwen-code/qwen-code --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-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 }}'
|
||||
|
||||
@@ -199,13 +205,13 @@ jobs:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
PREVIOUS_TAG: '${{ steps.version.outputs.PREVIOUS_TAG }}'
|
||||
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
|
||||
run: |-
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
bundle/gemini.js \
|
||||
dist/cli.js \
|
||||
--target "$RELEASE_BRANCH" \
|
||||
--title "Release ${RELEASE_TAG}" \
|
||||
--notes-start-tag "$PREVIOUS_TAG" \
|
||||
--notes-start-tag "$PREVIOUS_RELEASE_TAG" \
|
||||
--generate-notes
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
@@ -218,5 +224,4 @@ jobs:
|
||||
run: |-
|
||||
gh issue create \
|
||||
--title "Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
|
||||
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}" \
|
||||
--label "kind/bug,release-failure"
|
||||
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -3,14 +3,21 @@
|
||||
.env~
|
||||
|
||||
# gemini-cli settings
|
||||
.gemini/
|
||||
!gemini/config.yaml
|
||||
# We want to keep the .gemini in the root of the repo and ignore any .gemini
|
||||
# in subdirectories. In our root .gemini we want to allow for version control
|
||||
# for subcommands.
|
||||
**/.gemini/
|
||||
!/.gemini/
|
||||
.gemini/*
|
||||
!.gemini/config.yaml
|
||||
!.gemini/commands/
|
||||
|
||||
# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
|
||||
|
||||
# Dependency directory
|
||||
node_modules
|
||||
bower_components
|
||||
package-lock.json
|
||||
|
||||
# Editors
|
||||
.idea
|
||||
@@ -48,4 +55,5 @@ logs/
|
||||
# GHA credentials
|
||||
gha-creds-*.json
|
||||
|
||||
QWEN.md
|
||||
# Log files
|
||||
patch_output.log
|
||||
|
||||
9
.husky/pre-commit
Executable file
9
.husky/pre-commit
Executable file
@@ -0,0 +1,9 @@
|
||||
npm run pre-commit || {
|
||||
echo ''
|
||||
echo '===================================================='
|
||||
echo 'pre-commit checks failed. in case of emergency, run:'
|
||||
echo ''
|
||||
echo 'git commit --no-verify'
|
||||
echo '===================================================='
|
||||
exit 1
|
||||
}
|
||||
23
.vscode/launch.json
vendored
23
.vscode/launch.json
vendored
@@ -27,7 +27,7 @@
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "npm: build: vscode-ide-companion"
|
||||
"preLaunchTask": "launch: vscode-ide-companion (copy+build)"
|
||||
},
|
||||
{
|
||||
"name": "Attach",
|
||||
@@ -73,7 +73,15 @@
|
||||
"request": "launch",
|
||||
"name": "Launch CLI Non-Interactive",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"],
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"start",
|
||||
"--",
|
||||
"-p",
|
||||
"${input:prompt}",
|
||||
"--output-format",
|
||||
"stream-json"
|
||||
],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
@@ -108,6 +116,17 @@
|
||||
"request": "attach",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Current TS File",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": ["tsx", "${file}"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"env": {}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -12,5 +12,6 @@
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true
|
||||
}
|
||||
|
||||
16
.vscode/tasks.json
vendored
16
.vscode/tasks.json
vendored
@@ -20,6 +20,22 @@
|
||||
"problemMatcher": [],
|
||||
"label": "npm: build: vscode-ide-companion",
|
||||
"detail": "npm run build -w packages/vscode-ide-companion"
|
||||
},
|
||||
{
|
||||
"label": "copy: bundled-cli (dev)",
|
||||
"type": "shell",
|
||||
"command": "node",
|
||||
"args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "launch: vscode-ide-companion (copy+build)",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"copy: bundled-cli (dev)",
|
||||
"npm: build: vscode-ide-companion"
|
||||
],
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -86,3 +86,5 @@ ignore:
|
||||
- 'thirdparty/'
|
||||
- 'third_party/'
|
||||
- 'vendor/'
|
||||
- 'node_modules/'
|
||||
- 'integration-tests/terminal-bench/'
|
||||
|
||||
@@ -136,7 +136,7 @@ To start the Gemini CLI from the source code (after building), run the following
|
||||
npm start
|
||||
```
|
||||
|
||||
If you'd like to run the source build outside of the gemini-cli folder you can utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini`
|
||||
If you'd like to run the source build outside of the gemini-cli folder, you can utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini`
|
||||
|
||||
### Running Tests
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||
|
||||
## 💡 Free Options Available
|
||||
|
||||
@@ -88,6 +88,12 @@ npm install -g .
|
||||
brew install qwen-code
|
||||
```
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more.
|
||||
|
||||
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
10
docs/_meta.ts
Normal file
10
docs/_meta.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
index: 'Welcome to Qwen Code',
|
||||
cli: 'CLI',
|
||||
core: 'Core',
|
||||
tools: 'Tools',
|
||||
features: 'Features',
|
||||
'ide-integration': 'IDE Integration',
|
||||
development: 'Development',
|
||||
support: 'Support',
|
||||
};
|
||||
35
docs/cli/_meta.ts
Normal file
35
docs/cli/_meta.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export default {
|
||||
index: 'Introduction',
|
||||
authentication: 'Authentication',
|
||||
'openai-auth': 'OpenAI Authentication',
|
||||
commands: 'Commands',
|
||||
configuration: 'Configuration',
|
||||
'configuration-v1': 'Configuration (v1)',
|
||||
themes: 'Themes',
|
||||
tutorials: 'Tutorials',
|
||||
'keyboard-shortcuts': 'Keyboard Shortcuts',
|
||||
'trusted-folders': 'Trusted Folders',
|
||||
'qwen-ignore': 'Ignoring Files',
|
||||
Uninstall: 'Uninstall',
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* { "label": "Introduction", "slug": "docs/cli" },
|
||||
{ "label": "Authentication", "slug": "docs/cli/authentication" },
|
||||
{ "label": "Commands", "slug": "docs/cli/commands" },
|
||||
{ "label": "Configuration", "slug": "docs/cli/configuration" },
|
||||
{ "label": "Checkpointing", "slug": "docs/checkpointing" },
|
||||
{ "label": "Extensions", "slug": "docs/extension" },
|
||||
{ "label": "Headless Mode", "slug": "docs/headless" },
|
||||
{ "label": "IDE Integration", "slug": "docs/ide-integration" },
|
||||
{
|
||||
"label": "IDE Companion Spec",
|
||||
"slug": "docs/ide-companion-spec"
|
||||
},
|
||||
{ "label": "Telemetry", "slug": "docs/telemetry" },
|
||||
{ "label": "Themes", "slug": "docs/cli/themes" },
|
||||
{ "label": "Token Caching", "slug": "docs/cli/token-caching" },
|
||||
{ "label": "Trusted Folders", "slug": "docs/trusted-folders" },
|
||||
{ "label": "Tutorials", "slug": "docs/cli/tutorials" }
|
||||
*/
|
||||
@@ -131,13 +131,7 @@ OpenAI-compatible API method if configured:
|
||||
|
||||
**Example for headless environments:**
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
|
||||
export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
|
||||
If none of these environment variables are set in a non-interactive session, the CLI will exit with an error.
|
||||
|
||||
# Run Qwen Code
|
||||
qwen
|
||||
```
|
||||
|
||||
If no API key is set in a non-interactive session, the CLI will exit with an error prompting you to configure authentication.
|
||||
For comprehensive guidance on using Qwen COde programmatically and in
|
||||
automation workflows, see the [Headless Mode Guide](../headless.md).
|
||||
|
||||
@@ -9,30 +9,10 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
### Built-in Commands
|
||||
|
||||
- **`/bug`**
|
||||
- **Description:** File an issue about Qwen Code. By default, the issue is filed within the GitHub repository for Qwen Code. The string you enter after `/bug` will become the headline for the bug being filed. The default `/bug` behavior can be modified using the `bugCommand` setting in your `.qwen/settings.json` files.
|
||||
- **Description:** File an issue about Qwen Code. By default, the issue is filed within the GitHub repository for Qwen Code. The string you enter after `/bug` will become the headline for the bug being filed. The default `/bug` behavior can be modified using the `advanced.bugCommand` setting in your `.qwen/settings.json` files.
|
||||
|
||||
- **`/chat`**
|
||||
- **Description:** Save and resume conversation history for branching conversation state interactively, or resuming a previous state from a later session.
|
||||
- **Sub-commands:**
|
||||
- **`save`**
|
||||
- **Description:** Saves the current conversation history. You must add a `<tag>` for identifying the conversation state.
|
||||
- **Usage:** `/chat save <tag>`
|
||||
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
|
||||
- Linux/macOS: `~/.qwen/tmp/<project_hash>/`
|
||||
- Windows: `C:\Users\<YourUsername>\.qwen\tmp\<project_hash>\`
|
||||
- When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints.
|
||||
- **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md).
|
||||
- **`resume`**
|
||||
- **Description:** Resumes a conversation from a previous save.
|
||||
- **Usage:** `/chat resume <tag>`
|
||||
- **`list`**
|
||||
- **Description:** Lists available tags for chat state resumption.
|
||||
- **`delete`**
|
||||
- **Description:** Deletes a saved conversation checkpoint.
|
||||
- **Usage:** `/chat delete <tag>`
|
||||
|
||||
- **`/clear`**
|
||||
- **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared.
|
||||
- **`/clear`** (aliases: `reset`, `new`)
|
||||
- **Description:** Clear conversation history and free up context by starting a fresh session. Also clears the terminal output and scrollback within the CLI.
|
||||
- **Keyboard shortcut:** Press **Ctrl+L** at any time to perform a clear action.
|
||||
|
||||
- **`/summary`**
|
||||
@@ -63,17 +43,6 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
|
||||
- **Usage:** `/directory show`
|
||||
|
||||
- **`/directory`** (or **`/dir`**)
|
||||
- **Description:** Manage workspace directories for multi-directory support.
|
||||
- **Sub-commands:**
|
||||
- **`add`**:
|
||||
- **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well.
|
||||
- **Usage:** `/directory add <path1>,<path2>`
|
||||
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
|
||||
- **`show`**:
|
||||
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
|
||||
- **Usage:** `/directory show`
|
||||
|
||||
- **`/editor`**
|
||||
- **Description:** Open a dialog for selecting supported editors.
|
||||
|
||||
@@ -105,6 +74,20 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Description:** Reload the hierarchical instructional memory from all context files (default: `QWEN.md`) found in the configured locations (global, project/ancestors, and sub-directories). This updates the model with the latest context content.
|
||||
- **Note:** For more details on how context files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#context-files-hierarchical-instructional-context).
|
||||
|
||||
- **`/model`**
|
||||
- **Description:** Switch the model for the current session. Opens a dialog to select from available models based on your authentication type.
|
||||
- **Usage:** `/model`
|
||||
- **Features:**
|
||||
- Shows a dialog with all available models for your current authentication type
|
||||
- Displays model descriptions and capabilities (e.g., vision support)
|
||||
- Changes the model for the current session only
|
||||
- Supports both Qwen models (via OAuth) and OpenAI models (via API key)
|
||||
- **Available Models:**
|
||||
- **Qwen Coder:** The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)
|
||||
- **Qwen Vision:** The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23) - supports image analysis
|
||||
- **OpenAI Models:** Available when using OpenAI authentication (configured via `OPENAI_MODEL` environment variable)
|
||||
- **Note:** Model selection is session-specific and does not persist across different Qwen Code sessions. To set a default model, use the `model.name` setting in your configuration.
|
||||
|
||||
- **`/restore`**
|
||||
- **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file edits made by a tool. If run without a tool call ID, it will list available checkpoints to restore from.
|
||||
- **Usage:** `/restore [tool_call_id]`
|
||||
@@ -162,19 +145,6 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **`nodesc`** or **`nodescriptions`**:
|
||||
- **Description:** Hide tool descriptions, showing only the tool names.
|
||||
|
||||
- **`/privacy`**
|
||||
- **Description:** Display the Privacy Notice and allow users to select whether they consent to the collection of their data for service improvement purposes.
|
||||
|
||||
- **`/quit-confirm`**
|
||||
- **Description:** Show a confirmation dialog before exiting Qwen Code, allowing you to choose how to handle your current session.
|
||||
- **Usage:** `/quit-confirm`
|
||||
- **Features:**
|
||||
- **Quit immediately:** Exit without saving anything (equivalent to `/quit`)
|
||||
- **Generate summary and quit:** Create a project summary using `/summary` before exiting
|
||||
- **Save conversation and quit:** Save the current conversation with an auto-generated tag before exiting
|
||||
- **Keyboard shortcut:** Press **Ctrl+C** twice to trigger the quit confirmation dialog
|
||||
- **Note:** This command is automatically triggered when you press Ctrl+C once, providing a safety mechanism to prevent accidental exits.
|
||||
|
||||
- **`/quit`** (or **`/exit`**)
|
||||
- **Description:** Exit Qwen Code immediately without any confirmation dialog.
|
||||
|
||||
@@ -192,6 +162,16 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **`/init`**
|
||||
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
|
||||
|
||||
- [**`/language`**](./language.md)
|
||||
- **Description:** View or change the language setting for both UI and LLM output.
|
||||
- **Sub-commands:**
|
||||
- **`ui`**: Set the UI language (zh-CN or en-US)
|
||||
- **`output`**: Set the LLM output language
|
||||
- **Usage:** `/language [ui|output] [language]`
|
||||
- **Examples:**
|
||||
- `/language ui zh-CN` (set UI language to Simplified Chinese)
|
||||
- `/language output English` (set LLM output language to English)
|
||||
|
||||
### Custom Commands
|
||||
|
||||
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.
|
||||
@@ -435,6 +415,16 @@ That's it! You can now run your command in the CLI. First, you might add a file
|
||||
|
||||
Qwen Code will then execute the multi-line prompt defined in your TOML file.
|
||||
|
||||
## Input Prompt Shortcuts
|
||||
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
- **Keyboard shortcut:** Press **Ctrl+z** to undo the last action in the input prompt.
|
||||
|
||||
- **Redo:**
|
||||
- **Keyboard shortcut:** Press **Ctrl+Shift+Z** to redo the last undone action in the input prompt.
|
||||
|
||||
## At commands (`@`)
|
||||
|
||||
At commands are used to include the content of files or directories as part of your prompt to the model. These commands include git-aware filtering.
|
||||
@@ -450,7 +440,7 @@ At commands are used to include the content of files or directories as part of y
|
||||
- If a path to a directory is provided, the command attempts to read the content of files within that directory and any subdirectories.
|
||||
- Spaces in paths should be escaped with a backslash (e.g., `@My\ Documents/file.txt`).
|
||||
- The command uses the `read_many_files` tool internally. The content is fetched and then inserted into your query before being sent to the model.
|
||||
- **Git-aware filtering:** By default, git-ignored files (like `node_modules/`, `dist/`, `.env`, `.git/`) are excluded. This behavior can be changed via the `fileFiltering` settings.
|
||||
- **Git-aware filtering:** By default, git-ignored files (like `node_modules/`, `dist/`, `.env`, `.git/`) are excluded. This behavior can be changed via the `context.fileFiltering` settings.
|
||||
- **File types:** The command is intended for text-based files. While it might attempt to read any file, binary files or very large files might be skipped or truncated by the underlying `read_many_files` tool to ensure performance and relevance. The tool indicates if files were skipped.
|
||||
- **Output:** The CLI will show a tool call message indicating that `read_many_files` was used, along with a message detailing the status and the path(s) that were processed.
|
||||
|
||||
|
||||
674
docs/cli/configuration-v1.md
Normal file
674
docs/cli/configuration-v1.md
Normal file
@@ -0,0 +1,674 @@
|
||||
# Qwen Code Configuration
|
||||
|
||||
Qwen Code offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. This document outlines the different configuration methods and available settings.
|
||||
|
||||
## Configuration layers
|
||||
|
||||
Configuration is applied in the following order of precedence (lower numbers are overridden by higher numbers):
|
||||
|
||||
1. **Default values:** Hardcoded defaults within the application.
|
||||
2. **System defaults file:** System-wide default settings that can be overridden by other settings files.
|
||||
3. **User settings file:** Global settings for the current user.
|
||||
4. **Project settings file:** Project-specific settings.
|
||||
5. **System settings file:** System-wide settings that override all other settings files.
|
||||
6. **Environment variables:** System-wide or session-specific variables, potentially loaded from `.env` files.
|
||||
7. **Command-line arguments:** Values passed when launching the CLI.
|
||||
|
||||
## Settings files
|
||||
|
||||
Qwen Code uses JSON settings files for persistent configuration. There are four locations for these files:
|
||||
|
||||
- **System defaults file:**
|
||||
- **Location:** `/etc/qwen-code/system-defaults.json` (Linux), `C:\ProgramData\qwen-code\system-defaults.json` (Windows) or `/Library/Application Support/QwenCode/system-defaults.json` (macOS). The path can be overridden using the `QWEN_CODE_SYSTEM_DEFAULTS_PATH` environment variable.
|
||||
- **Scope:** Provides a base layer of system-wide default settings. These settings have the lowest precedence and are intended to be overridden by user, project, or system override settings.
|
||||
- **User settings file:**
|
||||
- **Location:** `~/.qwen/settings.json` (where `~` is your home directory).
|
||||
- **Scope:** Applies to all Qwen Code sessions for the current user.
|
||||
- **Project settings file:**
|
||||
- **Location:** `.qwen/settings.json` within your project's root directory.
|
||||
- **Scope:** Applies only when running Qwen Code from that specific project. Project settings override user settings.
|
||||
|
||||
- **System settings file:**
|
||||
- **Location:** `/etc/qwen-code/settings.json` (Linux), `C:\ProgramData\qwen-code\settings.json` (Windows) or `/Library/Application Support/QwenCode/settings.json` (macOS). The path can be overridden using the `QWEN_CODE_SYSTEM_SETTINGS_PATH` environment variable.
|
||||
- **Scope:** Applies to all Qwen Code sessions on the system, for all users. System settings override user and project settings. May be useful for system administrators at enterprises to have controls over users' Qwen Code setups.
|
||||
|
||||
**Note on environment variables in settings:** String values within your `settings.json` files can reference environment variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically resolved when the settings are loaded. For example, if you have an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`.
|
||||
|
||||
### The `.qwen` directory in your project
|
||||
|
||||
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](#sandboxing) (e.g., `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
|
||||
|
||||
### Available settings in `settings.json`:
|
||||
|
||||
- **`contextFileName`** (string or array of strings):
|
||||
- **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
|
||||
- **Default:** `QWEN.md`
|
||||
- **Example:** `"contextFileName": "AGENTS.md"`
|
||||
|
||||
- **`bugCommand`** (object):
|
||||
- **Description:** Overrides the default URL for the `/bug` command.
|
||||
- **Default:** `"urlTemplate": "https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}"`
|
||||
- **Properties:**
|
||||
- **`urlTemplate`** (string): A URL that can contain `{title}` and `{info}` placeholders.
|
||||
- **Example:**
|
||||
```json
|
||||
"bugCommand": {
|
||||
"urlTemplate": "https://bug.example.com/new?title={title}&info={info}"
|
||||
}
|
||||
```
|
||||
|
||||
- **`fileFiltering`** (object):
|
||||
- **Description:** Controls git-aware file filtering behavior for @ commands and file discovery tools.
|
||||
- **Default:** `"respectGitIgnore": true, "enableRecursiveFileSearch": true`
|
||||
- **Properties:**
|
||||
- **`respectGitIgnore`** (boolean): Whether to respect .gitignore patterns when discovering files. When set to `true`, git-ignored files (like `node_modules/`, `dist/`, `.env`) are automatically excluded from @ commands and file listing operations.
|
||||
- **`enableRecursiveFileSearch`** (boolean): Whether to enable searching recursively for filenames under the current tree when completing @ prefixes in the prompt.
|
||||
- **`disableFuzzySearch`** (boolean): When `true`, disables the fuzzy search capabilities when searching for files, which can improve performance on projects with a large number of files.
|
||||
- **Example:**
|
||||
```json
|
||||
"fileFiltering": {
|
||||
"respectGitIgnore": true,
|
||||
"enableRecursiveFileSearch": false,
|
||||
"disableFuzzySearch": true
|
||||
}
|
||||
```
|
||||
|
||||
### Troubleshooting File Search Performance
|
||||
|
||||
If you are experiencing performance issues with file searching (e.g., with `@` completions), especially in projects with a very large number of files, here are a few things you can try in order of recommendation:
|
||||
|
||||
1. **Use `.qwenignore`:** Create a `.qwenignore` file in your project root to exclude directories that contain a large number of files that you don't need to reference (e.g., build artifacts, logs, `node_modules`). Reducing the total number of files crawled is the most effective way to improve performance.
|
||||
|
||||
2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `disableFuzzySearch` to `true` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster.
|
||||
|
||||
3. **Disable Recursive File Search:** As a last resort, you can disable recursive file search entirely by setting `enableRecursiveFileSearch` to `false`. This will be the fastest option as it avoids a recursive crawl of your project. However, it means you will need to type the full path to files when using `@` completions.
|
||||
|
||||
- **`coreTools`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of core tool names that should be made available to the model. This can be used to restrict the set of built-in tools. See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"coreTools": ["ShellTool(ls -l)"]` will only allow the `ls -l` command to be executed.
|
||||
- **Default:** All tools available for use by the model.
|
||||
- **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]`.
|
||||
|
||||
- **`allowedTools`** (array of strings):
|
||||
- **Default:** `undefined`
|
||||
- **Description:** A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. The match semantics are the same as `coreTools`.
|
||||
- **Example:** `"allowedTools": ["ShellTool(git status)"]`.
|
||||
|
||||
- **`excludeTools`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of core tool names that should be excluded from the model. A tool listed in both `excludeTools` and `coreTools` is excluded. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"excludeTools": ["ShellTool(rm -rf)"]` will block the `rm -rf` command.
|
||||
- **Default**: No tools excluded.
|
||||
- **Example:** `"excludeTools": ["run_shell_command", "findFiles"]`.
|
||||
- **Security Note:** Command-specific restrictions in
|
||||
`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
|
||||
that can be executed.
|
||||
|
||||
- **`allowMCPServers`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of MCP server names that should be made available to the model. This can be used to restrict the set of MCP servers to connect to. Note that this will be ignored if `--allowed-mcp-server-names` is set.
|
||||
- **Default:** All MCP servers are available for use by the model.
|
||||
- **Example:** `"allowMCPServers": ["myPythonServer"]`.
|
||||
- **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism.
|
||||
|
||||
- **`excludeMCPServers`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of MCP server names that should be excluded from the model. A server listed in both `excludeMCPServers` and `allowMCPServers` is excluded. Note that this will be ignored if `--allowed-mcp-server-names` is set.
|
||||
- **Default**: No MCP servers excluded.
|
||||
- **Example:** `"excludeMCPServers": ["myNodeServer"]`.
|
||||
- **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism.
|
||||
|
||||
- **`autoAccept`** (boolean):
|
||||
- **Description:** Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"autoAccept": true`
|
||||
|
||||
- **`theme`** (string):
|
||||
- **Description:** Sets the visual [theme](./themes.md) for Qwen Code.
|
||||
- **Default:** `"Default"`
|
||||
- **Example:** `"theme": "GitHub"`
|
||||
|
||||
- **`vimMode`** (boolean):
|
||||
- **Description:** Enables or disables vim mode for input editing. When enabled, the input area supports vim-style navigation and editing commands with NORMAL and INSERT modes. The vim mode status is displayed in the footer and persists between sessions.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"vimMode": true`
|
||||
|
||||
- **`sandbox`** (boolean or string):
|
||||
- **Description:** Controls whether and how to use sandboxing for tool execution. If set to `true`, Qwen Code uses a pre-built `qwen-code-sandbox` Docker image. For more information, see [Sandboxing](#sandboxing).
|
||||
- **Default:** `false`
|
||||
- **Example:** `"sandbox": "docker"`
|
||||
|
||||
- **`toolDiscoveryCommand`** (string):
|
||||
- **Description:** **Align with Gemini CLI.** Defines a custom shell command for discovering tools from your project. The shell command must return on `stdout` a JSON array of [function declarations](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations). Tool wrappers are optional.
|
||||
- **Default:** Empty
|
||||
- **Example:** `"toolDiscoveryCommand": "bin/get_tools"`
|
||||
|
||||
- **`toolCallCommand`** (string):
|
||||
- **Description:** **Align with Gemini CLI.** Defines a custom shell command for calling a specific tool that was discovered using `toolDiscoveryCommand`. The shell command must meet the following criteria:
|
||||
- It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument.
|
||||
- It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall).
|
||||
- It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse).
|
||||
- **Default:** Empty
|
||||
- **Example:** `"toolCallCommand": "bin/call_tool"`
|
||||
|
||||
- **`mcpServers`** (object):
|
||||
- **Description:** Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`.
|
||||
- **Default:** Empty
|
||||
- **Properties:**
|
||||
- **`<SERVER_NAME>`** (object): The server parameters for the named server.
|
||||
- `command` (string, optional): The command to execute to start the MCP server via standard I/O.
|
||||
- `args` (array of strings, optional): Arguments to pass to the command.
|
||||
- `env` (object, optional): Environment variables to set for the server process.
|
||||
- `cwd` (string, optional): The working directory in which to start the server.
|
||||
- `url` (string, optional): The URL of an MCP server that uses Server-Sent Events (SSE) for communication.
|
||||
- `httpUrl` (string, optional): The URL of an MCP server that uses streamable HTTP for communication.
|
||||
- `headers` (object, optional): A map of HTTP headers to send with requests to `url` or `httpUrl`.
|
||||
- `timeout` (number, optional): Timeout in milliseconds for requests to this MCP server.
|
||||
- `trust` (boolean, optional): Trust this server and bypass all tool call confirmations.
|
||||
- `description` (string, optional): A brief description of the server, which may be used for display purposes.
|
||||
- `includeTools` (array of strings, optional): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (whitelist behavior). If not specified, all tools from the server are enabled by default.
|
||||
- `excludeTools` (array of strings, optional): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded.
|
||||
- **Example:**
|
||||
```json
|
||||
"mcpServers": {
|
||||
"myPythonServer": {
|
||||
"command": "python",
|
||||
"args": ["mcp_server.py", "--port", "8080"],
|
||||
"cwd": "./mcp_tools/python",
|
||||
"timeout": 5000,
|
||||
"includeTools": ["safe_tool", "file_reader"],
|
||||
},
|
||||
"myNodeServer": {
|
||||
"command": "node",
|
||||
"args": ["mcp_server.js"],
|
||||
"cwd": "./mcp_tools/node",
|
||||
"excludeTools": ["dangerous_tool", "file_deleter"]
|
||||
},
|
||||
"myDockerServer": {
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "-e", "API_KEY", "ghcr.io/foo/bar"],
|
||||
"env": {
|
||||
"API_KEY": "$MY_API_TOKEN"
|
||||
}
|
||||
},
|
||||
"mySseServer": {
|
||||
"url": "http://localhost:8081/events",
|
||||
"headers": {
|
||||
"Authorization": "Bearer $MY_SSE_TOKEN"
|
||||
},
|
||||
"description": "An example SSE-based MCP server."
|
||||
},
|
||||
"myStreamableHttpServer": {
|
||||
"httpUrl": "http://localhost:8082/stream",
|
||||
"headers": {
|
||||
"X-API-Key": "$MY_HTTP_API_KEY"
|
||||
},
|
||||
"description": "An example Streamable HTTP-based MCP server."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`checkpointing`** (object):
|
||||
- **Description:** Configures the checkpointing feature, which allows you to save and restore conversation and file states. See the [Checkpointing documentation](../checkpointing.md) for more details.
|
||||
- **Default:** `{"enabled": false}`
|
||||
- **Properties:**
|
||||
- **`enabled`** (boolean): When `true`, the `/restore` command is available.
|
||||
|
||||
- **`preferredEditor`** (string):
|
||||
- **Description:** Specifies the preferred editor to use for viewing diffs.
|
||||
- **Default:** `vscode`
|
||||
- **Example:** `"preferredEditor": "vscode"`
|
||||
|
||||
- **`telemetry`** (object)
|
||||
- **Description:** Configures logging and metrics collection for Qwen Code. For more information, see [Telemetry](../telemetry.md).
|
||||
- **Default:** `{"enabled": false, "target": "local", "otlpEndpoint": "http://localhost:4317", "logPrompts": true}`
|
||||
- **Properties:**
|
||||
- **`enabled`** (boolean): Whether or not telemetry is enabled.
|
||||
- **`target`** (string): The destination for collected telemetry. Supported values are `local` and `gcp`.
|
||||
- **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter.
|
||||
- **`logPrompts`** (boolean): Whether or not to include the content of user prompts in the logs.
|
||||
- **Example:**
|
||||
```json
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "local",
|
||||
"otlpEndpoint": "http://localhost:16686",
|
||||
"logPrompts": false
|
||||
}
|
||||
```
|
||||
- **`usageStatisticsEnabled`** (boolean):
|
||||
- **Description:** Enables or disables the collection of usage statistics. See [Usage Statistics](#usage-statistics) for more information.
|
||||
- **Default:** `true`
|
||||
- **Example:**
|
||||
```json
|
||||
"usageStatisticsEnabled": false
|
||||
```
|
||||
|
||||
- **`hideTips`** (boolean):
|
||||
- **Description:** Enables or disables helpful tips in the CLI interface.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
|
||||
```json
|
||||
"hideTips": true
|
||||
```
|
||||
|
||||
- **`hideBanner`** (boolean):
|
||||
- **Description:** Enables or disables the startup banner (ASCII art logo) in the CLI interface.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
|
||||
```json
|
||||
"hideBanner": true
|
||||
```
|
||||
|
||||
- **`maxSessionTurns`** (number):
|
||||
- **Description:** Sets the maximum number of turns for a session. If the session exceeds this limit, the CLI will stop processing and start a new chat.
|
||||
- **Default:** `-1` (unlimited)
|
||||
- **Example:**
|
||||
```json
|
||||
"maxSessionTurns": 10
|
||||
```
|
||||
|
||||
- **`summarizeToolOutput`** (object):
|
||||
- **Description:** Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting.
|
||||
- Note: Currently only the `run_shell_command` tool is supported.
|
||||
- **Default:** `{}` (Disabled by default)
|
||||
- **Example:**
|
||||
```json
|
||||
"summarizeToolOutput": {
|
||||
"run_shell_command": {
|
||||
"tokenBudget": 2000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`excludedProjectEnvVars`** (array of strings):
|
||||
- **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded.
|
||||
- **Default:** `["DEBUG", "DEBUG_MODE"]`
|
||||
- **Example:**
|
||||
```json
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
|
||||
```
|
||||
|
||||
- **`includeDirectories`** (array of strings):
|
||||
- **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag.
|
||||
- **Default:** `[]`
|
||||
- **Example:**
|
||||
```json
|
||||
"includeDirectories": [
|
||||
"/path/to/another/project",
|
||||
"../shared-library",
|
||||
"~/common-utils"
|
||||
]
|
||||
```
|
||||
|
||||
- **`loadMemoryFromIncludeDirectories`** (boolean):
|
||||
- **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
```json
|
||||
"loadMemoryFromIncludeDirectories": true
|
||||
```
|
||||
|
||||
- **`tavilyApiKey`** (string):
|
||||
- **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality.
|
||||
- **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
|
||||
- **Default:** `undefined` (web search disabled)
|
||||
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
|
||||
- **`chatCompression`** (object):
|
||||
- **Description:** Controls the settings for chat history compression, both automatic and
|
||||
when manually invoked through the /compress command.
|
||||
- **Properties:**
|
||||
- **`contextPercentageThreshold`** (number): A value between 0 and 1 that specifies the token threshold for compression as a percentage of the model's total token limit. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit.
|
||||
- **Example:**
|
||||
```json
|
||||
"chatCompression": {
|
||||
"contextPercentageThreshold": 0.6
|
||||
}
|
||||
```
|
||||
|
||||
- **`showLineNumbers`** (boolean):
|
||||
- **Description:** Controls whether line numbers are displayed in code blocks in the CLI output.
|
||||
- **Default:** `true`
|
||||
- **Example:**
|
||||
```json
|
||||
"showLineNumbers": false
|
||||
```
|
||||
|
||||
- **`accessibility`** (object):
|
||||
- **Description:** Configures accessibility features for the CLI.
|
||||
- **Properties:**
|
||||
- **`screenReader`** (boolean): Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. This can also be enabled with the `--screen-reader` command-line flag, which will take precedence over the setting.
|
||||
- **`disableLoadingPhrases`** (boolean): Disables the display of loading phrases during operations.
|
||||
- **Default:** `{"screenReader": false, "disableLoadingPhrases": false}`
|
||||
- **Example:**
|
||||
```json
|
||||
"accessibility": {
|
||||
"screenReader": true,
|
||||
"disableLoadingPhrases": true
|
||||
}
|
||||
```
|
||||
|
||||
- **`skipNextSpeakerCheck`** (boolean):
|
||||
- **Description:** Skips the next speaker check after text responses. When enabled, the system bypasses analyzing whether the AI should continue speaking.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
```json
|
||||
"skipNextSpeakerCheck": true
|
||||
```
|
||||
|
||||
- **`skipLoopDetection`** (boolean):
|
||||
- **Description:** Disables all loop detection checks (streaming and LLM-based). Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
```json
|
||||
"skipLoopDetection": true
|
||||
```
|
||||
|
||||
- **`approvalMode`** (string):
|
||||
- **Description:** Sets the default approval mode for tool usage. Accepted values are:
|
||||
- `plan`: Analyze only, do not modify files or execute commands.
|
||||
- `default`: Require approval before file edits or shell commands run.
|
||||
- `auto-edit`: Automatically approve file edits.
|
||||
- `yolo`: Automatically approve all tool calls.
|
||||
- **Default:** `"default"`
|
||||
- **Example:**
|
||||
```json
|
||||
"approvalMode": "plan"
|
||||
```
|
||||
|
||||
### Example `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "GitHub",
|
||||
"sandbox": "docker",
|
||||
"toolDiscoveryCommand": "bin/get_tools",
|
||||
"toolCallCommand": "bin/call_tool",
|
||||
"tavilyApiKey": "$TAVILY_API_KEY",
|
||||
"mcpServers": {
|
||||
"mainServer": {
|
||||
"command": "bin/mcp_server.py"
|
||||
},
|
||||
"anotherServer": {
|
||||
"command": "node",
|
||||
"args": ["mcp_server.js", "--verbose"]
|
||||
}
|
||||
},
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "local",
|
||||
"otlpEndpoint": "http://localhost:4317",
|
||||
"logPrompts": true
|
||||
},
|
||||
"usageStatisticsEnabled": true,
|
||||
"hideTips": false,
|
||||
"hideBanner": false,
|
||||
"skipNextSpeakerCheck": false,
|
||||
"skipLoopDetection": false,
|
||||
"maxSessionTurns": 10,
|
||||
"summarizeToolOutput": {
|
||||
"run_shell_command": {
|
||||
"tokenBudget": 100
|
||||
}
|
||||
},
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"],
|
||||
"includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"],
|
||||
"loadMemoryFromIncludeDirectories": true
|
||||
}
|
||||
```
|
||||
|
||||
## Shell History
|
||||
|
||||
The CLI keeps a history of shell commands you run. To avoid conflicts between different projects, this history is stored in a project-specific directory within your user's home folder.
|
||||
|
||||
- **Location:** `~/.qwen/tmp/<project_hash>/shell_history`
|
||||
- `<project_hash>` is a unique identifier generated from your project's root path.
|
||||
- The history is stored in a file named `shell_history`.
|
||||
|
||||
## Environment Variables & `.env` Files
|
||||
|
||||
Environment variables are a common way to configure applications, especially for sensitive information like API keys or for settings that might change between environments. For authentication setup, see the [Authentication documentation](./authentication.md) which covers all available authentication methods.
|
||||
|
||||
The CLI automatically loads environment variables from an `.env` file. The loading order is:
|
||||
|
||||
1. `.env` file in the current working directory.
|
||||
2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory.
|
||||
3. If still not found, it looks for `~/.env` (in the user's home directory).
|
||||
|
||||
**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file.
|
||||
|
||||
- **`OPENAI_API_KEY`**:
|
||||
- One of several available [authentication methods](./authentication.md).
|
||||
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file.
|
||||
- **`OPENAI_BASE_URL`**:
|
||||
- One of several available [authentication methods](./authentication.md).
|
||||
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file.
|
||||
- **`OPENAI_MODEL`**:
|
||||
- Specifies the default OPENAI model to use.
|
||||
- Overrides the hardcoded default
|
||||
- Example: `export OPENAI_MODEL="qwen3-coder-plus"`
|
||||
- **`GEMINI_SANDBOX`**:
|
||||
- Alternative to the `sandbox` setting in `settings.json`.
|
||||
- Accepts `true`, `false`, `docker`, `podman`, or a custom command string.
|
||||
- **`SEATBELT_PROFILE`** (macOS specific):
|
||||
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
|
||||
- `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations.
|
||||
- `strict`: Uses a strict profile that declines operations by default.
|
||||
- `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`).
|
||||
- **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself):
|
||||
- Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting.
|
||||
- **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically.
|
||||
- **`NO_COLOR`**:
|
||||
- Set to any value to disable all color output in the CLI.
|
||||
- **`CLI_TITLE`**:
|
||||
- Set to a string to customize the title of the CLI.
|
||||
- **`CODE_ASSIST_ENDPOINT`**:
|
||||
- Specifies the endpoint for the code assist server.
|
||||
- This is useful for development and testing.
|
||||
- **`TAVILY_API_KEY`**:
|
||||
- Your API key for the Tavily web search service.
|
||||
- Used to enable the `web_search` tool functionality.
|
||||
- **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search.
|
||||
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
|
||||
|
||||
## Command-Line Arguments
|
||||
|
||||
Arguments passed directly when running the CLI can override other configurations for that specific session.
|
||||
|
||||
- **`--model <model_name>`** (**`-m <model_name>`**):
|
||||
- Specifies the Qwen model to use for this session.
|
||||
- Example: `npm start -- --model qwen3-coder-plus`
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
- Starts an interactive session with the provided prompt as the initial input.
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `qwen -i "explain this code"`
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--sandbox-image`**:
|
||||
- Sets the sandbox image URI.
|
||||
- **`--debug`** (**`-d`**):
|
||||
- Enables debug mode for this session, providing more verbose output.
|
||||
- **`--all-files`** (**`-a`**):
|
||||
- If set, recursively includes all files within the current directory as context for the prompt.
|
||||
- **`--help`** (or **`-h`**):
|
||||
- Displays help information about command-line arguments.
|
||||
- **`--show-memory-usage`**:
|
||||
- Displays the current memory usage.
|
||||
- **`--yolo`**:
|
||||
- Enables YOLO mode, which automatically approves all tool calls.
|
||||
- **`--approval-mode <mode>`**:
|
||||
- Sets the approval mode for tool calls. Supported modes:
|
||||
- `plan`: Analyze only—do not modify files or execute commands.
|
||||
- `default`: Require approval for file edits or shell commands (default behavior).
|
||||
- `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others.
|
||||
- `yolo`: Automatically approve all tool calls (equivalent to `--yolo`).
|
||||
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach.
|
||||
- Example: `qwen --approval-mode auto-edit`
|
||||
- **`--allowed-tools <tool1,tool2,...>`**:
|
||||
- A comma-separated list of tool names that will bypass the confirmation dialog.
|
||||
- Example: `qwen --allowed-tools "ShellTool(git status)"`
|
||||
- **`--telemetry`**:
|
||||
- Enables [telemetry](../telemetry.md).
|
||||
- **`--telemetry-target`**:
|
||||
- Sets the telemetry target. See [telemetry](../telemetry.md) for more information.
|
||||
- **`--telemetry-otlp-endpoint`**:
|
||||
- Sets the OTLP endpoint for telemetry. See [telemetry](../telemetry.md) for more information.
|
||||
- **`--telemetry-otlp-protocol`**:
|
||||
- Sets the OTLP protocol for telemetry (`grpc` or `http`). Defaults to `grpc`. See [telemetry](../telemetry.md) for more information.
|
||||
- **`--telemetry-log-prompts`**:
|
||||
- Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) for more information.
|
||||
- **`--checkpointing`**:
|
||||
- Enables [checkpointing](../checkpointing.md).
|
||||
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
||||
- Specifies a list of extensions to use for the session. 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.
|
||||
- **`--proxy`**:
|
||||
- Sets the proxy for the CLI.
|
||||
- Example: `--proxy http://localhost:7890`.
|
||||
- **`--include-directories <dir1,dir2,...>`**:
|
||||
- Includes additional directories in the workspace for multi-directory support.
|
||||
- Can be specified multiple times or as comma-separated values.
|
||||
- 5 directories can be added at maximum.
|
||||
- Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2`
|
||||
- **`--screen-reader`**:
|
||||
- Enables screen reader mode for accessibility.
|
||||
- **`--version`**:
|
||||
- Displays the version of the CLI.
|
||||
- **`--openai-logging`**:
|
||||
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
|
||||
- **`--openai-logging-dir <directory>`**:
|
||||
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
|
||||
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
|
||||
- **`--tavily-api-key <api_key>`**:
|
||||
- Sets the Tavily API key for web search functionality for this session.
|
||||
- Example: `qwen --tavily-api-key tvly-your-api-key-here`
|
||||
|
||||
## Context Files (Hierarchical Instructional Context)
|
||||
|
||||
While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `contextFileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). This powerful feature allows you to give project-specific instructions, coding style guides, or any relevant background information to the AI, making its responses more tailored and accurate to your needs. The CLI includes UI elements, such as an indicator in the footer showing the number of loaded context files, to keep you informed about the active context.
|
||||
|
||||
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Qwen model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
|
||||
|
||||
### Example Context File Content (e.g., `QWEN.md`)
|
||||
|
||||
Here's a conceptual example of what a context file at the root of a TypeScript project might contain:
|
||||
|
||||
```markdown
|
||||
# Project: My Awesome TypeScript Library
|
||||
|
||||
## General Instructions:
|
||||
|
||||
- When generating new TypeScript code, please follow the existing coding style.
|
||||
- Ensure all new functions and classes have JSDoc comments.
|
||||
- Prefer functional programming paradigms where appropriate.
|
||||
- All code should be compatible with TypeScript 5.0 and Node.js 20+.
|
||||
|
||||
## Coding Style:
|
||||
|
||||
- Use 2 spaces for indentation.
|
||||
- Interface names should be prefixed with `I` (e.g., `IUserService`).
|
||||
- Private class members should be prefixed with an underscore (`_`).
|
||||
- Always use strict equality (`===` and `!==`).
|
||||
|
||||
## Specific Component: `src/api/client.ts`
|
||||
|
||||
- This file handles all outbound API requests.
|
||||
- When adding new API call functions, ensure they include robust error handling and logging.
|
||||
- Use the existing `fetchWithRetry` utility for all GET requests.
|
||||
|
||||
## Regarding Dependencies:
|
||||
|
||||
- Avoid introducing new external dependencies unless absolutely necessary.
|
||||
- If a new dependency is required, please state the reason.
|
||||
```
|
||||
|
||||
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
|
||||
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
1. **Global Context File:**
|
||||
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Scope: Provides default instructions for all your projects.
|
||||
2. **Project Root & Ancestors Context Files:**
|
||||
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
|
||||
- Scope: Provides context relevant to the entire project or a significant portion of it.
|
||||
3. **Sub-directory Context Files (Contextual/Local):**
|
||||
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with a `memoryDiscoveryMaxDirs` field in your `settings.json` file.
|
||||
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
|
||||
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
|
||||
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../core/memport.md).
|
||||
- **Commands for Memory Management:**
|
||||
- Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context.
|
||||
- Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI.
|
||||
- See the [Commands documentation](./commands.md#memory) for full details on the `/memory` command and its sub-commands (`show` and `refresh`).
|
||||
|
||||
By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects.
|
||||
|
||||
## Sandboxing
|
||||
|
||||
Qwen Code can execute potentially unsafe operations (like shell commands and file modifications) within a sandboxed environment to protect your system.
|
||||
|
||||
Sandboxing is disabled by default, but you can enable it in a few ways:
|
||||
|
||||
- Using `--sandbox` or `-s` flag.
|
||||
- Setting `GEMINI_SANDBOX` environment variable.
|
||||
- Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default.
|
||||
|
||||
By default, it uses a pre-built `qwen-code-sandbox` Docker image.
|
||||
|
||||
For project-specific sandboxing needs, you can create a custom Dockerfile at `.qwen/sandbox.Dockerfile` in your project's root directory. This Dockerfile can be based on the base sandbox image:
|
||||
|
||||
```dockerfile
|
||||
FROM qwen-code-sandbox
|
||||
|
||||
# Add your custom dependencies or configurations here
|
||||
# For example:
|
||||
# RUN apt-get update && apt-get install -y some-package
|
||||
# COPY ./my-config /app/my-config
|
||||
```
|
||||
|
||||
When `.qwen/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` environment variable when running Qwen Code to automatically build the custom sandbox image:
|
||||
|
||||
```bash
|
||||
BUILD_SANDBOX=1 qwen -s
|
||||
```
|
||||
|
||||
## Usage Statistics
|
||||
|
||||
To help us improve Qwen Code, we collect anonymized usage statistics. This data helps us understand how the CLI is used, identify common issues, and prioritize new features.
|
||||
|
||||
**What we collect:**
|
||||
|
||||
- **Tool Calls:** We log the names of the tools that are called, whether they succeed or fail, and how long they take to execute. We do not collect the arguments passed to the tools or any data returned by them.
|
||||
- **API Requests:** We log the model used for each request, the duration of the request, and whether it was successful. We do not collect the content of the prompts or responses.
|
||||
- **Session Information:** We collect information about the configuration of the CLI, such as the enabled tools and the approval mode.
|
||||
|
||||
**What we DON'T collect:**
|
||||
|
||||
- **Personally Identifiable Information (PII):** We do not collect any personal information, such as your name, email address, or API keys.
|
||||
- **Prompt and Response Content:** We do not log the content of your prompts or the responses from the model.
|
||||
- **File Content:** We do not log the content of any files that are read or written by the CLI.
|
||||
|
||||
**How to opt out:**
|
||||
|
||||
You can opt out of usage statistics collection at any time by setting the `usageStatisticsEnabled` property to `false` in your `settings.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"usageStatisticsEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint.
|
||||
|
||||
- **`enableWelcomeBack`** (boolean):
|
||||
- **Description:** Show welcome back dialog when returning to a project with conversation history.
|
||||
- **Default:** `true`
|
||||
- **Category:** UI
|
||||
- **Requires Restart:** No
|
||||
- **Example:** `"enableWelcomeBack": false`
|
||||
- **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command. See the [Welcome Back documentation](./welcome-back.md) for more details.
|
||||
@@ -1,5 +1,11 @@
|
||||
# Qwen Code Configuration
|
||||
|
||||
**Note on New Configuration Format**
|
||||
|
||||
The format of the `settings.json` file has been updated to a new, more organized structure. The old format will be migrated automatically.
|
||||
|
||||
For details on the previous format, please see the [v1 Configuration documentation](./configuration-v1.md).
|
||||
|
||||
Qwen Code offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. This document outlines the different configuration methods and available settings.
|
||||
|
||||
## Configuration layers
|
||||
@@ -40,119 +46,326 @@ In addition to a project settings file, a project's `.qwen` directory can contai
|
||||
|
||||
- [Custom sandbox profiles](#sandboxing) (e.g., `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
|
||||
|
||||
### Available settings in `settings.json`:
|
||||
### Available settings in `settings.json`
|
||||
|
||||
- **`contextFileName`** (string or array of strings):
|
||||
- **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
|
||||
- **Default:** `QWEN.md`
|
||||
- **Example:** `"contextFileName": "AGENTS.md"`
|
||||
Settings are organized into categories. All settings should be placed within their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`bugCommand`** (object):
|
||||
- **Description:** Overrides the default URL for the `/bug` command.
|
||||
- **Default:** `"urlTemplate": "https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}"`
|
||||
- **Properties:**
|
||||
- **`urlTemplate`** (string): A URL that can contain `{title}` and `{info}` placeholders.
|
||||
- **Example:**
|
||||
```json
|
||||
"bugCommand": {
|
||||
"urlTemplate": "https://bug.example.com/new?title={title}&info={info}"
|
||||
}
|
||||
```
|
||||
#### `general`
|
||||
|
||||
- **`fileFiltering`** (object):
|
||||
- **Description:** Controls git-aware file filtering behavior for @ commands and file discovery tools.
|
||||
- **Default:** `"respectGitIgnore": true, "enableRecursiveFileSearch": true`
|
||||
- **Properties:**
|
||||
- **`respectGitIgnore`** (boolean): Whether to respect .gitignore patterns when discovering files. When set to `true`, git-ignored files (like `node_modules/`, `dist/`, `.env`) are automatically excluded from @ commands and file listing operations.
|
||||
- **`enableRecursiveFileSearch`** (boolean): Whether to enable searching recursively for filenames under the current tree when completing @ prefixes in the prompt.
|
||||
- **`disableFuzzySearch`** (boolean): When `true`, disables the fuzzy search capabilities when searching for files, which can improve performance on projects with a large number of files.
|
||||
- **Example:**
|
||||
```json
|
||||
"fileFiltering": {
|
||||
"respectGitIgnore": true,
|
||||
"enableRecursiveFileSearch": false,
|
||||
"disableFuzzySearch": true
|
||||
}
|
||||
```
|
||||
|
||||
### Troubleshooting File Search Performance
|
||||
|
||||
If you are experiencing performance issues with file searching (e.g., with `@` completions), especially in projects with a very large number of files, here are a few things you can try in order of recommendation:
|
||||
|
||||
1. **Use `.qwenignore`:** Create a `.qwenignore` file in your project root to exclude directories that contain a large number of files that you don't need to reference (e.g., build artifacts, logs, `node_modules`). Reducing the total number of files crawled is the most effective way to improve performance.
|
||||
|
||||
2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `disableFuzzySearch` to `true` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster.
|
||||
|
||||
3. **Disable Recursive File Search:** As a last resort, you can disable recursive file search entirely by setting `enableRecursiveFileSearch` to `false`. This will be the fastest option as it avoids a recursive crawl of your project. However, it means you will need to type the full path to files when using `@` completions.
|
||||
|
||||
- **`coreTools`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of core tool names that should be made available to the model. This can be used to restrict the set of built-in tools. See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"coreTools": ["ShellTool(ls -l)"]` will only allow the `ls -l` command to be executed.
|
||||
- **Default:** All tools available for use by the model.
|
||||
- **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]`.
|
||||
|
||||
- **`allowedTools`** (array of strings):
|
||||
- **`general.preferredEditor`** (string):
|
||||
- **Description:** The preferred editor to open files in.
|
||||
- **Default:** `undefined`
|
||||
- **Description:** A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. The match semantics are the same as `coreTools`.
|
||||
- **Example:** `"allowedTools": ["ShellTool(git status)"]`.
|
||||
|
||||
- **`excludeTools`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of core tool names that should be excluded from the model. A tool listed in both `excludeTools` and `coreTools` is excluded. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"excludeTools": ["ShellTool(rm -rf)"]` will block the `rm -rf` command.
|
||||
- **Default**: No tools excluded.
|
||||
- **Example:** `"excludeTools": ["run_shell_command", "findFiles"]`.
|
||||
- **Security Note:** Command-specific restrictions in
|
||||
`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
|
||||
that can be executed.
|
||||
|
||||
- **`allowMCPServers`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of MCP server names that should be made available to the model. This can be used to restrict the set of MCP servers to connect to. Note that this will be ignored if `--allowed-mcp-server-names` is set.
|
||||
- **Default:** All MCP servers are available for use by the model.
|
||||
- **Example:** `"allowMCPServers": ["myPythonServer"]`.
|
||||
- **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism.
|
||||
|
||||
- **`excludeMCPServers`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of MCP server names that should be excluded from the model. A server listed in both `excludeMCPServers` and `allowMCPServers` is excluded. Note that this will be ignored if `--allowed-mcp-server-names` is set.
|
||||
- **Default**: No MCP servers excluded.
|
||||
- **Example:** `"excludeMCPServers": ["myNodeServer"]`.
|
||||
- **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism.
|
||||
|
||||
- **`autoAccept`** (boolean):
|
||||
- **Description:** Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe.
|
||||
- **`general.vimMode`** (boolean):
|
||||
- **Description:** Enable Vim keybindings.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"autoAccept": true`
|
||||
|
||||
- **`theme`** (string):
|
||||
- **Description:** Sets the visual [theme](./themes.md) for Qwen Code.
|
||||
- **Default:** `"Default"`
|
||||
- **Example:** `"theme": "GitHub"`
|
||||
|
||||
- **`vimMode`** (boolean):
|
||||
- **Description:** Enables or disables vim mode for input editing. When enabled, the input area supports vim-style navigation and editing commands with NORMAL and INSERT modes. The vim mode status is displayed in the footer and persists between sessions.
|
||||
- **`general.disableAutoUpdate`** (boolean):
|
||||
- **Description:** Disable automatic updates.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"vimMode": true`
|
||||
|
||||
- **`sandbox`** (boolean or string):
|
||||
- **Description:** Controls whether and how to use sandboxing for tool execution. If set to `true`, Qwen Code uses a pre-built `qwen-code-sandbox` Docker image. For more information, see [Sandboxing](#sandboxing).
|
||||
- **`general.disableUpdateNag`** (boolean):
|
||||
- **Description:** Disable update notification prompts.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"sandbox": "docker"`
|
||||
|
||||
- **`toolDiscoveryCommand`** (string):
|
||||
- **Description:** **Align with Gemini CLI.** Defines a custom shell command for discovering tools from your project. The shell command must return on `stdout` a JSON array of [function declarations](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations). Tool wrappers are optional.
|
||||
- **Default:** Empty
|
||||
- **Example:** `"toolDiscoveryCommand": "bin/get_tools"`
|
||||
- **`general.checkpointing.enabled`** (boolean):
|
||||
- **Description:** Enable session checkpointing for recovery.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`toolCallCommand`** (string):
|
||||
- **Description:** **Align with Gemini CLI.** Defines a custom shell command for calling a specific tool that was discovered using `toolDiscoveryCommand`. The shell command must meet the following criteria:
|
||||
#### `output`
|
||||
|
||||
- **`output.format`** (string):
|
||||
- **Description:** The format of the CLI output.
|
||||
- **Default:** `"text"`
|
||||
- **Values:** `"text"`, `"json"`
|
||||
|
||||
#### `ui`
|
||||
|
||||
- **`ui.theme`** (string):
|
||||
- **Description:** The color theme for the UI. See [Themes](./themes.md) for available options.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`ui.customThemes`** (object):
|
||||
- **Description:** Custom theme definitions.
|
||||
- **Default:** `{}`
|
||||
|
||||
- **`ui.hideWindowTitle`** (boolean):
|
||||
- **Description:** Hide the window title bar.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.hideTips`** (boolean):
|
||||
- **Description:** Hide helpful tips in the UI.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.hideBanner`** (boolean):
|
||||
- **Description:** Hide the application banner.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.hideFooter`** (boolean):
|
||||
- **Description:** Hide the footer from the UI.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showMemoryUsage`** (boolean):
|
||||
- **Description:** Display memory usage information in the UI.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showLineNumbers`** (boolean):
|
||||
- **Description:** Show line numbers in the chat.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showCitations`** (boolean):
|
||||
- **Description:** Show citations for generated text in the chat.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`enableWelcomeBack`** (boolean):
|
||||
- **Description:** Show welcome back dialog when returning to a project with conversation history.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.accessibility.disableLoadingPhrases`** (boolean):
|
||||
- **Description:** Disable loading phrases for accessibility.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.customWittyPhrases`** (array of strings):
|
||||
- **Description:** A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones.
|
||||
- **Default:** `[]`
|
||||
|
||||
#### `ide`
|
||||
|
||||
- **`ide.enabled`** (boolean):
|
||||
- **Description:** Enable IDE integration mode.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ide.hasSeenNudge`** (boolean):
|
||||
- **Description:** Whether the user has seen the IDE integration nudge.
|
||||
- **Default:** `false`
|
||||
|
||||
#### `privacy`
|
||||
|
||||
- **`privacy.usageStatisticsEnabled`** (boolean):
|
||||
- **Description:** Enable collection of usage statistics.
|
||||
- **Default:** `true`
|
||||
|
||||
#### `model`
|
||||
|
||||
- **`model.name`** (string):
|
||||
- **Description:** The Qwen model to use for conversations.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`model.maxSessionTurns`** (number):
|
||||
- **Description:** Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.
|
||||
- **Default:** `-1`
|
||||
|
||||
- **`model.summarizeToolOutput`** (object):
|
||||
- **Description:** Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}`
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`model.chatCompression.contextPercentageThreshold`** (number):
|
||||
- **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely.
|
||||
- **Default:** `0.7`
|
||||
|
||||
- **`model.generationConfig`** (object):
|
||||
- **Description:** Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults.
|
||||
- **Default:** `undefined`
|
||||
- **Example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"disableCacheControl": false,
|
||||
"samplingParams": {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.8,
|
||||
"max_tokens": 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`model.skipNextSpeakerCheck`** (boolean):
|
||||
- **Description:** Skip the next speaker check.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`model.skipLoopDetection`**(boolean):
|
||||
- **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`model.skipStartupContext`** (boolean):
|
||||
- **Description:** Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`model.enableOpenAILogging`** (boolean):
|
||||
- **Description:** Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`model.openAILoggingDir`** (string):
|
||||
- **Description:** Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory).
|
||||
- **Default:** `undefined`
|
||||
- **Examples:**
|
||||
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
|
||||
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
||||
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
|
||||
|
||||
#### `context`
|
||||
|
||||
- **`context.fileName`** (string or array of strings):
|
||||
- **Description:** The name of the context file(s).
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`context.importFormat`** (string):
|
||||
- **Description:** The format to use when importing memory.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`context.discoveryMaxDirs`** (number):
|
||||
- **Description:** Maximum number of directories to search for memory.
|
||||
- **Default:** `200`
|
||||
|
||||
- **`context.includeDirectories`** (array):
|
||||
- **Description:** Additional directories to include in the workspace context. Missing directories will be skipped with a warning.
|
||||
- **Default:** `[]`
|
||||
|
||||
- **`context.loadFromIncludeDirectories`** (boolean):
|
||||
- **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`context.fileFiltering.respectGitIgnore`** (boolean):
|
||||
- **Description:** Respect .gitignore files when searching.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`context.fileFiltering.respectQwenIgnore`** (boolean):
|
||||
- **Description:** Respect .qwenignore files when searching.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`context.fileFiltering.enableRecursiveFileSearch`** (boolean):
|
||||
- **Description:** Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt.
|
||||
- **Default:** `true`
|
||||
|
||||
#### `tools`
|
||||
|
||||
- **`tools.sandbox`** (boolean or string):
|
||||
- **Description:** Sandbox execution environment (can be a boolean or a path string).
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`tools.shell.enableInteractiveShell`** (boolean):
|
||||
|
||||
Use `node-pty` for an interactive shell experience. Fallback to `child_process` still applies. Defaults to `false`.
|
||||
|
||||
- **`tools.core`** (array of strings):
|
||||
- **Description:** This can be used to restrict the set of built-in tools [with an allowlist](./enterprise.md#restricting-tool-access). See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. The match semantics are the same as `tools.allowed`.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`tools.exclude`** (array of strings):
|
||||
- **Description:** Tool names to exclude from discovery.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`tools.allowed`** (array of strings):
|
||||
- **Description:** A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. For example, `["run_shell_command(git)", "run_shell_command(npm test)"]` will skip the confirmation dialog to run any `git` and `npm test` commands. See [Shell Tool command restrictions](../tools/shell.md#command-restrictions) for details on prefix matching, command chaining, etc.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`tools.approvalMode`** (string):
|
||||
- **Description:** Sets the default approval mode for tool usage. Accepted values are:
|
||||
- `plan`: Analyze only, do not modify files or execute commands.
|
||||
- `default`: Require approval before file edits or shell commands run.
|
||||
- `auto-edit`: Automatically approve file edits.
|
||||
- `yolo`: Automatically approve all tool calls.
|
||||
- **Default:** `default`
|
||||
|
||||
- **`tools.discoveryCommand`** (string):
|
||||
- **Description:** Command to run for tool discovery.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`tools.callCommand`** (string):
|
||||
- **Description:** Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria:
|
||||
- It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument.
|
||||
- It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall).
|
||||
- It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse).
|
||||
- **Default:** Empty
|
||||
- **Example:** `"toolCallCommand": "bin/call_tool"`
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`mcpServers`** (object):
|
||||
- **Description:** Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`.
|
||||
- **Default:** Empty
|
||||
- **Properties:**
|
||||
- **`<SERVER_NAME>`** (object): The server parameters for the named server.
|
||||
- **`tools.useRipgrep`** (boolean):
|
||||
- **Description:** Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.useBuiltinRipgrep`** (boolean):
|
||||
- **Description:** Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.enableToolOutputTruncation`** (boolean):
|
||||
- **Description:** Enable truncation of large tool outputs.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputThreshold`** (number):
|
||||
- **Description:** Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
|
||||
- **Default:** `25000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputLines`** (number):
|
||||
- **Description:** Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
|
||||
- **Default:** `1000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `mcp`
|
||||
|
||||
- **`mcp.serverCommand`** (string):
|
||||
- **Description:** Command to start an MCP server.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`mcp.allowed`** (array of strings):
|
||||
- **Description:** An allowlist of MCP servers to allow.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`mcp.excluded`** (array of strings):
|
||||
- **Description:** A denylist of MCP servers to exclude.
|
||||
- **Default:** `undefined`
|
||||
|
||||
#### `security`
|
||||
|
||||
- **`security.folderTrust.enabled`** (boolean):
|
||||
- **Description:** Setting to track whether Folder trust is enabled.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`security.auth.selectedType`** (string):
|
||||
- **Description:** The currently selected authentication type.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`security.auth.enforcedType`** (string):
|
||||
- **Description:** The required auth type (useful for enterprises).
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`security.auth.useExternal`** (boolean):
|
||||
- **Description:** Whether to use an external authentication flow.
|
||||
- **Default:** `undefined`
|
||||
|
||||
#### `advanced`
|
||||
|
||||
- **`advanced.autoConfigureMemory`** (boolean):
|
||||
- **Description:** Automatically configure Node.js memory limits.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`advanced.dnsResolutionOrder`** (string):
|
||||
- **Description:** The DNS resolution order.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`advanced.excludedEnvVars`** (array of strings):
|
||||
- **Description:** Environment variables to exclude from project context.
|
||||
- **Default:** `["DEBUG","DEBUG_MODE"]`
|
||||
|
||||
- **`advanced.bugCommand`** (object):
|
||||
- **Description:** Configuration for the bug report command.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`advanced.tavilyApiKey`** (string):
|
||||
- **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality.
|
||||
- **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
|
||||
- **Default:** `undefined`
|
||||
|
||||
#### `mcpServers`
|
||||
|
||||
Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`.
|
||||
|
||||
- **`mcpServers.<SERVER_NAME>`** (object): The server parameters for the named server.
|
||||
- `command` (string, optional): The command to execute to start the MCP server via standard I/O.
|
||||
- `args` (array of strings, optional): Arguments to pass to the command.
|
||||
- `env` (object, optional): Environment variables to set for the server process.
|
||||
@@ -163,226 +376,48 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
- `timeout` (number, optional): Timeout in milliseconds for requests to this MCP server.
|
||||
- `trust` (boolean, optional): Trust this server and bypass all tool call confirmations.
|
||||
- `description` (string, optional): A brief description of the server, which may be used for display purposes.
|
||||
- `includeTools` (array of strings, optional): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (whitelist behavior). If not specified, all tools from the server are enabled by default.
|
||||
- `includeTools` (array of strings, optional): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default.
|
||||
- `excludeTools` (array of strings, optional): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded.
|
||||
- **Example:**
|
||||
```json
|
||||
"mcpServers": {
|
||||
"myPythonServer": {
|
||||
"command": "python",
|
||||
"args": ["mcp_server.py", "--port", "8080"],
|
||||
"cwd": "./mcp_tools/python",
|
||||
"timeout": 5000,
|
||||
"includeTools": ["safe_tool", "file_reader"],
|
||||
},
|
||||
"myNodeServer": {
|
||||
"command": "node",
|
||||
"args": ["mcp_server.js"],
|
||||
"cwd": "./mcp_tools/node",
|
||||
"excludeTools": ["dangerous_tool", "file_deleter"]
|
||||
},
|
||||
"myDockerServer": {
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "-e", "API_KEY", "ghcr.io/foo/bar"],
|
||||
"env": {
|
||||
"API_KEY": "$MY_API_TOKEN"
|
||||
}
|
||||
},
|
||||
"mySseServer": {
|
||||
"url": "http://localhost:8081/events",
|
||||
"headers": {
|
||||
"Authorization": "Bearer $MY_SSE_TOKEN"
|
||||
},
|
||||
"description": "An example SSE-based MCP server."
|
||||
},
|
||||
"myStreamableHttpServer": {
|
||||
"httpUrl": "http://localhost:8082/stream",
|
||||
"headers": {
|
||||
"X-API-Key": "$MY_HTTP_API_KEY"
|
||||
},
|
||||
"description": "An example Streamable HTTP-based MCP server."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`checkpointing`** (object):
|
||||
- **Description:** Configures the checkpointing feature, which allows you to save and restore conversation and file states. See the [Checkpointing documentation](../checkpointing.md) for more details.
|
||||
- **Default:** `{"enabled": false}`
|
||||
- **Properties:**
|
||||
- **`enabled`** (boolean): When `true`, the `/restore` command is available.
|
||||
#### `telemetry`
|
||||
|
||||
- **`preferredEditor`** (string):
|
||||
- **Description:** Specifies the preferred editor to use for viewing diffs.
|
||||
- **Default:** `vscode`
|
||||
- **Example:** `"preferredEditor": "vscode"`
|
||||
Configures logging and metrics collection for Qwen Code. For more information, see [Telemetry](../telemetry.md).
|
||||
|
||||
- **`telemetry`** (object)
|
||||
- **Description:** Configures logging and metrics collection for Qwen Code. For more information, see [Telemetry](../telemetry.md).
|
||||
- **Default:** `{"enabled": false, "target": "local", "otlpEndpoint": "http://localhost:4317", "logPrompts": true}`
|
||||
- **Properties:**
|
||||
- **Properties:**
|
||||
- **`enabled`** (boolean): Whether or not telemetry is enabled.
|
||||
- **`target`** (string): The destination for collected telemetry. Supported values are `local` and `gcp`.
|
||||
- **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter.
|
||||
- **`otlpProtocol`** (string): The protocol for the OTLP Exporter (`grpc` or `http`).
|
||||
- **`logPrompts`** (boolean): Whether or not to include the content of user prompts in the logs.
|
||||
- **Example:**
|
||||
```json
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "local",
|
||||
"otlpEndpoint": "http://localhost:16686",
|
||||
"logPrompts": false
|
||||
}
|
||||
```
|
||||
- **`usageStatisticsEnabled`** (boolean):
|
||||
- **Description:** Enables or disables the collection of usage statistics. See [Usage Statistics](#usage-statistics) for more information.
|
||||
- **Default:** `true`
|
||||
- **Example:**
|
||||
```json
|
||||
"usageStatisticsEnabled": false
|
||||
```
|
||||
- **`outfile`** (string): The file to write telemetry to when `target` is `local`.
|
||||
- **`useCollector`** (boolean): Whether to use an external OTLP collector.
|
||||
|
||||
- **`hideTips`** (boolean):
|
||||
- **Description:** Enables or disables helpful tips in the CLI interface.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
### Example `settings.json`
|
||||
|
||||
```json
|
||||
"hideTips": true
|
||||
```
|
||||
|
||||
- **`hideBanner`** (boolean):
|
||||
- **Description:** Enables or disables the startup banner (ASCII art logo) in the CLI interface.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
|
||||
```json
|
||||
"hideBanner": true
|
||||
```
|
||||
|
||||
- **`maxSessionTurns`** (number):
|
||||
- **Description:** Sets the maximum number of turns for a session. If the session exceeds this limit, the CLI will stop processing and start a new chat.
|
||||
- **Default:** `-1` (unlimited)
|
||||
- **Example:**
|
||||
```json
|
||||
"maxSessionTurns": 10
|
||||
```
|
||||
|
||||
- **`summarizeToolOutput`** (object):
|
||||
- **Description:** Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting.
|
||||
- Note: Currently only the `run_shell_command` tool is supported.
|
||||
- **Default:** `{}` (Disabled by default)
|
||||
- **Example:**
|
||||
```json
|
||||
"summarizeToolOutput": {
|
||||
"run_shell_command": {
|
||||
"tokenBudget": 2000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`excludedProjectEnvVars`** (array of strings):
|
||||
- **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded.
|
||||
- **Default:** `["DEBUG", "DEBUG_MODE"]`
|
||||
- **Example:**
|
||||
```json
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
|
||||
```
|
||||
|
||||
- **`includeDirectories`** (array of strings):
|
||||
- **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag.
|
||||
- **Default:** `[]`
|
||||
- **Example:**
|
||||
```json
|
||||
"includeDirectories": [
|
||||
"/path/to/another/project",
|
||||
"../shared-library",
|
||||
"~/common-utils"
|
||||
]
|
||||
```
|
||||
|
||||
- **`loadMemoryFromIncludeDirectories`** (boolean):
|
||||
- **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
```json
|
||||
"loadMemoryFromIncludeDirectories": true
|
||||
```
|
||||
|
||||
- **`tavilyApiKey`** (string):
|
||||
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
|
||||
- **Default:** `undefined` (web search disabled)
|
||||
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
|
||||
- **`chatCompression`** (object):
|
||||
- **Description:** Controls the settings for chat history compression, both automatic and
|
||||
when manually invoked through the /compress command.
|
||||
- **Properties:**
|
||||
- **`contextPercentageThreshold`** (number): A value between 0 and 1 that specifies the token threshold for compression as a percentage of the model's total token limit. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit.
|
||||
- **Example:**
|
||||
```json
|
||||
"chatCompression": {
|
||||
"contextPercentageThreshold": 0.6
|
||||
}
|
||||
```
|
||||
|
||||
- **`showLineNumbers`** (boolean):
|
||||
- **Description:** Controls whether line numbers are displayed in code blocks in the CLI output.
|
||||
- **Default:** `true`
|
||||
- **Example:**
|
||||
```json
|
||||
"showLineNumbers": false
|
||||
```
|
||||
|
||||
- **`accessibility`** (object):
|
||||
- **Description:** Configures accessibility features for the CLI.
|
||||
- **Properties:**
|
||||
- **`screenReader`** (boolean): Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. This can also be enabled with the `--screen-reader` command-line flag, which will take precedence over the setting.
|
||||
- **`disableLoadingPhrases`** (boolean): Disables the display of loading phrases during operations.
|
||||
- **Default:** `{"screenReader": false, "disableLoadingPhrases": false}`
|
||||
- **Example:**
|
||||
```json
|
||||
"accessibility": {
|
||||
"screenReader": true,
|
||||
"disableLoadingPhrases": true
|
||||
}
|
||||
```
|
||||
|
||||
- **`skipNextSpeakerCheck`** (boolean):
|
||||
- **Description:** Skips the next speaker check after text responses. When enabled, the system bypasses analyzing whether the AI should continue speaking.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
```json
|
||||
"skipNextSpeakerCheck": true
|
||||
```
|
||||
|
||||
- **`skipLoopDetection`** (boolean):
|
||||
- **Description:** Disables all loop detection checks (streaming and LLM-based). Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
```json
|
||||
"skipLoopDetection": true
|
||||
```
|
||||
|
||||
- **`approvalMode`** (string):
|
||||
- **Description:** Sets the default approval mode for tool usage. Accepted values are:
|
||||
- `plan`: Analyze only, do not modify files or execute commands.
|
||||
- `default`: Require approval before file edits or shell commands run.
|
||||
- `auto-edit`: Automatically approve file edits.
|
||||
- `yolo`: Automatically approve all tool calls.
|
||||
- **Default:** `"default"`
|
||||
- **Example:**
|
||||
```json
|
||||
"approvalMode": "plan"
|
||||
```
|
||||
|
||||
### Example `settings.json`:
|
||||
Here is an example of a `settings.json` file with the nested structure, new as of v0.3.0:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"vimMode": true,
|
||||
"preferredEditor": "code"
|
||||
},
|
||||
"ui": {
|
||||
"theme": "GitHub",
|
||||
"hideBanner": true,
|
||||
"hideTips": false,
|
||||
"customWittyPhrases": [
|
||||
"You forget a thousand things every day. Make sure this is one of ’em",
|
||||
"Connecting to AGI"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"approvalMode": "yolo",
|
||||
"sandbox": "docker",
|
||||
"toolDiscoveryCommand": "bin/get_tools",
|
||||
"toolCallCommand": "bin/call_tool",
|
||||
"tavilyApiKey": "$TAVILY_API_KEY",
|
||||
"discoveryCommand": "bin/get_tools",
|
||||
"callCommand": "bin/call_tool",
|
||||
"exclude": ["write_file"]
|
||||
},
|
||||
"mcpServers": {
|
||||
"mainServer": {
|
||||
"command": "bin/mcp_server.py"
|
||||
@@ -398,20 +433,31 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
"otlpEndpoint": "http://localhost:4317",
|
||||
"logPrompts": true
|
||||
},
|
||||
"usageStatisticsEnabled": true,
|
||||
"hideTips": false,
|
||||
"hideBanner": false,
|
||||
"skipNextSpeakerCheck": false,
|
||||
"skipLoopDetection": false,
|
||||
"privacy": {
|
||||
"usageStatisticsEnabled": true
|
||||
},
|
||||
"model": {
|
||||
"name": "qwen3-coder-plus",
|
||||
"maxSessionTurns": 10,
|
||||
"enableOpenAILogging": false,
|
||||
"openAILoggingDir": "~/qwen-logs",
|
||||
"summarizeToolOutput": {
|
||||
"run_shell_command": {
|
||||
"tokenBudget": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"],
|
||||
"context": {
|
||||
"fileName": ["CONTEXT.md", "QWEN.md"],
|
||||
"includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"],
|
||||
"loadMemoryFromIncludeDirectories": true
|
||||
"loadFromIncludeDirectories": true,
|
||||
"fileFiltering": {
|
||||
"respectGitIgnore": false
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"excludedEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -433,7 +479,7 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory.
|
||||
3. If still not found, it looks for `~/.env` (in the user's home directory).
|
||||
|
||||
**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file.
|
||||
**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
|
||||
- **`OPENAI_API_KEY`**:
|
||||
- One of several available [authentication methods](./authentication.md).
|
||||
@@ -445,6 +491,27 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
- Specifies the default OPENAI model to use.
|
||||
- Overrides the hardcoded default
|
||||
- Example: `export OPENAI_MODEL="qwen3-coder-plus"`
|
||||
- **`GEMINI_TELEMETRY_ENABLED`**:
|
||||
- Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it.
|
||||
- Overrides the `telemetry.enabled` setting.
|
||||
- **`GEMINI_TELEMETRY_TARGET`**:
|
||||
- Sets the telemetry target (`local` or `gcp`).
|
||||
- Overrides the `telemetry.target` setting.
|
||||
- **`GEMINI_TELEMETRY_OTLP_ENDPOINT`**:
|
||||
- Sets the OTLP endpoint for telemetry.
|
||||
- Overrides the `telemetry.otlpEndpoint` setting.
|
||||
- **`GEMINI_TELEMETRY_OTLP_PROTOCOL`**:
|
||||
- Sets the OTLP protocol (`grpc` or `http`).
|
||||
- Overrides the `telemetry.otlpProtocol` setting.
|
||||
- **`GEMINI_TELEMETRY_LOG_PROMPTS`**:
|
||||
- Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it.
|
||||
- Overrides the `telemetry.logPrompts` setting.
|
||||
- **`GEMINI_TELEMETRY_OUTFILE`**:
|
||||
- Sets the file path to write telemetry to when the target is `local`.
|
||||
- Overrides the `telemetry.outfile` setting.
|
||||
- **`GEMINI_TELEMETRY_USE_COLLECTOR`**:
|
||||
- Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it.
|
||||
- Overrides the `telemetry.useCollector` setting.
|
||||
- **`GEMINI_SANDBOX`**:
|
||||
- Alternative to the `sandbox` setting in `settings.json`.
|
||||
- Accepts `true`, `false`, `docker`, `podman`, or a custom command string.
|
||||
@@ -460,13 +527,10 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
- Set to any value to disable all color output in the CLI.
|
||||
- **`CLI_TITLE`**:
|
||||
- Set to a string to customize the title of the CLI.
|
||||
- **`CODE_ASSIST_ENDPOINT`**:
|
||||
- Specifies the endpoint for the code assist server.
|
||||
- This is useful for development and testing.
|
||||
- **`TAVILY_API_KEY`**:
|
||||
- Your API key for the Tavily web search service.
|
||||
- Required to enable the `web_search` tool functionality.
|
||||
- If not configured, the web search tool will be disabled and skipped.
|
||||
- Used to enable the `web_search` tool functionality.
|
||||
- **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search.
|
||||
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
|
||||
|
||||
## Command-Line Arguments
|
||||
@@ -478,11 +542,37 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
- Example: `npm start -- --model qwen3-coder-plus`
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode.
|
||||
- For scripting examples, use the `--output-format json` flag to get structured output.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
- Starts an interactive session with the provided prompt as the initial input.
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `qwen -i "explain this code"`
|
||||
- **`--continue`**:
|
||||
- Resume the most recent session for the current project (current working directory).
|
||||
- Works in interactive and headless modes (e.g., `qwen --continue -p "Keep going"`).
|
||||
- **`--resume [sessionId]`**:
|
||||
- Resume a specific session for the current project. When called without an ID, an interactive picker lists only this project's sessions with prompt preview, timestamps, message count, and optional git branch.
|
||||
- If an ID is provided and not found for this project, the CLI exits with an error.
|
||||
- **`--output-format <format>`** (**`-o <format>`**):
|
||||
- **Description:** Specifies the format of the CLI output for non-interactive mode.
|
||||
- **Values:**
|
||||
- `text`: (Default) The standard human-readable output.
|
||||
- `json`: A machine-readable JSON output emitted at the end of execution.
|
||||
- `stream-json`: Streaming JSON messages emitted as they occur during execution.
|
||||
- **Note:** For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless.md) for detailed information.
|
||||
- **`--input-format <format>`**:
|
||||
- **Description:** Specifies the format consumed from standard input.
|
||||
- **Values:**
|
||||
- `text`: (Default) Standard text input from stdin or command-line arguments.
|
||||
- `stream-json`: JSON message protocol via stdin for bidirectional communication.
|
||||
- **Requirement:** `--input-format stream-json` requires `--output-format stream-json` to be set.
|
||||
- **Note:** When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless.md) for detailed information.
|
||||
- **`--include-partial-messages`**:
|
||||
- **Description:** Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming.
|
||||
- **Default:** `false`
|
||||
- **Requirement:** Requires `--output-format stream-json` to be set.
|
||||
- **Note:** See [Headless Mode](../features/headless.md) for detailed information about stream events.
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--sandbox-image`**:
|
||||
@@ -507,7 +597,7 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
- Example: `qwen --approval-mode auto-edit`
|
||||
- **`--allowed-tools <tool1,tool2,...>`**:
|
||||
- A comma-separated list of tool names that will bypass the confirmation dialog.
|
||||
- Example: `qwen --allowed-tools "ShellTool(git status)"`
|
||||
- Example: `qwen --allowed-tools "Shell(git status)"`
|
||||
- **`--telemetry`**:
|
||||
- Enables [telemetry](../telemetry.md).
|
||||
- **`--telemetry-target`**:
|
||||
@@ -535,18 +625,21 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
- 5 directories can be added at maximum.
|
||||
- Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2`
|
||||
- **`--screen-reader`**:
|
||||
- Enables screen reader mode for accessibility.
|
||||
- Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers.
|
||||
- **`--version`**:
|
||||
- Displays the version of the CLI.
|
||||
- **`--openai-logging`**:
|
||||
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
|
||||
- **`--openai-logging-dir <directory>`**:
|
||||
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
|
||||
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
|
||||
- **`--tavily-api-key <api_key>`**:
|
||||
- Sets the Tavily API key for web search functionality for this session.
|
||||
- Example: `qwen --tavily-api-key tvly-your-api-key-here`
|
||||
|
||||
## Context Files (Hierarchical Instructional Context)
|
||||
|
||||
While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `contextFileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). This powerful feature allows you to give project-specific instructions, coding style guides, or any relevant background information to the AI, making its responses more tailored and accurate to your needs. The CLI includes UI elements, such as an indicator in the footer showing the number of loaded context files, to keep you informed about the active context.
|
||||
While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `context.fileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). This powerful feature allows you to give project-specific instructions, coding style guides, or any relevant background information to the AI, making its responses more tailored and accurate to your needs. The CLI includes UI elements, such as an indicator in the footer showing the number of loaded context files, to keep you informed about the active context.
|
||||
|
||||
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Qwen model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
|
||||
|
||||
@@ -587,13 +680,13 @@ This example demonstrates how you can provide general project context, specific
|
||||
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
1. **Global Context File:**
|
||||
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Location: `~/.qwen/<configured-context-filename>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Scope: Provides default instructions for all your projects.
|
||||
2. **Project Root & Ancestors Context Files:**
|
||||
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
|
||||
- Scope: Provides context relevant to the entire project or a significant portion of it.
|
||||
3. **Sub-directory Context Files (Contextual/Local):**
|
||||
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with a `memoryDiscoveryMaxDirs` field in your `settings.json` file.
|
||||
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file.
|
||||
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
|
||||
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
|
||||
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../core/memport.md).
|
||||
@@ -651,20 +744,14 @@ To help us improve Qwen Code, we collect anonymized usage statistics. This data
|
||||
|
||||
**How to opt out:**
|
||||
|
||||
You can opt out of usage statistics collection at any time by setting the `usageStatisticsEnabled` property to `false` in your `settings.json` file:
|
||||
You can opt out of usage statistics collection at any time by setting the `usageStatisticsEnabled` property to `false` under the `privacy` category in your `settings.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"privacy": {
|
||||
"usageStatisticsEnabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint.
|
||||
|
||||
- **`enableWelcomeBack`** (boolean):
|
||||
- **Description:** Show welcome back dialog when returning to a project with conversation history.
|
||||
- **Default:** `true`
|
||||
- **Category:** UI
|
||||
- **Requires Restart:** No
|
||||
- **Example:** `"enableWelcomeBack": false`
|
||||
- **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/chat summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details.
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
# Qwen Code CLI
|
||||
|
||||
Within Qwen Code, `packages/cli` is the frontend for users to send and receive prompts with Qwen and other AI models and their associated tools. For a general overview of Qwen Code, see the [main documentation page](../index.md).
|
||||
Within Qwen Code, `packages/cli` is the frontend for users to send and receive prompts with Qwen and other AI models and their associated tools. For a general overview of Qwen Code
|
||||
|
||||
## Navigating this section
|
||||
|
||||
- **[Authentication](./authentication.md):** A guide to setting up authentication with Qwen OAuth and OpenAI-compatible providers.
|
||||
- **[Commands](./commands.md):** A reference for Qwen Code CLI commands (e.g., `/help`, `/tools`, `/theme`).
|
||||
- **[Configuration](./configuration.md):** A guide to tailoring Qwen Code CLI behavior using configuration files.
|
||||
- **[Token Caching](./token-caching.md):** Optimize API costs through token caching.
|
||||
- **[Themes](./themes.md)**: A guide to customizing the CLI's appearance with different themes.
|
||||
- **[Tutorials](tutorials.md)**: A tutorial showing how to use Qwen Code to automate a development task.
|
||||
- **[Welcome Back](./welcome-back.md)**: Learn about the Welcome Back feature that helps you resume work seamlessly across sessions.
|
||||
|
||||
## Non-interactive mode
|
||||
|
||||
@@ -22,8 +20,10 @@ The following example pipes a command to Qwen Code from your terminal:
|
||||
echo "What is fine tuning?" | qwen
|
||||
```
|
||||
|
||||
Qwen Code executes the command and prints the output to your terminal. Note that you can achieve the same behavior by using the `--prompt` or `-p` flag. For example:
|
||||
You can also use the `--prompt` or `-p` flag:
|
||||
|
||||
```bash
|
||||
qwen -p "What is fine tuning?"
|
||||
```
|
||||
|
||||
For comprehensive documentation on headless usage, scripting, automation, and advanced examples, see the **[Headless Mode](../headless.md)** guide.
|
||||
|
||||
74
docs/cli/language.md
Normal file
74
docs/cli/language.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Language Command
|
||||
|
||||
The `/language` command allows you to customize the language settings for both the Qwen Code user interface (UI) and the language model's output. This command supports two distinct functionalities:
|
||||
|
||||
1. Setting the UI language for the Qwen Code interface
|
||||
2. Setting the output language for the language model (LLM)
|
||||
|
||||
## UI Language Settings
|
||||
|
||||
To change the UI language of Qwen Code, use the `ui` subcommand:
|
||||
|
||||
```
|
||||
/language ui [zh-CN|en-US|ru-RU]
|
||||
```
|
||||
|
||||
### Available UI Languages
|
||||
|
||||
- **zh-CN**: Simplified Chinese (简体中文)
|
||||
- **en-US**: English
|
||||
- **ru-RU**: Russian (Русский)
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
/language ui zh-CN # Set UI language to Simplified Chinese
|
||||
/language ui en-US # Set UI language to English
|
||||
/language ui ru-RU # Set UI language to Russian
|
||||
```
|
||||
|
||||
### UI Language Subcommands
|
||||
|
||||
You can also use direct subcommands for convenience:
|
||||
|
||||
- `/language ui zh-CN` or `/language ui zh` or `/language ui 中文`
|
||||
- `/language ui en-US` or `/language ui en` or `/language ui english`
|
||||
- `/language ui ru-RU` or `/language ui ru` or `/language ui русский`
|
||||
|
||||
## LLM Output Language Settings
|
||||
|
||||
To set the language for the language model's responses, use the `output` subcommand:
|
||||
|
||||
```
|
||||
/language output <language>
|
||||
```
|
||||
|
||||
This command generates a language rule file that instructs the LLM to respond in the specified language. The rule file is saved to `~/.qwen/output-language.md`.
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
/language output 中文 # Set LLM output language to Chinese
|
||||
/language output English # Set LLM output language to English
|
||||
/language output 日本語 # Set LLM output language to Japanese
|
||||
```
|
||||
|
||||
## Viewing Current Settings
|
||||
|
||||
When used without arguments, the `/language` command displays the current language settings:
|
||||
|
||||
```
|
||||
/language
|
||||
```
|
||||
|
||||
This will show:
|
||||
|
||||
- Current UI language
|
||||
- Current LLM output language (if set)
|
||||
- Available subcommands
|
||||
|
||||
## Notes
|
||||
|
||||
- UI language changes take effect immediately and reload all command descriptions
|
||||
- LLM output language settings are persisted in a rule file that is automatically included in the model's context
|
||||
- To request additional UI language packs, please open an issue on GitHub
|
||||
@@ -46,25 +46,14 @@ Add a `customThemes` block to your user, project, or system `settings.json` file
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"customThemes": {
|
||||
"MyCustomTheme": {
|
||||
"name": "MyCustomTheme",
|
||||
"type": "custom",
|
||||
"Background": "#181818",
|
||||
"Foreground": "#F8F8F2",
|
||||
"LightBlue": "#82AAFF",
|
||||
"AccentBlue": "#61AFEF",
|
||||
"AccentPurple": "#C678DD",
|
||||
"AccentCyan": "#56B6C2",
|
||||
"AccentGreen": "#98C379",
|
||||
"AccentYellow": "#E5C07B",
|
||||
"AccentRed": "#E06C75",
|
||||
"Comment": "#5C6370",
|
||||
"Gray": "#ABB2BF",
|
||||
"DiffAdded": "#A6E3A1",
|
||||
"DiffRemoved": "#F38BA8",
|
||||
"DiffModified": "#89B4FA",
|
||||
"GradientColors": ["#4796E4", "#847ACE", "#C3677F"]
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +104,9 @@ To load a theme from a file, set the `theme` property in your `settings.json` to
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"theme": "/path/to/your/theme.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -154,7 +145,7 @@ The theme file must be a valid JSON file that follows the same structure as a cu
|
||||
### Using Your Custom Theme
|
||||
|
||||
- Select your custom theme using the `/theme` command in Qwen Code. Your custom theme will appear in the theme selection dialog.
|
||||
- Or, set it as the default by adding `"theme": "MyCustomTheme"` to your `settings.json`.
|
||||
- Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`.
|
||||
- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](./configuration.md) as other settings.
|
||||
|
||||
---
|
||||
|
||||
61
docs/cli/trusted-folders.md
Normal file
61
docs/cli/trusted-folders.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Trusted Folders
|
||||
|
||||
The Trusted Folders feature is a security setting that gives you control over which projects can use the full capabilities of the Qwen Code. It prevents potentially malicious code from running by asking you to approve a folder before the CLI loads any project-specific configurations from it.
|
||||
|
||||
## Enabling the Feature
|
||||
|
||||
The Trusted Folders feature is **disabled by default**. To use it, you must first enable it in your settings.
|
||||
|
||||
Add the following to your user `settings.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"folderTrust": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works: The Trust Dialog
|
||||
|
||||
Once the feature is enabled, the first time you run the Qwen Code from a folder, a dialog will automatically appear, prompting you to make a choice:
|
||||
|
||||
- **Trust folder**: Grants full trust to the current folder (e.g., `my-project`).
|
||||
- **Trust parent folder**: Grants trust to the parent directory (e.g., `safe-projects`), which automatically trusts all of its subdirectories as well. This is useful if you keep all your safe projects in one place.
|
||||
- **Don't trust**: Marks the folder as untrusted. The CLI will operate in a restricted "safe mode."
|
||||
|
||||
Your choice is saved in a central file (`~/.qwen/trustedFolders.json`), so you will only be asked once per folder.
|
||||
|
||||
## Why Trust Matters: The Impact of an Untrusted Workspace
|
||||
|
||||
When a folder is **untrusted**, the Qwen Code runs in a restricted "safe mode" to protect you. In this mode, the following features are disabled:
|
||||
|
||||
1. **Workspace Settings are Ignored**: The CLI will **not** load the `.qwen/settings.json` file from the project. This prevents the loading of custom tools and other potentially dangerous configurations.
|
||||
|
||||
2. **Environment Variables are Ignored**: The CLI will **not** load any `.env` files from the project.
|
||||
|
||||
3. **Extension Management is Restricted**: You **cannot install, update, or uninstall** extensions.
|
||||
|
||||
4. **Tool Auto-Acceptance is Disabled**: You will always be prompted before any tool is run, even if you have auto-acceptance enabled globally.
|
||||
|
||||
5. **Automatic Memory Loading is Disabled**: The CLI will not automatically load files into context from directories specified in local settings.
|
||||
|
||||
Granting trust to a folder unlocks the full functionality of the Qwen Code for that workspace.
|
||||
|
||||
## Managing Your Trust Settings
|
||||
|
||||
If you need to change a decision or see all your settings, you have a couple of options:
|
||||
|
||||
- **Change the Current Folder's Trust**: Run the `/permissions` command from within the CLI. This will bring up the same interactive dialog, allowing you to change the trust level for the current folder.
|
||||
|
||||
- **View All Trust Rules**: To see a complete list of all your trusted and untrusted folder rules, you can inspect the contents of the `~/.qwen/trustedFolders.json` file in your home directory.
|
||||
|
||||
## The Trust Check Process (Advanced)
|
||||
|
||||
For advanced users, it's helpful to know the exact order of operations for how trust is determined:
|
||||
|
||||
1. **IDE Trust Signal**: If you are using the [IDE Integration](./ide-integration.md), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority.
|
||||
|
||||
2. **Local Trust File**: If the IDE is not connected, the CLI checks the central `~/.qwen/trustedFolders.json` file.
|
||||
@@ -147,7 +147,7 @@ Processes import statements in context file content.
|
||||
- `debugMode` (boolean, optional): Whether to enable debug logging (default: false)
|
||||
- `importState` (ImportState, optional): State tracking for circular import prevention
|
||||
|
||||
**Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree
|
||||
**Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree
|
||||
|
||||
### `ProcessImportsResult`
|
||||
|
||||
@@ -187,7 +187,7 @@ Finds the project root by searching for a `.git` directory upwards from the give
|
||||
|
||||
- `startDir` (string): The directory to start searching from
|
||||
|
||||
**Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found)
|
||||
**Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found)
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ The Qwen Code core (`packages/core`) features a robust system for defining, regi
|
||||
- **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content.
|
||||
|
||||
- **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for:
|
||||
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`).
|
||||
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ListFiles`, `ReadFile`).
|
||||
- **Discovering Tools:** It can also discover tools dynamically:
|
||||
- **Command-based Discovery:** If `toolDiscoveryCommand` is configured in settings, this command is executed. It's expected to output JSON describing custom tools, which are then registered as `DiscoveredTool` instances.
|
||||
- **MCP-based Discovery:** If `mcpServerCommand` is configured, the registry can connect to a Model Context Protocol (MCP) server to list and register tools (`DiscoveredMCPTool`).
|
||||
- **Command-based Discovery:** If `tools.toolDiscoveryCommand` is configured in settings, this command is executed. It's expected to output JSON describing custom tools, which are then registered as `DiscoveredTool` instances.
|
||||
- **MCP-based Discovery:** If `mcp.mcpServerCommand` is configured, the registry can connect to a Model Context Protocol (MCP) server to list and register tools (`DiscoveredMCPTool`).
|
||||
- **Providing Schemas:** Exposing the `FunctionDeclaration` schemas of all registered tools to the model, so it knows what tools are available and how to use them.
|
||||
- **Retrieving Tools:** Allowing the core to get a specific tool by name for execution.
|
||||
|
||||
@@ -33,20 +33,24 @@ The Qwen Code core (`packages/core`) features a robust system for defining, regi
|
||||
The core comes with a suite of pre-defined tools, typically found in `packages/core/src/tools/`. These include:
|
||||
|
||||
- **File System Tools:**
|
||||
- `LSTool` (`ls.ts`): Lists directory contents.
|
||||
- `ReadFileTool` (`read-file.ts`): Reads the content of a single file. It takes an `absolute_path` parameter, which must be an absolute path.
|
||||
- `WriteFileTool` (`write-file.ts`): Writes content to a file.
|
||||
- `GrepTool` (`grep.ts`): Searches for patterns in files.
|
||||
- `GlobTool` (`glob.ts`): Finds files matching glob patterns.
|
||||
- `EditTool` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation).
|
||||
- `ReadManyFilesTool` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI).
|
||||
- `ListFiles` (`ls.ts`): Lists directory contents.
|
||||
- `ReadFile` (`read-file.ts`): Reads the content of a single file. It takes an `absolute_path` parameter, which must be an absolute path.
|
||||
- `WriteFile` (`write-file.ts`): Writes content to a file.
|
||||
- `ReadManyFiles` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI).
|
||||
- `Grep` (`grep.ts`): Searches for patterns in files.
|
||||
- `Glob` (`glob.ts`): Finds files matching glob patterns.
|
||||
- `Edit` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation).
|
||||
- **Execution Tools:**
|
||||
- `ShellTool` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation).
|
||||
- `Shell` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation).
|
||||
- **Web Tools:**
|
||||
- `WebFetchTool` (`web-fetch.ts`): Fetches content from a URL.
|
||||
- `WebSearchTool` (`web-search.ts`): Performs a web search.
|
||||
- `WebFetch` (`web-fetch.ts`): Fetches content from a URL.
|
||||
- `WebSearch` (`web-search.ts`): Performs a web search.
|
||||
- **Memory Tools:**
|
||||
- `MemoryTool` (`memoryTool.ts`): Interacts with the AI's memory.
|
||||
- `SaveMemory` (`memoryTool.ts`): Interacts with the AI's memory.
|
||||
- **Planning Tools:**
|
||||
- `Task` (`task.ts`): Delegates tasks to specialized subagents.
|
||||
- `TodoWrite` (`todoWrite.ts`): Creates and manages a structured task list.
|
||||
- `ExitPlanMode` (`exitPlanMode.ts`): Exits plan mode and returns to normal operation.
|
||||
|
||||
Each of these tools extends `BaseTool` and implements the required methods for its specific functionality.
|
||||
|
||||
@@ -69,7 +73,7 @@ Each of these tools extends `BaseTool` and implements the required methods for i
|
||||
|
||||
While direct programmatic registration of new tools by users isn't explicitly detailed as a primary workflow in the provided files for typical end-users, the architecture supports extension through:
|
||||
|
||||
- **Command-based Discovery:** Advanced users or project administrators can define a `toolDiscoveryCommand` in `settings.json`. This command, when run by the core, should output a JSON array of `FunctionDeclaration` objects. The core will then make these available as `DiscoveredTool` instances. The corresponding `toolCallCommand` would then be responsible for actually executing these custom tools.
|
||||
- **Command-based Discovery:** Advanced users or project administrators can define a `tools.toolDiscoveryCommand` in `settings.json`. This command, when run by the core, should output a JSON array of `FunctionDeclaration` objects. The core will then make these available as `DiscoveredTool` instances. The corresponding `tools.toolCallCommand` would then be responsible for actually executing these custom tools.
|
||||
- **MCP Server(s):** For more complex scenarios, one or more MCP servers can be set up and configured via the `mcpServers` setting in `settings.json`. The core can then discover and use tools exposed by these servers. As mentioned, if you have multiple MCP servers, the tool names will be prefixed with the server name from your configuration (e.g., `serverAlias__actualToolName`).
|
||||
|
||||
This tool system provides a flexible and powerful way to augment the model's capabilities, making Qwen Code a versatile assistant for a wide range of tasks.
|
||||
|
||||
8
docs/development/_meta.ts
Normal file
8
docs/development/_meta.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
architecture: 'Architecture',
|
||||
npm: 'NPM',
|
||||
deployment: 'Deployment',
|
||||
telemetry: 'Telemetry',
|
||||
'integration-tests': 'Integration Tests',
|
||||
'issue-and-pr-automation': 'Issue and PR Automation',
|
||||
};
|
||||
@@ -20,7 +20,7 @@ npm run test:e2e
|
||||
|
||||
## Running a specific set of tests
|
||||
|
||||
To run a subset of test files, you can use `npm run <integration test command> <file_name1> ....` where <integration test command> is either `test:e2e` or `test:integration*` and `<file_name>` is any of the `.test.js` files in the `integration-tests/` directory. For example, the following command runs `list_directory.test.js` and `write_file.test.js`:
|
||||
To run a subset of test files, you can use `npm run <integration test command> <file_name1> ....` where <integration test command> is either `test:e2e` or `test:integration*` and `<file_name>` is any of the `.test.js` files in the `integration-tests/` directory. For example, the following command runs `list_directory.test.js` and `write_file.test.js`:
|
||||
|
||||
```bash
|
||||
npm run test:e2e list_directory write_file
|
||||
@@ -16,10 +16,10 @@ Here is a breakdown of the specific automation workflows that run in our reposit
|
||||
|
||||
This is the first bot you will interact with when you create an issue. Its job is to perform an initial analysis and apply the correct labels.
|
||||
|
||||
- **Workflow File**: `.github/workflows/gemini-automated-issue-triage.yml`
|
||||
- **Workflow File**: `.github/workflows/qwen-automated-issue-triage.yml`
|
||||
- **When it runs**: Immediately after an issue is created or reopened.
|
||||
- **What it does**:
|
||||
- It uses a Gemini model to analyze the issue's title and body against a detailed set of guidelines.
|
||||
- It uses a Qwen model to analyze the issue's title and body against a detailed set of guidelines.
|
||||
- **Applies one `area/*` label**: Categorizes the issue into a functional area of the project (e.g., `area/ux`, `area/models`, `area/platform`).
|
||||
- **Applies one `kind/*` label**: Identifies the type of issue (e.g., `kind/bug`, `kind/enhancement`, `kind/question`).
|
||||
- **Applies one `priority/*` label**: Assigns a priority from P0 (critical) to P3 (low) based on the described impact.
|
||||
@@ -47,7 +47,7 @@ This workflow ensures that all changes meet our quality standards before they ca
|
||||
|
||||
This workflow runs periodically to ensure all open PRs are correctly linked to issues and have consistent labels.
|
||||
|
||||
- **Workflow File**: `.github/workflows/gemini-scheduled-pr-triage.yml`
|
||||
- **Workflow File**: `.github/workflows/qwen-scheduled-pr-triage.yml`
|
||||
- **When it runs**: Every 15 minutes on all open pull requests.
|
||||
- **What it does**:
|
||||
- **Checks for a linked issue**: The bot scans your PR description for a keyword that links it to an issue (e.g., `Fixes #123`, `Closes #456`).
|
||||
@@ -61,11 +61,11 @@ This workflow runs periodically to ensure all open PRs are correctly linked to i
|
||||
|
||||
This is a fallback workflow to ensure that no issue gets missed by the triage process.
|
||||
|
||||
- **Workflow File**: `.github/workflows/gemini-scheduled-issue-triage.yml`
|
||||
- **Workflow File**: `.github/workflows/qwen-scheduled-issue-triage.yml`
|
||||
- **When it runs**: Every hour on all open issues.
|
||||
- **What it does**:
|
||||
- It actively seeks out issues that either have no labels at all or still have the `status/need-triage` label.
|
||||
- It then triggers the same powerful Gemini-based analysis as the initial triage bot to apply the correct labels.
|
||||
- It then triggers the same powerful QwenCode-based analysis as the initial triage bot to apply the correct labels.
|
||||
- **What you should do**:
|
||||
- You typically don't need to do anything. This workflow is a safety net to ensure every issue is eventually categorized, even if the initial triage fails.
|
||||
|
||||
369
docs/development/telemetry.md
Normal file
369
docs/development/telemetry.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Observability with OpenTelemetry
|
||||
|
||||
Learn how to enable and setup OpenTelemetry for Qwen Code.
|
||||
|
||||
- [Observability with OpenTelemetry](#observability-with-opentelemetry)
|
||||
- [Key Benefits](#key-benefits)
|
||||
- [OpenTelemetry Integration](#opentelemetry-integration)
|
||||
- [Configuration](#configuration)
|
||||
- [Google Cloud Telemetry](#google-cloud-telemetry)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Direct Export (Recommended)](#direct-export-recommended)
|
||||
- [Collector-Based Export (Advanced)](#collector-based-export-advanced)
|
||||
- [Local Telemetry](#local-telemetry)
|
||||
- [File-based Output (Recommended)](#file-based-output-recommended)
|
||||
- [Collector-Based Export (Advanced)](#collector-based-export-advanced-1)
|
||||
- [Logs and Metrics](#logs-and-metrics)
|
||||
- [Logs](#logs)
|
||||
- [Metrics](#metrics)
|
||||
|
||||
## Key Benefits
|
||||
|
||||
- **🔍 Usage Analytics**: Understand interaction patterns and feature adoption
|
||||
across your team
|
||||
- **⚡ Performance Monitoring**: Track response times, token consumption, and
|
||||
resource utilization
|
||||
- **🐛 Real-time Debugging**: Identify bottlenecks, failures, and error patterns
|
||||
as they occur
|
||||
- **📊 Workflow Optimization**: Make informed decisions to improve
|
||||
configurations and processes
|
||||
- **🏢 Enterprise Governance**: Monitor usage across teams, track costs, ensure
|
||||
compliance, and integrate with existing monitoring infrastructure
|
||||
|
||||
## OpenTelemetry Integration
|
||||
|
||||
Built on **[OpenTelemetry]** — the vendor-neutral, industry-standard
|
||||
observability framework — Qwen Code's observability system provides:
|
||||
|
||||
- **Universal Compatibility**: Export to any OpenTelemetry backend (Google
|
||||
Cloud, Jaeger, Prometheus, Datadog, etc.)
|
||||
- **Standardized Data**: Use consistent formats and collection methods across
|
||||
your toolchain
|
||||
- **Future-Proof Integration**: Connect with existing and future observability
|
||||
infrastructure
|
||||
- **No Vendor Lock-in**: Switch between backends without changing your
|
||||
instrumentation
|
||||
|
||||
[OpenTelemetry]: https://opentelemetry.io/
|
||||
|
||||
## Configuration
|
||||
|
||||
All telemetry behavior is controlled through your `.qwen/settings.json` file.
|
||||
These settings can be overridden by environment variables or CLI flags.
|
||||
|
||||
| Setting | Environment Variable | CLI Flag | Description | Values | Default |
|
||||
| -------------- | -------------------------------- | -------------------------------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- |
|
||||
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` |
|
||||
| `target` | `GEMINI_TELEMETRY_TARGET` | `--telemetry-target <local\|gcp>` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
|
||||
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint <URL>` | OTLP collector endpoint | URL string | `http://localhost:4317` |
|
||||
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol <grpc\|http>` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
|
||||
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | `--telemetry-outfile <path>` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
|
||||
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` |
|
||||
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` |
|
||||
|
||||
**Note on boolean environment variables:** For the boolean settings (`enabled`,
|
||||
`logPrompts`, `useCollector`), setting the corresponding environment variable to
|
||||
`true` or `1` will enable the feature. Any other value will disable it.
|
||||
|
||||
For detailed information about all configuration options, see the
|
||||
[Configuration Guide](./cli/configuration.md).
|
||||
|
||||
## Google Cloud Telemetry
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before using either method below, complete these steps:
|
||||
|
||||
1. Set your Google Cloud project ID:
|
||||
- For telemetry in a separate project from inference:
|
||||
```bash
|
||||
export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id"
|
||||
```
|
||||
- For telemetry in the same project as inference:
|
||||
```bash
|
||||
export GOOGLE_CLOUD_PROJECT="your-project-id"
|
||||
```
|
||||
|
||||
2. Authenticate with Google Cloud:
|
||||
- If using a user account:
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
- If using a service account:
|
||||
```bash
|
||||
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json"
|
||||
```
|
||||
3. Make sure your account or service account has these IAM roles:
|
||||
- Cloud Trace Agent
|
||||
- Monitoring Metric Writer
|
||||
- Logs Writer
|
||||
|
||||
4. Enable the required Google Cloud APIs (if not already enabled):
|
||||
```bash
|
||||
gcloud services enable \
|
||||
cloudtrace.googleapis.com \
|
||||
monitoring.googleapis.com \
|
||||
logging.googleapis.com \
|
||||
--project="$OTLP_GOOGLE_CLOUD_PROJECT"
|
||||
```
|
||||
|
||||
### Direct Export (Recommended)
|
||||
|
||||
Sends telemetry directly to Google Cloud services. No collector needed.
|
||||
|
||||
1. Enable telemetry in your `.qwen/settings.json`:
|
||||
```json
|
||||
{
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp"
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Run Qwen Code and send prompts.
|
||||
3. View logs and metrics:
|
||||
- Open the Google Cloud Console in your browser after sending prompts:
|
||||
- Logs: https://console.cloud.google.com/logs/
|
||||
- Metrics: https://console.cloud.google.com/monitoring/metrics-explorer
|
||||
- Traces: https://console.cloud.google.com/traces/list
|
||||
|
||||
### Collector-Based Export (Advanced)
|
||||
|
||||
For custom processing, filtering, or routing, use an OpenTelemetry collector to
|
||||
forward data to Google Cloud.
|
||||
|
||||
1. Configure your `.qwen/settings.json`:
|
||||
```json
|
||||
{
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp",
|
||||
"useCollector": true
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Run the automation script:
|
||||
```bash
|
||||
npm run telemetry -- --target=gcp
|
||||
```
|
||||
This will:
|
||||
- Start a local OTEL collector that forwards to Google Cloud
|
||||
- Configure your workspace
|
||||
- Provide links to view traces, metrics, and logs in Google Cloud Console
|
||||
- Save collector logs to `~/.qwen/tmp/<projectHash>/otel/collector-gcp.log`
|
||||
- Stop collector on exit (e.g. `Ctrl+C`)
|
||||
3. Run Qwen Code and send prompts.
|
||||
4. View logs and metrics:
|
||||
- Open the Google Cloud Console in your browser after sending prompts:
|
||||
- Logs: https://console.cloud.google.com/logs/
|
||||
- Metrics: https://console.cloud.google.com/monitoring/metrics-explorer
|
||||
- Traces: https://console.cloud.google.com/traces/list
|
||||
- Open `~/.qwen/tmp/<projectHash>/otel/collector-gcp.log` to view local
|
||||
collector logs.
|
||||
|
||||
## Local Telemetry
|
||||
|
||||
For local development and debugging, you can capture telemetry data locally:
|
||||
|
||||
### File-based Output (Recommended)
|
||||
|
||||
1. Enable telemetry in your `.qwen/settings.json`:
|
||||
```json
|
||||
{
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "local",
|
||||
"otlpEndpoint": "",
|
||||
"outfile": ".qwen/telemetry.log"
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Run Qwen Code and send prompts.
|
||||
3. View logs and metrics in the specified file (e.g., `.qwen/telemetry.log`).
|
||||
|
||||
### Collector-Based Export (Advanced)
|
||||
|
||||
1. Run the automation script:
|
||||
```bash
|
||||
npm run telemetry -- --target=local
|
||||
```
|
||||
This will:
|
||||
- Download and start Jaeger and OTEL collector
|
||||
- Configure your workspace for local telemetry
|
||||
- Provide a Jaeger UI at http://localhost:16686
|
||||
- Save logs/metrics to `~/.qwen/tmp/<projectHash>/otel/collector.log`
|
||||
- Stop collector on exit (e.g. `Ctrl+C`)
|
||||
2. Run Qwen Code and send prompts.
|
||||
3. View traces at http://localhost:16686 and logs/metrics in the collector log
|
||||
file.
|
||||
|
||||
## Logs and Metrics
|
||||
|
||||
The following section describes the structure of logs and metrics generated for
|
||||
Qwen Code.
|
||||
|
||||
- A `sessionId` is included as a common attribute on all logs and metrics.
|
||||
|
||||
### Logs
|
||||
|
||||
Logs are timestamped records of specific events. The following events are logged for Qwen Code:
|
||||
|
||||
- `qwen-code.config`: This event occurs once at startup with the CLI's configuration.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
- `embedding_model` (string)
|
||||
- `sandbox_enabled` (boolean)
|
||||
- `core_tools_enabled` (string)
|
||||
- `approval_mode` (string)
|
||||
- `api_key_enabled` (boolean)
|
||||
- `vertex_ai_enabled` (boolean)
|
||||
- `code_assist_enabled` (boolean)
|
||||
- `log_prompts_enabled` (boolean)
|
||||
- `file_filtering_respect_git_ignore` (boolean)
|
||||
- `debug_mode` (boolean)
|
||||
- `mcp_servers` (string)
|
||||
- `output_format` (string: "text" or "json")
|
||||
|
||||
- `qwen-code.user_prompt`: This event occurs when a user submits a prompt.
|
||||
- **Attributes**:
|
||||
- `prompt_length` (int)
|
||||
- `prompt_id` (string)
|
||||
- `prompt` (string, this attribute is excluded if `log_prompts_enabled` is
|
||||
configured to be `false`)
|
||||
- `auth_type` (string)
|
||||
|
||||
- `qwen-code.tool_call`: This event occurs for each function call.
|
||||
- **Attributes**:
|
||||
- `function_name`
|
||||
- `function_args`
|
||||
- `duration_ms`
|
||||
- `success` (boolean)
|
||||
- `decision` (string: "accept", "reject", "auto_accept", or "modify", if
|
||||
applicable)
|
||||
- `error` (if applicable)
|
||||
- `error_type` (if applicable)
|
||||
- `content_length` (int, if applicable)
|
||||
- `metadata` (if applicable, dictionary of string -> any)
|
||||
|
||||
- `qwen-code.file_operation`: This event occurs for each file operation.
|
||||
- **Attributes**:
|
||||
- `tool_name` (string)
|
||||
- `operation` (string: "create", "read", "update")
|
||||
- `lines` (int, if applicable)
|
||||
- `mimetype` (string, if applicable)
|
||||
- `extension` (string, if applicable)
|
||||
- `programming_language` (string, if applicable)
|
||||
- `diff_stat` (json string, if applicable): A JSON string with the following members:
|
||||
- `ai_added_lines` (int)
|
||||
- `ai_removed_lines` (int)
|
||||
- `user_added_lines` (int)
|
||||
- `user_removed_lines` (int)
|
||||
|
||||
- `qwen-code.api_request`: This event occurs when making a request to Qwen API.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `request_text` (if applicable)
|
||||
|
||||
- `qwen-code.api_error`: This event occurs if the API request fails.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `error`
|
||||
- `error_type`
|
||||
- `status_code`
|
||||
- `duration_ms`
|
||||
- `auth_type`
|
||||
|
||||
- `qwen-code.api_response`: This event occurs upon receiving a response from Qwen API.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `status_code`
|
||||
- `duration_ms`
|
||||
- `error` (optional)
|
||||
- `input_token_count`
|
||||
- `output_token_count`
|
||||
- `cached_content_token_count`
|
||||
- `thoughts_token_count`
|
||||
- `tool_token_count`
|
||||
- `response_text` (if applicable)
|
||||
- `auth_type`
|
||||
|
||||
- `qwen-code.tool_output_truncated`: This event occurs when the output of a tool call is too large and gets truncated.
|
||||
- **Attributes**:
|
||||
- `tool_name` (string)
|
||||
- `original_content_length` (int)
|
||||
- `truncated_content_length` (int)
|
||||
- `threshold` (int)
|
||||
- `lines` (int)
|
||||
- `prompt_id` (string)
|
||||
|
||||
- `qwen-code.malformed_json_response`: This event occurs when a `generateJson` response from Qwen API cannot be parsed as a json.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
|
||||
- `qwen-code.flash_fallback`: This event occurs when Qwen Code switches to flash as fallback.
|
||||
- **Attributes**:
|
||||
- `auth_type`
|
||||
|
||||
- `qwen-code.slash_command`: This event occurs when a user executes a slash command.
|
||||
- **Attributes**:
|
||||
- `command` (string)
|
||||
- `subcommand` (string, if applicable)
|
||||
|
||||
- `qwen-code.extension_enable`: This event occurs when an extension is enabled
|
||||
- `qwen-code.extension_install`: This event occurs when an extension is installed
|
||||
- **Attributes**:
|
||||
- `extension_name` (string)
|
||||
- `extension_version` (string)
|
||||
- `extension_source` (string)
|
||||
- `status` (string)
|
||||
- `qwen-code.extension_uninstall`: This event occurs when an extension is uninstalled
|
||||
|
||||
### Metrics
|
||||
|
||||
Metrics are numerical measurements of behavior over time. The following metrics are collected for Qwen Code (metric names remain `qwen-code.*` for compatibility):
|
||||
|
||||
- `qwen-code.session.count` (Counter, Int): Incremented once per CLI startup.
|
||||
|
||||
- `qwen-code.tool.call.count` (Counter, Int): Counts tool calls.
|
||||
- **Attributes**:
|
||||
- `function_name`
|
||||
- `success` (boolean)
|
||||
- `decision` (string: "accept", "reject", or "modify", if applicable)
|
||||
- `tool_type` (string: "mcp", or "native", if applicable)
|
||||
|
||||
- `qwen-code.tool.call.latency` (Histogram, ms): Measures tool call latency.
|
||||
- **Attributes**:
|
||||
- `function_name`
|
||||
- `decision` (string: "accept", "reject", or "modify", if applicable)
|
||||
|
||||
- `qwen-code.api.request.count` (Counter, Int): Counts all API requests.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `status_code`
|
||||
- `error_type` (if applicable)
|
||||
|
||||
- `qwen-code.api.request.latency` (Histogram, ms): Measures API request latency.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
|
||||
- `qwen-code.token.usage` (Counter, Int): Counts the number of tokens used.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `type` (string: "input", "output", "thought", "cache", or "tool")
|
||||
|
||||
- `qwen-code.file.operation.count` (Counter, Int): Counts file operations.
|
||||
- **Attributes**:
|
||||
- `operation` (string: "create", "read", "update"): The type of file operation.
|
||||
- `lines` (Int, if applicable): Number of lines in the file.
|
||||
- `mimetype` (string, if applicable): Mimetype of the file.
|
||||
- `extension` (string, if applicable): File extension of the file.
|
||||
- `model_added_lines` (Int, if applicable): Number of lines added/changed by the model.
|
||||
- `model_removed_lines` (Int, if applicable): Number of lines removed/changed by the model.
|
||||
- `user_added_lines` (Int, if applicable): Number of lines added/changed by user in AI proposed changes.
|
||||
- `user_removed_lines` (Int, if applicable): Number of lines removed/changed by user in AI proposed changes.
|
||||
- `programming_language` (string, if applicable): The programming language of the file.
|
||||
|
||||
- `qwen-code.chat_compression` (Counter, Int): Counts chat compression operations
|
||||
- **Attributes**:
|
||||
- `tokens_before`: (Int): Number of tokens in context prior to compression
|
||||
- `tokens_after`: (Int): Number of tokens in context after compression
|
||||
@@ -1,100 +0,0 @@
|
||||
# Qwen Code Extensions
|
||||
|
||||
Qwen Code supports extensions that can be used to configure and extend its functionality.
|
||||
|
||||
## How it works
|
||||
|
||||
On startup, Qwen Code looks for extensions in two locations:
|
||||
|
||||
1. `<workspace>/.qwen/extensions`
|
||||
2. `<home>/.qwen/extensions`
|
||||
|
||||
Qwen Code loads all extensions from both locations. If an extension with the same name exists in both locations, the extension in the workspace directory takes precedence.
|
||||
|
||||
Within each location, individual extensions exist as a directory that contains a `qwen-extension.json` file. For example:
|
||||
|
||||
`<workspace>/.qwen/extensions/my-extension/qwen-extension.json`
|
||||
|
||||
### `qwen-extension.json`
|
||||
|
||||
The `qwen-extension.json` file contains the configuration for the extension. The file has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "QWEN.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
|
||||
|
||||
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||
|
||||
## Extension Commands
|
||||
|
||||
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
|
||||
|
||||
### Example
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
```
|
||||
.qwen/extensions/gcp/
|
||||
├── qwen-extension.json
|
||||
└── commands/
|
||||
├── deploy.toml
|
||||
└── gcs/
|
||||
└── sync.toml
|
||||
```
|
||||
|
||||
Would provide these commands:
|
||||
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user or project commands:
|
||||
|
||||
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
|
||||
2. **With conflict**: Extension command is renamed with the extension prefix (e.g., `/gcp.deploy`)
|
||||
|
||||
For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag)
|
||||
|
||||
## Installing Extensions
|
||||
|
||||
You can install extensions using the `install` command. This command allows you to install extensions from a Git repository or a local path.
|
||||
|
||||
### Usage
|
||||
|
||||
`qwen extensions install <source> | [options]`
|
||||
|
||||
### Options
|
||||
|
||||
- `source <url> positional argument`: The URL of a Git repository to install the extension from. The repository must contain a `qwen-extension.json` file in its root.
|
||||
- `--path <path>`: The path to a local directory to install as an extension. The directory must contain a `qwen-extension.json` file.
|
||||
|
||||
# Variables
|
||||
|
||||
Qwen Code extensions allow variable substitution in `qwen-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
| variable | description |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.qwen/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
121
docs/extensions/extension-releasing.md
Normal file
121
docs/extensions/extension-releasing.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Extension Releasing
|
||||
|
||||
There are two primary ways of releasing extensions to users:
|
||||
|
||||
- [Git repository](#releasing-through-a-git-repository)
|
||||
- [Github Releases](#releasing-through-github-releases)
|
||||
|
||||
Git repository releases tend to be the simplest and most flexible approach, while GitHub releases can be more efficient on initial install as they are shipped as single archives instead of requiring a git clone which downloads each file individually. Github releases may also contain platform specific archives if you need to ship platform specific binary files.
|
||||
|
||||
## Releasing through a git repository
|
||||
|
||||
This is the most flexible and simple option. All you need to do us create a publicly accessible git repo (such as a public github repository) and then users can install your extension using `qwen extensions install <your-repo-uri>`, or for a GitHub repository they can use the simplified `qwen extensions install <org>/<repo>` format. They can optionally depend on a specific ref (branch/tag/commit) using the `--ref=<some-ref>` argument, this defaults to the default branch.
|
||||
|
||||
Whenever commits are pushed to the ref that a user depends on, they will be prompted to update the extension. Note that this also allows for easy rollbacks, the HEAD commit is always treated as the latest version regardless of the actual version in the `qwen-extension.json` file.
|
||||
|
||||
### Managing release channels using a git repository
|
||||
|
||||
Users can depend on any ref from your git repo, such as a branch or tag, which allows you to manage multiple release channels.
|
||||
|
||||
For instance, you can maintain a `stable` branch, which users can install this way `qwen extensions install <your-repo-uri> --ref=stable`. Or, you could make this the default by treating your default branch as your stable release branch, and doing development in a different branch (for instance called `dev`). You can maintain as many branches or tags as you like, providing maximum flexibility for you and your users.
|
||||
|
||||
Note that these `ref` arguments can be tags, branches, or even specific commits, which allows users to depend on a specific version of your extension. It is up to you how you want to manage your tags and branches.
|
||||
|
||||
### Example releasing flow using a git repo
|
||||
|
||||
While there are many options for how you want to manage releases using a git flow, we recommend treating your default branch as your "stable" release branch. This means that the default behavior for `qwen extensions install <your-repo-uri>` is to be on the stable release branch.
|
||||
|
||||
Lets say you want to maintain three standard release channels, `stable`, `preview`, and `dev`. You would do all your standard development in the `dev` branch. When you are ready to do a preview release, you merge that branch into your `preview` branch. When you are ready to promote your preview branch to stable, you merge `preview` into your stable branch (which might be your default branch or a different branch).
|
||||
|
||||
You can also cherry pick changes from one branch into another using `git cherry-pick`, but do note that this will result in your branches having a slightly divergent history from each other, unless you force push changes to your branches on each release to restore the history to a clean slate (which may not be possible for the default branch depending on your repository settings). If you plan on doing cherry picks, you may want to avoid having your default branch be the stable branch to avoid force-pushing to the default branch which should generally be avoided.
|
||||
|
||||
## Releasing through Github releases
|
||||
|
||||
Qwen Code extensions can be distributed through [GitHub Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases). This provides a faster and more reliable initial installation experience for users, as it avoids the need to clone the repository.
|
||||
|
||||
Each release includes at least one archive file, which contains the full contents of the repo at the tag that it was linked to. Releases may also include [pre-built archives](#custom-pre-built-archives) if your extension requires some build step or has platform specific binaries attached to it.
|
||||
|
||||
When checking for updates, qwen code will just look for the latest release on github (you must mark it as such when creating the release), unless the user installed a specific release by passing `--ref=<some-release-tag>`. We do not at this time support opting in to pre-release releases or semver.
|
||||
|
||||
### Custom pre-built archives
|
||||
|
||||
Custom archives must be attached directly to the github release as assets and must be fully self-contained. This means they should include the entire extension, see [archive structure](#archive-structure).
|
||||
|
||||
If your extension is platform-independent, you can provide a single generic asset. In this case, there should be only one asset attached to the release.
|
||||
|
||||
Custom archives may also be used if you want to develop your extension within a larger repository, you can build an archive which has a different layout from the repo itself (for instance it might just be an archive of a subdirectory containing the extension).
|
||||
|
||||
#### Platform specific archives
|
||||
|
||||
To ensure Qwen Code can automatically find the correct release asset for each platform, you must follow this naming convention. The CLI will search for assets in the following order:
|
||||
|
||||
1. **Platform and Architecture-Specific:** `{platform}.{arch}.{name}.{extension}`
|
||||
2. **Platform-Specific:** `{platform}.{name}.{extension}`
|
||||
3. **Generic:** If only one asset is provided, it will be used as a generic fallback.
|
||||
|
||||
- `{name}`: The name of your extension.
|
||||
- `{platform}`: The operating system. Supported values are:
|
||||
- `darwin` (macOS)
|
||||
- `linux`
|
||||
- `win32` (Windows)
|
||||
- `{arch}`: The architecture. Supported values are:
|
||||
- `x64`
|
||||
- `arm64`
|
||||
- `{extension}`: The file extension of the archive (e.g., `.tar.gz` or `.zip`).
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `darwin.arm64.my-tool.tar.gz` (specific to Apple Silicon Macs)
|
||||
- `darwin.my-tool.tar.gz` (for all Macs)
|
||||
- `linux.x64.my-tool.tar.gz`
|
||||
- `win32.my-tool.zip`
|
||||
|
||||
#### Archive structure
|
||||
|
||||
Archives must be fully contained extensions and have all the standard requirements - specifically the `qwen-extension.json` file must be at the root of the archive.
|
||||
|
||||
The rest of the layout should look exactly the same as a typical extension, see [extensions.md](extension.md).
|
||||
|
||||
#### Example GitHub Actions workflow
|
||||
|
||||
Here is an example of a GitHub Actions workflow that builds and releases a Qwen Code extension for multiple platforms:
|
||||
|
||||
```yaml
|
||||
name: Release Extension
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build
|
||||
|
||||
- name: Create release assets
|
||||
run: |
|
||||
npm run package -- --platform=darwin --arch=arm64
|
||||
npm run package -- --platform=linux --arch=x64
|
||||
npm run package -- --platform=win32 --arch=x64
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
release/darwin.arm64.my-tool.tar.gz
|
||||
release/linux.arm64.my-tool.tar.gz
|
||||
release/win32.arm64.my-tool.zip
|
||||
```
|
||||
158
docs/extensions/extension.md
Normal file
158
docs/extensions/extension.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Qwen Code Extensions
|
||||
|
||||
Qwen Code extensions package prompts, MCP servers, and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Qwen Code and share those capabilities with others. They are designed to be easily installable and shareable.
|
||||
|
||||
## Extension management
|
||||
|
||||
We offer a suite of extension management tools using `qwen extensions` commands.
|
||||
|
||||
Note that these commands are not supported from within the CLI, although you can list installed extensions using the `/extensions list` subcommand.
|
||||
|
||||
Note that all of these commands will only be reflected in active CLI sessions on restart.
|
||||
|
||||
### Installing an extension
|
||||
|
||||
You can install an extension using `qwen extensions install` with either a GitHub URL or a local path`.
|
||||
|
||||
Note that we create a copy of the installed extension, so you will need to run `qwen extensions update` to pull in changes from both locally-defined extensions and those on GitHub.
|
||||
|
||||
```
|
||||
qwen extensions install https://github.com/qwen-cli-extensions/security
|
||||
```
|
||||
|
||||
This will install the Qwen Code Security extension, which offers support for a `/security:analyze` command.
|
||||
|
||||
### Uninstalling an extension
|
||||
|
||||
To uninstall, run `qwen extensions uninstall extension-name`, so, in the case of the install example:
|
||||
|
||||
```
|
||||
qwen extensions uninstall qwen-cli-security
|
||||
```
|
||||
|
||||
### Disabling an extension
|
||||
|
||||
Extensions are, by default, enabled across all workspaces. You can disable an extension entirely or for specific workspace.
|
||||
|
||||
For example, `qwen extensions disable extension-name` will disable the extension at the user level, so it will be disabled everywhere. `qwen extensions disable extension-name --scope=workspace` will only disable the extension in the current workspace.
|
||||
|
||||
### Enabling an extension
|
||||
|
||||
You can enable extensions using `qwen extensions enable extension-name`. You can also enable an extension for a specific workspace using `qwen extensions enable extension-name --scope=workspace` from within that workspace.
|
||||
|
||||
This is useful if you have an extension disabled at the top-level and only enabled in specific places.
|
||||
|
||||
### Updating an extension
|
||||
|
||||
For extensions installed from a local path or a git repository, you can explicitly update to the latest version (as reflected in the `qwen-extension.json` `version` field) with `qwen extensions update extension-name`.
|
||||
|
||||
You can update all extensions with:
|
||||
|
||||
```
|
||||
qwen extensions update --all
|
||||
```
|
||||
|
||||
## Extension creation
|
||||
|
||||
We offer commands to make extension development easier.
|
||||
|
||||
### Create a boilerplate extension
|
||||
|
||||
We offer several example extensions `context`, `custom-commands`, `exclude-tools` and `mcp-server`. You can view these examples [here](https://github.com/QwenLM/qwen-code/tree/main/packages/cli/src/commands/extensions/examples).
|
||||
|
||||
To copy one of these examples into a development directory using the type of your choosing, run:
|
||||
|
||||
```
|
||||
qwen extensions new path/to/directory custom-commands
|
||||
```
|
||||
|
||||
### Link a local extension
|
||||
|
||||
The `qwen extensions link` command will create a symbolic link from the extension installation directory to the development path.
|
||||
|
||||
This is useful so you don't have to run `qwen extensions update` every time you make changes you'd like to test.
|
||||
|
||||
```
|
||||
qwen extensions link path/to/directory
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
On startup, Qwen Code looks for extensions in `<home>/.qwen/extensions`
|
||||
|
||||
Extensions exist as a directory that contains a `qwen-extension.json` file. For example:
|
||||
|
||||
`<home>/.qwen/extensions/my-extension/qwen-extension.json`
|
||||
|
||||
### `qwen-extension.json`
|
||||
|
||||
The `qwen-extension.json` file contains the configuration for the extension. The file has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "QWEN.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase or numbers and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for `trust`.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. **Important:** Tools specified in `excludeTools` will be disabled for the entire conversation context and will affect all subsequent queries in the current session.
|
||||
|
||||
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||
|
||||
### Custom commands
|
||||
|
||||
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
```
|
||||
.qwen/extensions/gcp/
|
||||
├── qwen-extension.json
|
||||
└── commands/
|
||||
├── deploy.toml
|
||||
└── gcs/
|
||||
└── sync.toml
|
||||
```
|
||||
|
||||
Would provide these commands:
|
||||
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user or project commands:
|
||||
|
||||
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
|
||||
2. **With conflict**: Extension command is renamed with the extension prefix (e.g., `/gcp.deploy`)
|
||||
|
||||
For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag)
|
||||
|
||||
## Variables
|
||||
|
||||
Qwen Code extensions allow variable substitution in `qwen-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
| variable | description |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.qwen/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${workspacePath}` | The fully-qualified path of the current workspace. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
213
docs/extensions/getting-started-extensions.md
Normal file
213
docs/extensions/getting-started-extensions.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Getting Started with Qwen Code Extensions
|
||||
|
||||
This guide will walk you through creating your first Qwen Code extension. You'll learn how to set up a new extension, add a custom tool via an MCP server, create a custom command, and provide context to the model with a `QWEN.md` file.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start, make sure you have the Qwen Code installed and a basic understanding of Node.js and TypeScript.
|
||||
|
||||
## Step 1: Create a New Extension
|
||||
|
||||
The easiest way to start is by using one of the built-in templates. We'll use the `mcp-server` example as our foundation.
|
||||
|
||||
Run the following command to create a new directory called `my-first-extension` with the template files:
|
||||
|
||||
```bash
|
||||
qwen extensions new my-first-extension mcp-server
|
||||
```
|
||||
|
||||
This will create a new directory with the following structure:
|
||||
|
||||
```
|
||||
my-first-extension/
|
||||
├── example.ts
|
||||
├── qwen-extension.json
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Step 2: Understand the Extension Files
|
||||
|
||||
Let's look at the key files in your new extension.
|
||||
|
||||
### `qwen-extension.json`
|
||||
|
||||
This is the manifest file for your extension. It tells Qwen Code how to load and use your extension.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-first-extension",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The unique name for your extension.
|
||||
- `version`: The version of your extension.
|
||||
- `mcpServers`: This section defines one or more Model Context Protocol (MCP) servers. MCP servers are how you can add new tools for the model to use.
|
||||
- `command`, `args`, `cwd`: These fields specify how to start your server. Notice the use of the `${extensionPath}` variable, which Qwen Code replaces with the absolute path to your extension's installation directory. This allows your extension to work regardless of where it's installed.
|
||||
|
||||
### `example.ts`
|
||||
|
||||
This file contains the source code for your MCP server. It's a simple Node.js server that uses the `@modelcontextprotocol/sdk`.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'prompt-server',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Registers a new tool named 'fetch_posts'
|
||||
server.registerTool(
|
||||
'fetch_posts',
|
||||
{
|
||||
description: 'Fetches a list of posts from a public API.',
|
||||
inputSchema: z.object({}).shape,
|
||||
},
|
||||
async () => {
|
||||
const apiResponse = await fetch(
|
||||
'https://jsonplaceholder.typicode.com/posts',
|
||||
);
|
||||
const posts = await apiResponse.json();
|
||||
const response = { posts: posts.slice(0, 5) };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ... (prompt registration omitted for brevity)
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
```
|
||||
|
||||
This server defines a single tool called `fetch_posts` that fetches data from a public API.
|
||||
|
||||
### `package.json` and `tsconfig.json`
|
||||
|
||||
These are standard configuration files for a TypeScript project. The `package.json` file defines dependencies and a `build` script, and `tsconfig.json` configures the TypeScript compiler.
|
||||
|
||||
## Step 3: Build and Link Your Extension
|
||||
|
||||
Before you can use the extension, you need to compile the TypeScript code and link the extension to your Qwen Code installation for local development.
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
cd my-first-extension
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Build the server:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This will compile `example.ts` into `dist/example.js`, which is the file referenced in your `qwen-extension.json`.
|
||||
|
||||
3. **Link the extension:**
|
||||
|
||||
The `link` command creates a symbolic link from the Qwen Code extensions directory to your development directory. This means any changes you make will be reflected immediately without needing to reinstall.
|
||||
|
||||
```bash
|
||||
qwen extensions link .
|
||||
```
|
||||
|
||||
Now, restart your Qwen Code session. The new `fetch_posts` tool will be available. You can test it by asking: "fetch posts".
|
||||
|
||||
## Step 4: Add a Custom Command
|
||||
|
||||
Custom commands provide a way to create shortcuts for complex prompts. Let's add a command that searches for a pattern in your code.
|
||||
|
||||
1. Create a `commands` directory and a subdirectory for your command group:
|
||||
|
||||
```bash
|
||||
mkdir -p commands/fs
|
||||
```
|
||||
|
||||
2. Create a file named `commands/fs/grep-code.toml`:
|
||||
|
||||
```toml
|
||||
prompt = """
|
||||
Please summarize the findings for the pattern `{{args}}`.
|
||||
|
||||
Search Results:
|
||||
!{grep -r {{args}} .}
|
||||
"""
|
||||
```
|
||||
|
||||
This command, `/fs:grep-code`, will take an argument, run the `grep` shell command with it, and pipe the results into a prompt for summarization.
|
||||
|
||||
After saving the file, restart the Qwen Code. You can now run `/fs:grep-code "some pattern"` to use your new command.
|
||||
|
||||
## Step 5: Add a Custom `QWEN.md`
|
||||
|
||||
You can provide persistent context to the model by adding a `QWEN.md` file to your extension. This is useful for giving the model instructions on how to behave or information about your extension's tools. Note that you may not always need this for extensions built to expose commands and prompts.
|
||||
|
||||
1. Create a file named `QWEN.md` in the root of your extension directory:
|
||||
|
||||
```markdown
|
||||
# My First Extension Instructions
|
||||
|
||||
You are an expert developer assistant. When the user asks you to fetch posts, use the `fetch_posts` tool. Be concise in your responses.
|
||||
```
|
||||
|
||||
2. Update your `qwen-extension.json` to tell the CLI to load this file:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-first-extension",
|
||||
"version": "1.0.0",
|
||||
"contextFileName": "QWEN.md",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the CLI again. The model will now have the context from your `QWEN.md` file in every session where the extension is active.
|
||||
|
||||
## Step 6: Releasing Your Extension
|
||||
|
||||
Once you are happy with your extension, you can share it with others. The two primary ways of releasing extensions are via a Git repository or through GitHub Releases. Using a public Git repository is the simplest method.
|
||||
|
||||
For detailed instructions on both methods, please refer to the [Extension Releasing Guide](extension-releasing.md).
|
||||
|
||||
## Conclusion
|
||||
|
||||
You've successfully created a Qwen Code extension! You learned how to:
|
||||
|
||||
- Bootstrap a new extension from a template.
|
||||
- Add custom tools with an MCP server.
|
||||
- Create convenient custom commands.
|
||||
- Provide persistent context to the model.
|
||||
- Link your extension for local development.
|
||||
|
||||
From here, you can explore more advanced features and build powerful new capabilities into the Qwen Code.
|
||||
8
docs/features/_meta.ts
Normal file
8
docs/features/_meta.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
subagents: 'Subagents',
|
||||
checkpointing: 'Checkpointing',
|
||||
sandbox: 'Sandbox Support',
|
||||
headless: 'Headless Mode',
|
||||
'welcome-back': 'Welcome Back',
|
||||
'token-caching': 'Token Caching',
|
||||
};
|
||||
@@ -38,9 +38,11 @@ Add the following key to your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"checkpointing": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
307
docs/features/headless.md
Normal file
307
docs/features/headless.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# Headless Mode
|
||||
|
||||
Headless mode allows you to run Qwen Code programmatically from command line
|
||||
scripts and automation tools without any interactive UI. This is ideal for
|
||||
scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
||||
|
||||
- [Headless Mode](#headless-mode)
|
||||
- [Overview](#overview)
|
||||
- [Basic Usage](#basic-usage)
|
||||
- [Direct Prompts](#direct-prompts)
|
||||
- [Stdin Input](#stdin-input)
|
||||
- [Combining with File Input](#combining-with-file-input)
|
||||
- [Output Formats](#output-formats)
|
||||
- [Text Output (Default)](#text-output-default)
|
||||
- [JSON Output](#json-output)
|
||||
- [Example Usage](#example-usage)
|
||||
- [Stream-JSON Output](#stream-json-output)
|
||||
- [Input Format](#input-format)
|
||||
- [File Redirection](#file-redirection)
|
||||
- [Configuration Options](#configuration-options)
|
||||
- [Examples](#examples)
|
||||
- [Code review](#code-review)
|
||||
- [Generate commit messages](#generate-commit-messages)
|
||||
- [API documentation](#api-documentation)
|
||||
- [Batch code analysis](#batch-code-analysis)
|
||||
- [PR code review](#pr-code-review)
|
||||
- [Log analysis](#log-analysis)
|
||||
- [Release notes generation](#release-notes-generation)
|
||||
- [Model and tool usage tracking](#model-and-tool-usage-tracking)
|
||||
- [Resources](#resources)
|
||||
|
||||
## Overview
|
||||
|
||||
The headless mode provides a headless interface to Qwen Code that:
|
||||
|
||||
- Accepts prompts via command line arguments or stdin
|
||||
- Returns structured output (text or JSON)
|
||||
- Supports file redirection and piping
|
||||
- Enables automation and scripting workflows
|
||||
- Provides consistent exit codes for error handling
|
||||
- Can resume previous sessions scoped to the current project for multi-step automation
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Direct Prompts
|
||||
|
||||
Use the `--prompt` (or `-p`) flag to run in headless mode:
|
||||
|
||||
```bash
|
||||
qwen --prompt "What is machine learning?"
|
||||
```
|
||||
|
||||
### Stdin Input
|
||||
|
||||
Pipe input to Qwen Code from your terminal:
|
||||
|
||||
```bash
|
||||
echo "Explain this code" | qwen
|
||||
```
|
||||
|
||||
### Combining with File Input
|
||||
|
||||
Read from files and process with Qwen Code:
|
||||
|
||||
```bash
|
||||
cat README.md | qwen --prompt "Summarize this documentation"
|
||||
```
|
||||
|
||||
### Resume Previous Sessions (Headless)
|
||||
|
||||
Reuse conversation context from the current project in headless scripts:
|
||||
|
||||
```bash
|
||||
# Continue the most recent session for this project and run a new prompt
|
||||
qwen --continue -p "Run the tests again and summarize failures"
|
||||
|
||||
# Resume a specific session ID directly (no UI)
|
||||
qwen --resume 123e4567-e89b-12d3-a456-426614174000 -p "Apply the follow-up refactor"
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Session data is project-scoped JSONL under `~/.qwen/projects/<sanitized-cwd>/chats`.
|
||||
- Restores conversation history, tool outputs, and chat-compression checkpoints before sending the new prompt.
|
||||
|
||||
## Output Formats
|
||||
|
||||
Qwen Code supports multiple output formats for different use cases:
|
||||
|
||||
### Text Output (Default)
|
||||
|
||||
Standard human-readable output:
|
||||
|
||||
```bash
|
||||
qwen -p "What is the capital of France?"
|
||||
```
|
||||
|
||||
Response format:
|
||||
|
||||
```
|
||||
The capital of France is Paris.
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
Returns structured data as a JSON array. All messages are buffered and output together when the session completes. This format is ideal for programmatic processing and automation scripts.
|
||||
|
||||
The JSON output is an array of message objects. The output includes multiple message types: system messages (session initialization), assistant messages (AI responses), and result messages (execution summary).
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```bash
|
||||
qwen -p "What is the capital of France?" --output-format json
|
||||
```
|
||||
|
||||
Output (at end of execution):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "system",
|
||||
"subtype": "session_start",
|
||||
"uuid": "...",
|
||||
"session_id": "...",
|
||||
"model": "qwen3-coder-plus",
|
||||
...
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"uuid": "...",
|
||||
"session_id": "...",
|
||||
"message": {
|
||||
"id": "...",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "qwen3-coder-plus",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The capital of France is Paris."
|
||||
}
|
||||
],
|
||||
"usage": {...}
|
||||
},
|
||||
"parent_tool_use_id": null
|
||||
},
|
||||
{
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"uuid": "...",
|
||||
"session_id": "...",
|
||||
"is_error": false,
|
||||
"duration_ms": 1234,
|
||||
"result": "The capital of France is Paris.",
|
||||
"usage": {...}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Stream-JSON Output
|
||||
|
||||
Stream-JSON format emits JSON messages immediately as they occur during execution, enabling real-time monitoring. This format uses line-delimited JSON where each message is a complete JSON object on a single line.
|
||||
|
||||
```bash
|
||||
qwen -p "Explain TypeScript" --output-format stream-json
|
||||
```
|
||||
|
||||
Output (streaming as events occur):
|
||||
|
||||
```json
|
||||
{"type":"system","subtype":"session_start","uuid":"...","session_id":"..."}
|
||||
{"type":"assistant","uuid":"...","session_id":"...","message":{...}}
|
||||
{"type":"result","subtype":"success","uuid":"...","session_id":"..."}
|
||||
```
|
||||
|
||||
When combined with `--include-partial-messages`, additional stream events are emitted in real-time (message_start, content_block_delta, etc.) for real-time UI updates.
|
||||
|
||||
```bash
|
||||
qwen -p "Write a Python script" --output-format stream-json --include-partial-messages
|
||||
```
|
||||
|
||||
### Input Format
|
||||
|
||||
The `--input-format` parameter controls how Qwen Code consumes input from standard input:
|
||||
|
||||
- **`text`** (default): Standard text input from stdin or command-line arguments
|
||||
- **`stream-json`**: JSON message protocol via stdin for bidirectional communication
|
||||
|
||||
> **Note:** Stream-json input mode is currently under construction and is intended for SDK integration. It requires `--output-format stream-json` to be set.
|
||||
|
||||
### File Redirection
|
||||
|
||||
Save output to files or pipe to other commands:
|
||||
|
||||
```bash
|
||||
# Save to file
|
||||
qwen -p "Explain Docker" > docker-explanation.txt
|
||||
qwen -p "Explain Docker" --output-format json > docker-explanation.json
|
||||
|
||||
# Append to file
|
||||
qwen -p "Add more details" >> docker-explanation.txt
|
||||
|
||||
# Pipe to other tools
|
||||
qwen -p "What is Kubernetes?" --output-format json | jq '.response'
|
||||
qwen -p "Explain microservices" | wc -w
|
||||
qwen -p "List programming languages" | grep -i "python"
|
||||
|
||||
# Stream-JSON output for real-time processing
|
||||
qwen -p "Explain Docker" --output-format stream-json | jq '.type'
|
||||
qwen -p "Write code" --output-format stream-json --include-partial-messages | jq '.event.type'
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
Key command-line options for headless usage:
|
||||
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `--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` |
|
||||
| `--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` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--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` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--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"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
|
||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md).
|
||||
|
||||
## Examples
|
||||
|
||||
### Code review
|
||||
|
||||
```bash
|
||||
cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt
|
||||
```
|
||||
|
||||
### Generate commit messages
|
||||
|
||||
```bash
|
||||
result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json)
|
||||
echo "$result" | jq -r '.response'
|
||||
```
|
||||
|
||||
### API documentation
|
||||
|
||||
```bash
|
||||
result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json)
|
||||
echo "$result" | jq -r '.response' > openapi.json
|
||||
```
|
||||
|
||||
### Batch code analysis
|
||||
|
||||
```bash
|
||||
for file in src/*.py; do
|
||||
echo "Analyzing $file..."
|
||||
result=$(cat "$file" | qwen -p "Find potential bugs and suggest improvements" --output-format json)
|
||||
echo "$result" | jq -r '.response' > "reports/$(basename "$file").analysis"
|
||||
echo "Completed analysis for $(basename "$file")" >> reports/progress.log
|
||||
done
|
||||
```
|
||||
|
||||
### PR code review
|
||||
|
||||
```bash
|
||||
result=$(git diff origin/main...HEAD | qwen -p "Review these changes for bugs, security issues, and code quality" --output-format json)
|
||||
echo "$result" | jq -r '.response' > pr-review.json
|
||||
```
|
||||
|
||||
### Log analysis
|
||||
|
||||
```bash
|
||||
grep "ERROR" /var/log/app.log | tail -20 | qwen -p "Analyze these errors and suggest root cause and fixes" > error-analysis.txt
|
||||
```
|
||||
|
||||
### Release notes generation
|
||||
|
||||
```bash
|
||||
result=$(git log --oneline v1.0.0..HEAD | qwen -p "Generate release notes from these commits" --output-format json)
|
||||
response=$(echo "$result" | jq -r '.response')
|
||||
echo "$response"
|
||||
echo "$response" >> CHANGELOG.md
|
||||
```
|
||||
|
||||
### Model and tool usage tracking
|
||||
|
||||
```bash
|
||||
result=$(qwen -p "Explain this database schema" --include-directories db --output-format json)
|
||||
total_tokens=$(echo "$result" | jq -r '.stats.models // {} | to_entries | map(.value.tokens.total) | add // 0')
|
||||
models_used=$(echo "$result" | jq -r '.stats.models // {} | keys | join(", ") | if . == "" then "none" else . end')
|
||||
tool_calls=$(echo "$result" | jq -r '.stats.tools.totalCalls // 0')
|
||||
tools_used=$(echo "$result" | jq -r '.stats.tools.byName // {} | keys | join(", ") | if . == "" then "none" else . end')
|
||||
echo "$(date): $total_tokens tokens, $tool_calls tool calls ($tools_used) used with models: $models_used" >> usage.log
|
||||
echo "$result" | jq -r '.response' > schema-docs.md
|
||||
echo "Recent usage trends:"
|
||||
tail -5 usage.log
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [CLI Configuration](./cli/configuration.md) - Complete configuration guide
|
||||
- [Authentication](./cli/authentication.md) - Setup authentication
|
||||
- [Commands](./cli/commands.md) - Interactive commands reference
|
||||
- [Tutorials](./cli/tutorials.md) - Step-by-step automation guides
|
||||
@@ -55,7 +55,9 @@ qwen -p "run the test suite"
|
||||
|
||||
# Configure in settings.json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": "docker"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -65,7 +67,7 @@ qwen -p "run the test suite"
|
||||
|
||||
1. **Command flag**: `-s` or `--sandbox`
|
||||
2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec`
|
||||
3. **Settings file**: `"sandbox": true` in `settings.json`
|
||||
3. **Settings file**: `"sandbox": true` in the `tools` object of your `settings.json` file (e.g., `{"tools": {"sandbox": true}}`).
|
||||
|
||||
### macOS Seatbelt profiles
|
||||
|
||||
@@ -106,7 +106,10 @@ Subagents are configured using Markdown files with YAML frontmatter. This format
|
||||
---
|
||||
name: agent-name
|
||||
description: Brief description of when and how to use this agent
|
||||
tools: tool1, tool2, tool3 # Optional
|
||||
tools:
|
||||
- tool1
|
||||
- tool2
|
||||
- tool3 # Optional
|
||||
---
|
||||
|
||||
System prompt content goes here.
|
||||
@@ -167,7 +170,11 @@ Perfect for comprehensive test creation and test-driven development.
|
||||
---
|
||||
name: testing-expert
|
||||
description: Writes comprehensive unit tests, integration tests, and handles test automation with best practices
|
||||
tools: read_file, write_file, read_many_files, run_shell_command
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
- read_many_files
|
||||
- run_shell_command
|
||||
---
|
||||
|
||||
You are a testing specialist focused on creating high-quality, maintainable tests.
|
||||
@@ -207,7 +214,11 @@ Specialized in creating clear, comprehensive documentation.
|
||||
---
|
||||
name: documentation-writer
|
||||
description: Creates comprehensive documentation, README files, API docs, and user guides
|
||||
tools: read_file, write_file, read_many_files, web_search
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
- read_many_files
|
||||
- web_search
|
||||
---
|
||||
|
||||
You are a technical documentation specialist for ${project_name}.
|
||||
@@ -256,7 +267,9 @@ Focused on code quality, security, and best practices.
|
||||
---
|
||||
name: code-reviewer
|
||||
description: Reviews code for best practices, security issues, performance, and maintainability
|
||||
tools: read_file, read_many_files
|
||||
tools:
|
||||
- read_file
|
||||
- read_many_files
|
||||
---
|
||||
|
||||
You are an experienced code reviewer focused on quality, security, and maintainability.
|
||||
@@ -298,7 +311,11 @@ Optimized for React development, hooks, and component patterns.
|
||||
---
|
||||
name: react-specialist
|
||||
description: Expert in React development, hooks, component patterns, and modern React best practices
|
||||
tools: read_file, write_file, read_many_files, run_shell_command
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
- read_many_files
|
||||
- run_shell_command
|
||||
---
|
||||
|
||||
You are a React specialist with deep expertise in modern React development.
|
||||
@@ -339,7 +356,11 @@ Specialized in Python development, frameworks, and best practices.
|
||||
---
|
||||
name: python-expert
|
||||
description: Expert in Python development, frameworks, testing, and Python-specific best practices
|
||||
tools: read_file, write_file, read_many_files, run_shell_command
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
- read_many_files
|
||||
- run_shell_command
|
||||
---
|
||||
|
||||
You are a Python expert with deep knowledge of the Python ecosystem.
|
||||
@@ -75,20 +75,12 @@ Add to your `.qwen/settings.json`:
|
||||
|
||||
### Project Summary Generation
|
||||
|
||||
The Welcome Back feature works seamlessly with the `/chat summary` command:
|
||||
The Welcome Back feature works seamlessly with the `/summary` command:
|
||||
|
||||
1. **Generate Summary:** Use `/chat summary` to create a project summary
|
||||
1. **Generate Summary:** Use `/summary` to create a project summary
|
||||
2. **Automatic Detection:** Next time you start Qwen Code in this project, Welcome Back will detect the summary
|
||||
3. **Resume Work:** Choose to continue and the summary will be loaded as context
|
||||
|
||||
### Quit Confirmation
|
||||
|
||||
When exiting with `/quit-confirm` and choosing "Generate summary and quit":
|
||||
|
||||
1. A project summary is automatically created
|
||||
2. Next session will trigger the Welcome Back dialog
|
||||
3. You can seamlessly continue your work
|
||||
|
||||
## File Structure
|
||||
|
||||
The Welcome Back feature creates and uses:
|
||||
4
docs/ide-integration/_meta.ts
Normal file
4
docs/ide-integration/_meta.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
'ide-integration': 'Introduction',
|
||||
'ide-companion-spec': 'IDE Companion Spec',
|
||||
};
|
||||
184
docs/ide-integration/ide-companion-spec.md
Normal file
184
docs/ide-integration/ide-companion-spec.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Qwen Code Companion Plugin: Interface Specification
|
||||
|
||||
> Last Updated: September 15, 2025
|
||||
|
||||
This document defines the contract for building a companion plugin to enable Qwen Code's IDE mode. For VS Code, these features (native diffing, context awareness) are provided by the official extension ([marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion)). This specification is for contributors who wish to bring similar functionality to other editors like JetBrains IDEs, Sublime Text, etc.
|
||||
|
||||
## I. The Communication Interface
|
||||
|
||||
Qwen Code and the IDE plugin communicate through a local communication channel.
|
||||
|
||||
### 1. Transport Layer: MCP over HTTP
|
||||
|
||||
The plugin **MUST** run a local HTTP server that implements the **Model Context Protocol (MCP)**.
|
||||
|
||||
- **Protocol:** The server must be a valid MCP server. We recommend using an existing MCP SDK for your language of choice if available.
|
||||
- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication.
|
||||
- **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`).
|
||||
|
||||
### 2. Discovery Mechanism: The Port File
|
||||
|
||||
For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file."
|
||||
|
||||
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name.
|
||||
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist.
|
||||
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
|
||||
`qwen-code-ide-server-${PID}-${PORT}.json`
|
||||
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename.
|
||||
- `${PORT}`: The port your MCP server is listening on.
|
||||
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 12345,
|
||||
"workspacePath": "/path/to/project1:/path/to/project2",
|
||||
"authToken": "a-very-secret-token",
|
||||
"ideInfo": {
|
||||
"name": "vscode",
|
||||
"displayName": "VS Code"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s).
|
||||
- `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
|
||||
- `ideInfo` (object, required): Information about the IDE.
|
||||
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`).
|
||||
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
|
||||
|
||||
- **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
|
||||
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
|
||||
|
||||
## II. The Context Interface
|
||||
|
||||
To enable context awareness, the plugin **MAY** provide the CLI with real-time information about the user's activity in the IDE.
|
||||
|
||||
### `ide/contextUpdate` Notification
|
||||
|
||||
The plugin **MAY** send an `ide/contextUpdate` [notification](https://modelcontextprotocol.io/specification/2025-06-18/basic/index#notifications) to the CLI whenever the user's context changes.
|
||||
|
||||
- **Triggering Events:** This notification should be sent (with a recommended debounce of 50ms) when:
|
||||
- A file is opened, closed, or focused.
|
||||
- The user's cursor position or text selection changes in the active file.
|
||||
- **Payload (`IdeContext`):** The notification parameters **MUST** be an `IdeContext` object:
|
||||
|
||||
```typescript
|
||||
interface IdeContext {
|
||||
workspaceState?: {
|
||||
openFiles?: File[];
|
||||
isTrusted?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface File {
|
||||
// Absolute path to the file
|
||||
path: string;
|
||||
// Last focused Unix timestamp (for ordering)
|
||||
timestamp: number;
|
||||
// True if this is the currently focused file
|
||||
isActive?: boolean;
|
||||
cursor?: {
|
||||
// 1-based line number
|
||||
line: number;
|
||||
// 1-based character number
|
||||
character: number;
|
||||
};
|
||||
// The text currently selected by the user
|
||||
selectedText?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The `openFiles` list should only include files that exist on disk. Virtual files (e.g., unsaved files without a path, editor settings pages) **MUST** be excluded.
|
||||
|
||||
### How the CLI Uses This Context
|
||||
|
||||
After receiving the `IdeContext` object, the CLI performs several normalization and truncation steps before sending the information to the model.
|
||||
|
||||
- **File Ordering:** The CLI uses the `timestamp` field to determine the most recently used files. It sorts the `openFiles` list based on this value. Therefore, your plugin **MUST** provide an accurate Unix timestamp for when a file was last focused.
|
||||
- **Active File:** The CLI considers only the most recent file (after sorting) to be the "active" file. It will ignore the `isActive` flag on all other files and clear their `cursor` and `selectedText` fields. Your plugin should focus on setting `isActive: true` and providing cursor/selection details only for the currently focused file.
|
||||
- **Truncation:** To manage token limits, the CLI truncates both the file list (to 10 files) and the `selectedText` (to 16KB).
|
||||
|
||||
While the CLI handles the final truncation, it is highly recommended that your plugin also limits the amount of context it sends.
|
||||
|
||||
## III. The Diffing Interface
|
||||
|
||||
To enable interactive code modifications, the plugin **MAY** expose a diffing interface. This allows the CLI to request that the IDE open a diff view, showing proposed changes to a file. The user can then review, edit, and ultimately accept or reject these changes directly within the IDE.
|
||||
|
||||
### `openDiff` Tool
|
||||
|
||||
The plugin **MUST** register an `openDiff` tool on its MCP server.
|
||||
|
||||
- **Description:** This tool instructs the IDE to open a modifiable diff view for a specific file.
|
||||
- **Request (`OpenDiffRequest`):** The tool is invoked via a `tools/call` request. The `arguments` field within the request's `params` **MUST** be an `OpenDiffRequest` object.
|
||||
|
||||
```typescript
|
||||
interface OpenDiffRequest {
|
||||
// The absolute path to the file to be diffed.
|
||||
filePath: string;
|
||||
// The proposed new content for the file.
|
||||
newContent: string;
|
||||
}
|
||||
```
|
||||
|
||||
- **Response (`CallToolResult`):** The tool **MUST** immediately return a `CallToolResult` to acknowledge the request and report whether the diff view was successfully opened.
|
||||
- On Success: If the diff view was opened successfully, the response **MUST** contain empty content (i.e., `content: []`).
|
||||
- On Failure: If an error prevented the diff view from opening, the response **MUST** have `isError: true` and include a `TextContent` block in the `content` array describing the error.
|
||||
|
||||
The actual outcome of the diff (acceptance or rejection) is communicated asynchronously via notifications.
|
||||
|
||||
### `closeDiff` Tool
|
||||
|
||||
The plugin **MUST** register a `closeDiff` tool on its MCP server.
|
||||
|
||||
- **Description:** This tool instructs the IDE to close an open diff view for a specific file.
|
||||
- **Request (`CloseDiffRequest`):** The tool is invoked via a `tools/call` request. The `arguments` field within the request's `params` **MUST** be an `CloseDiffRequest` object.
|
||||
|
||||
```typescript
|
||||
interface CloseDiffRequest {
|
||||
// The absolute path to the file whose diff view should be closed.
|
||||
filePath: string;
|
||||
}
|
||||
```
|
||||
|
||||
- **Response (`CallToolResult`):** The tool **MUST** return a `CallToolResult`.
|
||||
- On Success: If the diff view was closed successfully, the response **MUST** include a single **TextContent** block in the content array containing the file's final content before closing.
|
||||
- On Failure: If an error prevented the diff view from closing, the response **MUST** have `isError: true` and include a `TextContent` block in the `content` array describing the error.
|
||||
|
||||
### `ide/diffAccepted` Notification
|
||||
|
||||
When the user accepts the changes in a diff view (e.g., by clicking an "Apply" or "Save" button), the plugin **MUST** send an `ide/diffAccepted` notification to the CLI.
|
||||
|
||||
- **Payload:** The notification parameters **MUST** include the file path and the final content of the file. The content may differ from the original `newContent` if the user made manual edits in the diff view.
|
||||
|
||||
```typescript
|
||||
{
|
||||
// The absolute path to the file that was diffed.
|
||||
filePath: string;
|
||||
// The full content of the file after acceptance.
|
||||
content: string;
|
||||
}
|
||||
```
|
||||
|
||||
### `ide/diffRejected` Notification
|
||||
|
||||
When the user rejects the changes (e.g., by closing the diff view without accepting), the plugin **MUST** send an `ide/diffRejected` notification to the CLI.
|
||||
|
||||
- **Payload:** The notification parameters **MUST** include the file path of the rejected diff.
|
||||
|
||||
```typescript
|
||||
{
|
||||
// The absolute path to the file that was diffed.
|
||||
filePath: string;
|
||||
}
|
||||
```
|
||||
|
||||
## IV. The Lifecycle Interface
|
||||
|
||||
The plugin **MUST** manage its resources and the discovery file correctly based on the IDE's lifecycle.
|
||||
|
||||
- **On Activation (IDE startup/plugin enabled):**
|
||||
1. Start the MCP server.
|
||||
2. Create the discovery file.
|
||||
- **On Deactivation (IDE shutdown/plugin disabled):**
|
||||
1. Stop the MCP server.
|
||||
2. Delete the discovery file.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Qwen Code can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing.
|
||||
|
||||
Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions.
|
||||
Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](./ide-companion-spec.md).
|
||||
|
||||
## Features
|
||||
|
||||
368
docs/index.md
368
docs/index.md
@@ -1,40 +1,344 @@
|
||||
# Welcome to Qwen Code documentation
|
||||
|
||||
This documentation provides a comprehensive guide to installing, using, and developing Qwen Code. This tool lets you interact with AI models through a command-line interface.
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||
|
||||
## Overview
|
||||
## 🚀 Why Choose Qwen Code?
|
||||
|
||||
Qwen Code brings the capabilities of advanced code models to your terminal in an interactive Read-Eval-Print Loop (REPL) environment. Qwen Code consists of a client-side application (`packages/cli`) that communicates with a local server (`packages/core`). Qwen Code also contains a variety of tools for tasks such as performing file system operations, running shells, and web fetching, which are managed by `packages/core`.
|
||||
- 🎯 **Free Tier:** Up to 60 requests/min and 2,000 requests/day with your [QwenChat](https://chat.qwen.ai/) account.
|
||||
- 🧠 **Advanced Model:** Specially optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) for superior code understanding and assistance.
|
||||
- 🏆 **Comprehensive Features:** Includes subagents, Plan Mode, TodoWrite, vision model support, and full OpenAI API compatibility—all seamlessly integrated.
|
||||
- 🔧 **Built-in & Extensible Tools:** Includes file system operations, shell command execution, web fetch/search, and more—all easily extended via the Model Context Protocol (MCP) for custom integrations.
|
||||
- 💻 **Developer-Centric:** Built for terminal-first workflows—perfect for command-line enthusiasts.
|
||||
- 🛡️ **Open Source:** Apache 2.0 licensed for maximum freedom and transparency.
|
||||
|
||||
## Navigating the documentation
|
||||
## Installation
|
||||
|
||||
This documentation is organized into the following sections:
|
||||
### Prerequisites
|
||||
|
||||
- **[Execution and Deployment](./deployment.md):** Information for running Qwen Code.
|
||||
- **[Architecture Overview](./architecture.md):** Understand the high-level design of Qwen Code, including its components and how they interact.
|
||||
- **CLI Usage:** Documentation for `packages/cli`.
|
||||
- **[CLI Introduction](./cli/index.md):** Overview of the command-line interface.
|
||||
- **[Commands](./cli/commands.md):** Description of available CLI commands.
|
||||
- **[Configuration](./cli/configuration.md):** Information on configuring the CLI.
|
||||
- **[Checkpointing](./checkpointing.md):** Documentation for the checkpointing feature.
|
||||
- **[Extensions](./extension.md):** How to extend the CLI with new functionality.
|
||||
- **[IDE Integration](./ide-integration.md):** Connect the CLI to your editor.
|
||||
- **[Telemetry](./telemetry.md):** Overview of telemetry in the CLI.
|
||||
- **Core Details:** Documentation for `packages/core`.
|
||||
- **[Core Introduction](./core/index.md):** Overview of the core component.
|
||||
- **[Tools API](./core/tools-api.md):** Information on how the core manages and exposes tools.
|
||||
- **Tools:**
|
||||
- **[Tools Overview](./tools/index.md):** Overview of the available tools.
|
||||
- **[File System Tools](./tools/file-system.md):** Documentation for the `read_file` and `write_file` tools.
|
||||
- **[Multi-File Read Tool](./tools/multi-file.md):** Documentation for the `read_many_files` tool.
|
||||
- **[Shell Tool](./tools/shell.md):** Documentation for the `run_shell_command` tool.
|
||||
- **[Web Fetch Tool](./tools/web-fetch.md):** Documentation for the `web_fetch` tool.
|
||||
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `web_search` tool.
|
||||
- **[Memory Tool](./tools/memory.md):** Documentation for the `save_memory` tool.
|
||||
- **[Subagents](./subagents.md):** Specialized AI assistants for focused tasks with comprehensive management, configuration, and usage guidance.
|
||||
- **[Contributing & Development Guide](../CONTRIBUTING.md):** Information for contributors and developers, including setup, building, testing, and coding conventions.
|
||||
- **[NPM](./npm.md):** Details on how the project's packages are structured
|
||||
- **[Troubleshooting Guide](./troubleshooting.md):** Find solutions to common problems and FAQs.
|
||||
- **[Terms of Service and Privacy Notice](./tos-privacy.md):** Information on the terms of service and privacy notices applicable to your use of Qwen Code.
|
||||
Ensure you have [Node.js version 20](https://nodejs.org/en/download) or higher installed.
|
||||
|
||||
We hope this documentation helps you make the most of Qwen Code!
|
||||
```bash
|
||||
curl -qL https://www.npmjs.com/install.sh | sh
|
||||
```
|
||||
|
||||
### Install from npm
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code@latest
|
||||
qwen --version
|
||||
```
|
||||
|
||||
### Install from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QwenLM/qwen-code.git
|
||||
cd qwen-code
|
||||
npm install
|
||||
npm install -g .
|
||||
```
|
||||
|
||||
### Install globally with Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew install qwen-code
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start Qwen Code
|
||||
qwen
|
||||
|
||||
# Example commands
|
||||
> Explain this codebase structure
|
||||
> Help me refactor this function
|
||||
> Generate unit tests for this module
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
Control your token usage with configurable session limits to optimize costs and performance.
|
||||
|
||||
#### Configure Session Token Limit
|
||||
|
||||
Create or edit `.qwen/settings.json` in your home directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionTokenLimit": 32000
|
||||
}
|
||||
```
|
||||
|
||||
#### Session Commands
|
||||
|
||||
- **`/compress`** - Compress conversation history to continue within token limits
|
||||
- **`/clear`** (aliases: `/reset`, `/new`) - Clear conversation history, start a fresh session, and free up context
|
||||
- **`/stats`** - Check current token usage and limits
|
||||
|
||||
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
|
||||
|
||||
### Vision Model Configuration
|
||||
|
||||
Qwen Code includes intelligent vision model auto-switching that detects images in your input and can automatically switch to vision-capable models for multimodal analysis. **This feature is enabled by default** - when you include images in your queries, you'll see a dialog asking how you'd like to handle the vision model switch.
|
||||
|
||||
#### Skip the Switch Dialog (Optional)
|
||||
|
||||
If you don't want to see the interactive dialog each time, configure the default behavior in your `.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"vlmSwitchMode": "once"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available modes:**
|
||||
|
||||
- **`"once"`** - Switch to vision model for this query only, then revert
|
||||
- **`"session"`** - Switch to vision model for the entire session
|
||||
- **`"persist"`** - Continue with current model (no switching)
|
||||
- **Not set** - Show interactive dialog each time (default)
|
||||
|
||||
#### Command Line Override
|
||||
|
||||
You can also set the behavior via command line:
|
||||
|
||||
```bash
|
||||
# Switch once per query
|
||||
qwen --vlm-switch-mode once
|
||||
|
||||
# Switch for entire session
|
||||
qwen --vlm-switch-mode session
|
||||
|
||||
# Never switch automatically
|
||||
qwen --vlm-switch-mode persist
|
||||
```
|
||||
|
||||
#### Disable Vision Models (Optional)
|
||||
|
||||
To completely disable vision model support, add to your `.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"visionModelPreview": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected.
|
||||
|
||||
### Authorization
|
||||
|
||||
Choose your preferred authentication method based on your needs:
|
||||
|
||||
#### 1. Qwen OAuth (🚀 Recommended - Start in 30 seconds)
|
||||
|
||||
The easiest way to get started - completely free with generous quotas:
|
||||
|
||||
```bash
|
||||
# Just run this command and follow the browser authentication
|
||||
qwen
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. **Instant Setup**: CLI opens your browser automatically
|
||||
2. **One-Click Login**: Authenticate with your qwen.ai account
|
||||
3. **Automatic Management**: Credentials cached locally for future use
|
||||
4. **No Configuration**: Zero setup required - just start coding!
|
||||
|
||||
**Free Tier Benefits:**
|
||||
|
||||
- ✅ **2,000 requests/day** (no token counting needed)
|
||||
- ✅ **60 requests/minute** rate limit
|
||||
- ✅ **Automatic credential refresh**
|
||||
- ✅ **Zero cost** for individual users
|
||||
- ℹ️ **Note**: Model fallback may occur to maintain service quality
|
||||
|
||||
#### 2. OpenAI-Compatible API
|
||||
|
||||
Use API keys for OpenAI or other compatible providers:
|
||||
|
||||
**Configuration Methods:**
|
||||
|
||||
1. **Environment Variables**
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="your_api_endpoint"
|
||||
export OPENAI_MODEL="your_model_choice"
|
||||
```
|
||||
|
||||
2. **Project `.env` File**
|
||||
Create a `.env` file in your project root:
|
||||
```env
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
OPENAI_BASE_URL=your_api_endpoint
|
||||
OPENAI_MODEL=your_model_choice
|
||||
```
|
||||
|
||||
**API Provider Options**
|
||||
|
||||
> ⚠️ **Regional Notice:**
|
||||
>
|
||||
> - **Mainland China**: Use Alibaba Cloud Bailian or ModelScope
|
||||
> - **International**: Use Alibaba Cloud ModelStudio or OpenRouter
|
||||
|
||||
<details>
|
||||
<summary><b>🇨🇳 For Users in Mainland China</b></summary>
|
||||
|
||||
**Option 1: Alibaba Cloud Bailian** ([Apply for API Key](https://bailian.console.aliyun.com/))
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
export OPENAI_MODEL="qwen3-coder-plus"
|
||||
```
|
||||
|
||||
**Option 2: ModelScope (Free Tier)** ([Apply for API Key](https://modelscope.cn/docs/model-service/API-Inference/intro))
|
||||
|
||||
- ✅ **2,000 free API calls per day**
|
||||
- ⚠️ Connect your Aliyun account to avoid authentication errors
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
|
||||
export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🌍 For International Users</b></summary>
|
||||
|
||||
**Option 1: Alibaba Cloud ModelStudio** ([Apply for API Key](https://modelstudio.console.alibabacloud.com/))
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||
export OPENAI_MODEL="qwen3-coder-plus"
|
||||
```
|
||||
|
||||
**Option 2: OpenRouter (Free Tier Available)** ([Apply for API Key](https://openrouter.ai/))
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
|
||||
export OPENAI_MODEL="qwen/qwen3-coder:free"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 🔍 Explore Codebases
|
||||
|
||||
```bash
|
||||
cd your-project/
|
||||
qwen
|
||||
|
||||
# Architecture analysis
|
||||
> Describe the main pieces of this system's architecture
|
||||
> What are the key dependencies and how do they interact?
|
||||
> Find all API endpoints and their authentication methods
|
||||
```
|
||||
|
||||
### 💻 Code Development
|
||||
|
||||
```bash
|
||||
# Refactoring
|
||||
> Refactor this function to improve readability and performance
|
||||
> Convert this class to use dependency injection
|
||||
> Split this large module into smaller, focused components
|
||||
|
||||
# Code generation
|
||||
> Create a REST API endpoint for user management
|
||||
> Generate unit tests for the authentication module
|
||||
> Add error handling to all database operations
|
||||
```
|
||||
|
||||
### 🔄 Automate Workflows
|
||||
|
||||
```bash
|
||||
# Git automation
|
||||
> Analyze git commits from the last 7 days, grouped by feature
|
||||
> Create a changelog from recent commits
|
||||
> Find all TODO comments and create GitHub issues
|
||||
|
||||
# File operations
|
||||
> Convert all images in this directory to PNG format
|
||||
> Rename all test files to follow the *.test.ts pattern
|
||||
> Find and remove all console.log statements
|
||||
```
|
||||
|
||||
### 🐛 Debugging & Analysis
|
||||
|
||||
```bash
|
||||
# Performance analysis
|
||||
> Identify performance bottlenecks in this React component
|
||||
> Find all N+1 query problems in the codebase
|
||||
|
||||
# Security audit
|
||||
> Check for potential SQL injection vulnerabilities
|
||||
> Find all hardcoded credentials or API keys
|
||||
```
|
||||
|
||||
## Popular Tasks
|
||||
|
||||
### 📚 Understand New Codebases
|
||||
|
||||
```text
|
||||
> What are the core business logic components?
|
||||
> What security mechanisms are in place?
|
||||
> How does the data flow through the system?
|
||||
> What are the main design patterns used?
|
||||
> Generate a dependency graph for this module
|
||||
```
|
||||
|
||||
### 🔨 Code Refactoring & Optimization
|
||||
|
||||
```text
|
||||
> What parts of this module can be optimized?
|
||||
> Help me refactor this class to follow SOLID principles
|
||||
> Add proper error handling and logging
|
||||
> Convert callbacks to async/await pattern
|
||||
> Implement caching for expensive operations
|
||||
```
|
||||
|
||||
### 📝 Documentation & Testing
|
||||
|
||||
```text
|
||||
> Generate comprehensive JSDoc comments for all public APIs
|
||||
> Write unit tests with edge cases for this component
|
||||
> Create API documentation in OpenAPI format
|
||||
> Add inline comments explaining complex algorithms
|
||||
> Generate a README for this module
|
||||
```
|
||||
|
||||
### 🚀 Development Acceleration
|
||||
|
||||
```text
|
||||
> Set up a new Express server with authentication
|
||||
> Create a React component with TypeScript and tests
|
||||
> Implement a rate limiter middleware
|
||||
> Add database migrations for new schema
|
||||
> Configure CI/CD pipeline for this project
|
||||
```
|
||||
|
||||
## Commands & Shortcuts
|
||||
|
||||
### Session Commands
|
||||
|
||||
- `/help` - Display available commands
|
||||
- `/clear` (aliases: `/reset`, `/new`) - Clear conversation history and start a fresh session
|
||||
- `/compress` - Compress history to save tokens
|
||||
- `/stats` - Show current session information
|
||||
- `/exit` or `/quit` - Exit Qwen Code
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
- `Ctrl+C` - Cancel current operation
|
||||
- `Ctrl+D` - Exit (on empty line)
|
||||
- `Up/Down` - Navigate command history
|
||||
|
||||
103
docs/mermaid/context.mmd
Normal file
103
docs/mermaid/context.mmd
Normal file
@@ -0,0 +1,103 @@
|
||||
graph LR
|
||||
%% --- Style Definitions ---
|
||||
classDef new fill:#98fb98,color:#000
|
||||
classDef changed fill:#add8e6,color:#000
|
||||
classDef unchanged fill:#f0f0f0,color:#000
|
||||
|
||||
%% --- Subgraphs ---
|
||||
subgraph "Context Providers"
|
||||
direction TB
|
||||
A["gemini.tsx"]
|
||||
B["AppContainer.tsx"]
|
||||
end
|
||||
|
||||
subgraph "Contexts"
|
||||
direction TB
|
||||
CtxSession["SessionContext"]
|
||||
CtxVim["VimModeContext"]
|
||||
CtxSettings["SettingsContext"]
|
||||
CtxApp["AppContext"]
|
||||
CtxConfig["ConfigContext"]
|
||||
CtxUIState["UIStateContext"]
|
||||
CtxUIActions["UIActionsContext"]
|
||||
end
|
||||
|
||||
subgraph "Component Consumers"
|
||||
direction TB
|
||||
ConsumerApp["App"]
|
||||
ConsumerAppContainer["AppContainer"]
|
||||
ConsumerAppHeader["AppHeader"]
|
||||
ConsumerDialogManager["DialogManager"]
|
||||
ConsumerHistoryItem["HistoryItemDisplay"]
|
||||
ConsumerComposer["Composer"]
|
||||
ConsumerMainContent["MainContent"]
|
||||
ConsumerNotifications["Notifications"]
|
||||
end
|
||||
|
||||
%% --- Provider -> Context Connections ---
|
||||
A -.-> CtxSession
|
||||
A -.-> CtxVim
|
||||
A -.-> CtxSettings
|
||||
|
||||
B -.-> CtxApp
|
||||
B -.-> CtxConfig
|
||||
B -.-> CtxUIState
|
||||
B -.-> CtxUIActions
|
||||
B -.-> CtxSettings
|
||||
|
||||
%% --- Context -> Consumer Connections ---
|
||||
CtxSession -.-> ConsumerAppContainer
|
||||
CtxSession -.-> ConsumerApp
|
||||
|
||||
CtxVim -.-> ConsumerAppContainer
|
||||
CtxVim -.-> ConsumerComposer
|
||||
CtxVim -.-> ConsumerApp
|
||||
|
||||
CtxSettings -.-> ConsumerAppContainer
|
||||
CtxSettings -.-> ConsumerAppHeader
|
||||
CtxSettings -.-> ConsumerDialogManager
|
||||
CtxSettings -.-> ConsumerApp
|
||||
|
||||
CtxApp -.-> ConsumerAppHeader
|
||||
CtxApp -.-> ConsumerNotifications
|
||||
|
||||
CtxConfig -.-> ConsumerAppHeader
|
||||
CtxConfig -.-> ConsumerHistoryItem
|
||||
CtxConfig -.-> ConsumerComposer
|
||||
CtxConfig -.-> ConsumerDialogManager
|
||||
|
||||
|
||||
|
||||
CtxUIState -.-> ConsumerApp
|
||||
CtxUIState -.-> ConsumerMainContent
|
||||
CtxUIState -.-> ConsumerComposer
|
||||
CtxUIState -.-> ConsumerDialogManager
|
||||
|
||||
CtxUIActions -.-> ConsumerComposer
|
||||
CtxUIActions -.-> ConsumerDialogManager
|
||||
|
||||
%% --- Apply Styles ---
|
||||
%% New Elements (Green)
|
||||
class B,CtxApp,CtxConfig,CtxUIState,CtxUIActions,ConsumerAppHeader,ConsumerDialogManager,ConsumerComposer,ConsumerMainContent,ConsumerNotifications new
|
||||
|
||||
%% Heavily Changed Elements (Blue)
|
||||
class A,ConsumerApp,ConsumerAppContainer,ConsumerHistoryItem changed
|
||||
|
||||
%% Mostly Unchanged Elements (Gray)
|
||||
class CtxSession,CtxVim,CtxSettings unchanged
|
||||
|
||||
%% --- Link Styles ---
|
||||
%% CtxSession (Red)
|
||||
linkStyle 0,8,9 stroke:#e57373,stroke-width:2px
|
||||
%% CtxVim (Orange)
|
||||
linkStyle 1,10,11,12 stroke:#ffb74d,stroke-width:2px
|
||||
%% CtxSettings (Yellow)
|
||||
linkStyle 2,7,13,14,15,16 stroke:#fff176,stroke-width:2px
|
||||
%% CtxApp (Green)
|
||||
linkStyle 3,17,18 stroke:#81c784,stroke-width:2px
|
||||
%% CtxConfig (Blue)
|
||||
linkStyle 4,19,20,21,22 stroke:#64b5f6,stroke-width:2px
|
||||
%% CtxUIState (Indigo)
|
||||
linkStyle 5,23,24,25,26 stroke:#7986cb,stroke-width:2px
|
||||
%% CtxUIActions (Violet)
|
||||
linkStyle 6,27,28 stroke:#ba68c8,stroke-width:2px
|
||||
64
docs/mermaid/render-path.mmd
Normal file
64
docs/mermaid/render-path.mmd
Normal file
@@ -0,0 +1,64 @@
|
||||
graph TD
|
||||
%% --- Style Definitions ---
|
||||
classDef new fill:#98fb98,color:#000
|
||||
classDef changed fill:#add8e6,color:#000
|
||||
classDef unchanged fill:#f0f0f0,color:#000
|
||||
classDef dispatcher fill:#f9e79f,color:#000,stroke:#333,stroke-width:1px
|
||||
classDef container fill:#f5f5f5,color:#000,stroke:#ccc
|
||||
|
||||
%% --- Component Tree ---
|
||||
subgraph "Entry Point"
|
||||
A["gemini.tsx"]
|
||||
end
|
||||
|
||||
subgraph "State & Logic Wrapper"
|
||||
B["AppContainer.tsx"]
|
||||
end
|
||||
|
||||
subgraph "Primary Layout"
|
||||
C["App.tsx"]
|
||||
end
|
||||
|
||||
A -.-> B
|
||||
B -.-> C
|
||||
|
||||
subgraph "UI Containers"
|
||||
direction LR
|
||||
C -.-> D["MainContent"]
|
||||
C -.-> G["Composer"]
|
||||
C -.-> F["DialogManager"]
|
||||
C -.-> E["Notifications"]
|
||||
end
|
||||
|
||||
subgraph "MainContent"
|
||||
direction TB
|
||||
D -.-> H["AppHeader"]
|
||||
D -.-> I["HistoryItemDisplay"]:::dispatcher
|
||||
D -.-> L["ShowMoreLines"]
|
||||
end
|
||||
|
||||
subgraph "Composer"
|
||||
direction TB
|
||||
G -.-> K_Prompt["InputPrompt"]
|
||||
G -.-> K_Footer["Footer"]
|
||||
end
|
||||
|
||||
subgraph "DialogManager"
|
||||
F -.-> J["Various Dialogs<br>(Auth, Theme, Settings, etc.)"]
|
||||
end
|
||||
|
||||
%% --- Apply Styles ---
|
||||
class B,D,E,F,G,H,J,K_Prompt,L new
|
||||
class A,C,I changed
|
||||
class K_Footer unchanged
|
||||
|
||||
%% --- Link Styles ---
|
||||
%% MainContent Branch (Blue)
|
||||
linkStyle 2,6,7,8 stroke:#64b5f6,stroke-width:2px
|
||||
%% Composer Branch (Green)
|
||||
linkStyle 3,9,10 stroke:#81c784,stroke-width:2px
|
||||
%% DialogManager Branch (Orange)
|
||||
linkStyle 4,11 stroke:#ffb74d,stroke-width:2px
|
||||
%% Notifications Branch (Violet)
|
||||
linkStyle 5 stroke:#ba68c8,stroke-width:2px
|
||||
|
||||
68
docs/sidebar.json
Normal file
68
docs/sidebar.json
Normal file
@@ -0,0 +1,68 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"items": [
|
||||
{ "label": "Welcome", "slug": "docs" },
|
||||
{ "label": "Execution and Deployment", "slug": "docs/deployment" },
|
||||
{ "label": "Architecture Overview", "slug": "docs/architecture" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "CLI",
|
||||
"items": [
|
||||
{ "label": "Introduction", "slug": "docs/cli" },
|
||||
{ "label": "Authentication", "slug": "docs/cli/authentication" },
|
||||
{ "label": "Commands", "slug": "docs/cli/commands" },
|
||||
{ "label": "Configuration", "slug": "docs/cli/configuration" },
|
||||
{ "label": "Checkpointing", "slug": "docs/checkpointing" },
|
||||
{ "label": "Extensions", "slug": "docs/extension" },
|
||||
{ "label": "Headless Mode", "slug": "docs/headless" },
|
||||
{ "label": "IDE Integration", "slug": "docs/ide-integration" },
|
||||
{
|
||||
"label": "IDE Companion Spec",
|
||||
"slug": "docs/ide-companion-spec"
|
||||
},
|
||||
{ "label": "Telemetry", "slug": "docs/telemetry" },
|
||||
{ "label": "Themes", "slug": "docs/cli/themes" },
|
||||
{ "label": "Token Caching", "slug": "docs/cli/token-caching" },
|
||||
{ "label": "Trusted Folders", "slug": "docs/trusted-folders" },
|
||||
{ "label": "Tutorials", "slug": "docs/cli/tutorials" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Core",
|
||||
"items": [
|
||||
{ "label": "Introduction", "slug": "docs/core" },
|
||||
{ "label": "Tools API", "slug": "docs/core/tools-api" },
|
||||
{ "label": "Memory Import Processor", "slug": "docs/core/memport" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Tools",
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/tools" },
|
||||
{ "label": "File System", "slug": "docs/tools/file-system" },
|
||||
{ "label": "Multi-File Read", "slug": "docs/tools/multi-file" },
|
||||
{ "label": "Shell", "slug": "docs/tools/shell" },
|
||||
{ "label": "Web Fetch", "slug": "docs/tools/web-fetch" },
|
||||
{ "label": "Web Search", "slug": "docs/tools/web-search" },
|
||||
{ "label": "Memory", "slug": "docs/tools/memory" },
|
||||
{ "label": "MCP Servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Sandboxing", "slug": "docs/sandbox" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Development",
|
||||
"items": [
|
||||
{ "label": "NPM", "slug": "docs/npm" },
|
||||
{ "label": "Releases", "slug": "docs/releases" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Support",
|
||||
"items": [
|
||||
{ "label": "Troubleshooting", "slug": "docs/troubleshooting" },
|
||||
{ "label": "Terms of Service", "slug": "docs/tos-privacy" }
|
||||
]
|
||||
}
|
||||
]
|
||||
4
docs/support/_meta.ts
Normal file
4
docs/support/_meta.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
troubleshooting: 'Troubleshooting',
|
||||
'tos-privacy': 'Terms of Service',
|
||||
};
|
||||
@@ -14,6 +14,13 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
- **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`
|
||||
|
||||
- **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.
|
||||
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
|
||||
- Open `~/.qwen/settings.json` (or `./.qwen/settings.json` for project-specific settings)
|
||||
- Remove the `security.auth.selectedType` field
|
||||
- Restart the CLI to allow it to prompt for authentication again
|
||||
|
||||
## Frequently asked questions (FAQs)
|
||||
|
||||
- **Q: How do I update Qwen Code to the latest version?**
|
||||
@@ -62,7 +69,7 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
- **DEBUG mode not working from project .env file**
|
||||
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for the CLI.
|
||||
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with the CLI behavior.
|
||||
- **Solution:** Use a `.qwen/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables.
|
||||
- **Solution:** Use a `.qwen/.env` file instead, or configure the `advanced.excludedEnvVars` setting in your `settings.json` to exclude fewer variables.
|
||||
|
||||
## IDE Companion not connecting
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
# Qwen Code Observability Guide
|
||||
|
||||
Telemetry provides data about Qwen Code's performance, health, and usage. By enabling it, you can monitor operations, debug issues, and optimize tool usage through traces, metrics, and structured logs.
|
||||
|
||||
Qwen Code's telemetry system is built on the **[OpenTelemetry] (OTEL)** standard, allowing you to send data to any compatible backend.
|
||||
|
||||
[OpenTelemetry]: https://opentelemetry.io/
|
||||
|
||||
## Enabling telemetry
|
||||
|
||||
You can enable telemetry in multiple ways. Configuration is primarily managed via the [`.qwen/settings.json` file](./cli/configuration.md) and environment variables, but CLI flags can override these settings for a specific session.
|
||||
|
||||
### Order of precedence
|
||||
|
||||
The following lists the precedence for applying telemetry settings, with items listed higher having greater precedence:
|
||||
|
||||
1. **CLI flags (for `qwen` command):**
|
||||
- `--telemetry` / `--no-telemetry`: Overrides `telemetry.enabled`.
|
||||
- `--telemetry-target <local|gcp>`: Overrides `telemetry.target`.
|
||||
- `--telemetry-otlp-endpoint <URL>`: Overrides `telemetry.otlpEndpoint`.
|
||||
- `--telemetry-log-prompts` / `--no-telemetry-log-prompts`: Overrides `telemetry.logPrompts`.
|
||||
- `--telemetry-outfile <path>`: Redirects telemetry output to a file. See [Exporting to a file](#exporting-to-a-file).
|
||||
|
||||
1. **Environment variables:**
|
||||
- `OTEL_EXPORTER_OTLP_ENDPOINT`: Overrides `telemetry.otlpEndpoint`.
|
||||
|
||||
1. **Workspace settings file (`.qwen/settings.json`):** Values from the `telemetry` object in this project-specific file.
|
||||
|
||||
1. **User settings file (`~/.qwen/settings.json`):** Values from the `telemetry` object in this global user file.
|
||||
|
||||
1. **Defaults:** applied if not set by any of the above.
|
||||
- `telemetry.enabled`: `false`
|
||||
- `telemetry.target`: `local`
|
||||
- `telemetry.otlpEndpoint`: `http://localhost:4317`
|
||||
- `telemetry.logPrompts`: `true`
|
||||
|
||||
**For the `npm run telemetry -- --target=<gcp|local>` script:**
|
||||
The `--target` argument to this script _only_ overrides the `telemetry.target` for the duration and purpose of that script (i.e., choosing which collector to start). It does not permanently change your `settings.json`. The script will first look at `settings.json` for a `telemetry.target` to use as its default.
|
||||
|
||||
### Example settings
|
||||
|
||||
The following code can be added to your workspace (`.qwen/settings.json`) or user (`~/.qwen/settings.json`) settings to enable telemetry and send the output to Google Cloud:
|
||||
|
||||
```json
|
||||
{
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp"
|
||||
},
|
||||
"sandbox": false
|
||||
}
|
||||
```
|
||||
|
||||
### Exporting to a file
|
||||
|
||||
You can export all telemetry data to a file for local inspection.
|
||||
|
||||
To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`.
|
||||
|
||||
```bash
|
||||
# Set your desired output file path
|
||||
TELEMETRY_FILE=".qwen/telemetry.log"
|
||||
|
||||
# Run Qwen Code with local telemetry
|
||||
# NOTE: --telemetry-otlp-endpoint="" is required to override the default
|
||||
# OTLP exporter and ensure telemetry is written to the local file.
|
||||
qwen --telemetry \
|
||||
--telemetry-target=local \
|
||||
--telemetry-otlp-endpoint="" \
|
||||
--telemetry-outfile="$TELEMETRY_FILE" \
|
||||
--prompt "What is OpenTelemetry?"
|
||||
```
|
||||
|
||||
## Running an OTEL Collector
|
||||
|
||||
An OTEL Collector is a service that receives, processes, and exports telemetry data.
|
||||
The CLI can send data using either the OTLP/gRPC or OTLP/HTTP protocol.
|
||||
You can specify which protocol to use via the `--telemetry-otlp-protocol` flag
|
||||
or the `telemetry.otlpProtocol` setting in your `settings.json` file. See the
|
||||
[configuration docs](./cli/configuration.md#--telemetry-otlp-protocol) for more
|
||||
details.
|
||||
|
||||
Learn more about OTEL exporter standard configuration in [documentation][otel-config-docs].
|
||||
|
||||
[otel-config-docs]: https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/
|
||||
|
||||
### Local
|
||||
|
||||
Use the `npm run telemetry -- --target=local` command to automate the process of setting up a local telemetry pipeline, including configuring the necessary settings in your `.qwen/settings.json` file. The underlying script installs `otelcol-contrib` (the OpenTelemetry Collector) and `jaeger` (The Jaeger UI for viewing traces). To use it:
|
||||
|
||||
1. **Run the command**:
|
||||
Execute the command from the root of the repository:
|
||||
|
||||
```bash
|
||||
npm run telemetry -- --target=local
|
||||
```
|
||||
|
||||
The script will:
|
||||
- Download Jaeger and OTEL if needed.
|
||||
- Start a local Jaeger instance.
|
||||
- Start an OTEL collector configured to receive data from Qwen Code.
|
||||
- Automatically enable telemetry in your workspace settings.
|
||||
- On exit, disable telemetry.
|
||||
|
||||
1. **View traces**:
|
||||
Open your web browser and navigate to **http://localhost:16686** to access the Jaeger UI. Here you can inspect detailed traces of Qwen Code operations.
|
||||
|
||||
1. **Inspect logs and metrics**:
|
||||
The script redirects the OTEL collector output (which includes logs and metrics) to `~/.qwen/tmp/<projectHash>/otel/collector.log`. The script will provide links to view and a command to tail your telemetry data (traces, metrics, logs) locally.
|
||||
|
||||
1. **Stop the services**:
|
||||
Press `Ctrl+C` in the terminal where the script is running to stop the OTEL Collector and Jaeger services.
|
||||
|
||||
### Google Cloud
|
||||
|
||||
Use the `npm run telemetry -- --target=gcp` command to automate setting up a local OpenTelemetry collector that forwards data to your Google Cloud project, including configuring the necessary settings in your `.qwen/settings.json` file. The underlying script installs `otelcol-contrib`. To use it:
|
||||
|
||||
1. **Prerequisites**:
|
||||
- Have a Google Cloud project ID.
|
||||
- Export the `GOOGLE_CLOUD_PROJECT` environment variable to make it available to the OTEL collector.
|
||||
```bash
|
||||
export OTLP_GOOGLE_CLOUD_PROJECT="your-project-id"
|
||||
```
|
||||
- Authenticate with Google Cloud (e.g., run `gcloud auth application-default login` or ensure `GOOGLE_APPLICATION_CREDENTIALS` is set).
|
||||
- Ensure your Google Cloud account/service account has the necessary IAM roles: "Cloud Trace Agent", "Monitoring Metric Writer", and "Logs Writer".
|
||||
|
||||
1. **Run the command**:
|
||||
Execute the command from the root of the repository:
|
||||
|
||||
```bash
|
||||
npm run telemetry -- --target=gcp
|
||||
```
|
||||
|
||||
The script will:
|
||||
- Download the `otelcol-contrib` binary if needed.
|
||||
- Start an OTEL collector configured to receive data from Qwen Code and export it to your specified Google Cloud project.
|
||||
- Automatically enable telemetry and disable sandbox mode in your workspace settings (`.qwen/settings.json`).
|
||||
- Provide direct links to view traces, metrics, and logs in your Google Cloud Console.
|
||||
- On exit (Ctrl+C), it will attempt to restore your original telemetry and sandbox settings.
|
||||
|
||||
1. **Run Qwen Code:**
|
||||
In a separate terminal, run your Qwen Code commands. This generates telemetry data that the collector captures.
|
||||
|
||||
1. **View telemetry in Google Cloud**:
|
||||
Use the links provided by the script to navigate to the Google Cloud Console and view your traces, metrics, and logs.
|
||||
|
||||
1. **Inspect local collector logs**:
|
||||
The script redirects the local OTEL collector output to `~/.qwen/tmp/<projectHash>/otel/collector-gcp.log`. The script provides links to view and command to tail your collector logs locally.
|
||||
|
||||
1. **Stop the service**:
|
||||
Press `Ctrl+C` in the terminal where the script is running to stop the OTEL Collector.
|
||||
|
||||
## Logs and metric reference
|
||||
|
||||
The following section describes the structure of logs and metrics generated for Qwen Code.
|
||||
|
||||
- A `sessionId` is included as a common attribute on all logs and metrics.
|
||||
|
||||
### Logs
|
||||
|
||||
Logs are timestamped records of specific events. The following events are logged for Qwen Code:
|
||||
|
||||
- `qwen-code.config`: This event occurs once at startup with the CLI's configuration.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
- `embedding_model` (string)
|
||||
- `sandbox_enabled` (boolean)
|
||||
- `core_tools_enabled` (string)
|
||||
- `approval_mode` (string)
|
||||
- `api_key_enabled` (boolean)
|
||||
- `vertex_ai_enabled` (boolean)
|
||||
- `code_assist_enabled` (boolean)
|
||||
- `log_prompts_enabled` (boolean)
|
||||
- `file_filtering_respect_git_ignore` (boolean)
|
||||
- `debug_mode` (boolean)
|
||||
- `mcp_servers` (string)
|
||||
|
||||
- `qwen-code.user_prompt`: This event occurs when a user submits a prompt.
|
||||
- **Attributes**:
|
||||
- `prompt_length` (int)
|
||||
- `prompt_id` (string)
|
||||
- `prompt` (string, this attribute is excluded if `log_prompts_enabled` is configured to be `false`)
|
||||
- `auth_type` (string)
|
||||
|
||||
- `qwen-code.tool_call`: This event occurs for each function call.
|
||||
- **Attributes**:
|
||||
- `function_name`
|
||||
- `function_args`
|
||||
- `duration_ms`
|
||||
- `success` (boolean)
|
||||
- `decision` (string: "accept", "reject", "auto_accept", or "modify", if applicable)
|
||||
- `error` (if applicable)
|
||||
- `error_type` (if applicable)
|
||||
- `metadata` (if applicable, dictionary of string -> any)
|
||||
|
||||
- `qwen-code.api_request`: This event occurs when making a request to Qwen API.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `request_text` (if applicable)
|
||||
|
||||
- `qwen-code.api_error`: This event occurs if the API request fails.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `error`
|
||||
- `error_type`
|
||||
- `status_code`
|
||||
- `duration_ms`
|
||||
- `auth_type`
|
||||
|
||||
- `qwen-code.api_response`: This event occurs upon receiving a response from Qwen API.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `status_code`
|
||||
- `duration_ms`
|
||||
- `error` (optional)
|
||||
- `input_token_count`
|
||||
- `output_token_count`
|
||||
- `cached_content_token_count`
|
||||
- `thoughts_token_count`
|
||||
- `tool_token_count`
|
||||
- `response_text` (if applicable)
|
||||
- `auth_type`
|
||||
|
||||
- `qwen-code.flash_fallback`: This event occurs when Qwen Code switches to flash as fallback.
|
||||
- **Attributes**:
|
||||
- `auth_type`
|
||||
|
||||
- `qwen-code.slash_command`: This event occurs when a user executes a slash command.
|
||||
- **Attributes**:
|
||||
- `command` (string)
|
||||
- `subcommand` (string, if applicable)
|
||||
|
||||
### Metrics
|
||||
|
||||
Metrics are numerical measurements of behavior over time. The following metrics are collected for Qwen Code (metric names remain `qwen-code.*` for compatibility):
|
||||
|
||||
- `qwen-code.session.count` (Counter, Int): Incremented once per CLI startup.
|
||||
|
||||
- `qwen-code.tool.call.count` (Counter, Int): Counts tool calls.
|
||||
- **Attributes**:
|
||||
- `function_name`
|
||||
- `success` (boolean)
|
||||
- `decision` (string: "accept", "reject", or "modify", if applicable)
|
||||
- `tool_type` (string: "mcp", or "native", if applicable)
|
||||
|
||||
- `qwen-code.tool.call.latency` (Histogram, ms): Measures tool call latency.
|
||||
- **Attributes**:
|
||||
- `function_name`
|
||||
- `decision` (string: "accept", "reject", or "modify", if applicable)
|
||||
|
||||
- `qwen-code.api.request.count` (Counter, Int): Counts all API requests.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `status_code`
|
||||
- `error_type` (if applicable)
|
||||
|
||||
- `qwen-code.api.request.latency` (Histogram, ms): Measures API request latency.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
|
||||
- `qwen-code.token.usage` (Counter, Int): Counts the number of tokens used.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `type` (string: "input", "output", "thought", "cache", or "tool")
|
||||
|
||||
- `qwen-code.file.operation.count` (Counter, Int): Counts file operations.
|
||||
- **Attributes**:
|
||||
- `operation` (string: "create", "read", "update"): The type of file operation.
|
||||
- `lines` (Int, if applicable): Number of lines in the file.
|
||||
- `mimetype` (string, if applicable): Mimetype of the file.
|
||||
- `extension` (string, if applicable): File extension of the file.
|
||||
- `ai_added_lines` (Int, if applicable): Number of lines added/changed by AI.
|
||||
- `ai_removed_lines` (Int, if applicable): Number of lines removed/changed by AI.
|
||||
- `user_added_lines` (Int, if applicable): Number of lines added/changed by user in AI proposed changes.
|
||||
- `user_removed_lines` (Int, if applicable): Number of lines removed/changed by user in AI proposed changes.
|
||||
- `programming_language` (string, if applicable): The programming language of the file.
|
||||
|
||||
- `qwen-code.chat_compression` (Counter, Int): Counts chat compression operations
|
||||
- **Attributes**:
|
||||
- `tokens_before`: (Int): Number of tokens in context prior to compression
|
||||
- `tokens_after`: (Int): Number of tokens in context after compression
|
||||
11
docs/tools/_meta.ts
Normal file
11
docs/tools/_meta.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
index: 'Introduction',
|
||||
'file-system': 'File System',
|
||||
'multi-file': 'Multi-File Read',
|
||||
shell: 'Shell',
|
||||
'todo-write': 'Todo Write',
|
||||
'web-fetch': 'Web Fetch',
|
||||
'web-search': 'Web Search',
|
||||
memory: 'Memory',
|
||||
'mcp-server': 'MCP Servers',
|
||||
};
|
||||
@@ -4,12 +4,12 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
|
||||
|
||||
**Note:** All file system tools operate within a `rootDirectory` (usually the current working directory where you launched the CLI) for security. Paths that you provide to these tools are generally expected to be absolute or are resolved relative to this root directory.
|
||||
|
||||
## 1. `list_directory` (ReadFolder)
|
||||
## 1. `list_directory` (ListFiles)
|
||||
|
||||
`list_directory` lists the names of files and subdirectories directly within a specified directory path. It can optionally ignore entries matching provided glob patterns.
|
||||
|
||||
- **Tool name:** `list_directory`
|
||||
- **Display name:** ReadFolder
|
||||
- **Display name:** ListFiles
|
||||
- **File:** `ls.ts`
|
||||
- **Parameters:**
|
||||
- `path` (string, required): The absolute path to the directory to list.
|
||||
@@ -59,86 +59,80 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
|
||||
- **Output (`llmContent`):** A success message, e.g., `Successfully overwrote file: /path/to/your/file.txt` or `Successfully created and wrote to new file: /path/to/new/file.txt`.
|
||||
- **Confirmation:** Yes. Shows a diff of changes and asks for user approval before writing.
|
||||
|
||||
## 4. `glob` (FindFiles)
|
||||
## 4. `glob` (Glob)
|
||||
|
||||
`glob` finds files matching specific glob patterns (e.g., `src/**/*.ts`, `*.md`), returning absolute paths sorted by modification time (newest first).
|
||||
|
||||
- **Tool name:** `glob`
|
||||
- **Display name:** FindFiles
|
||||
- **Display name:** Glob
|
||||
- **File:** `glob.ts`
|
||||
- **Parameters:**
|
||||
- `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`).
|
||||
- `path` (string, optional): The absolute path to the directory to search within. If omitted, searches the tool's root directory.
|
||||
- `case_sensitive` (boolean, optional): Whether the search should be case-sensitive. Defaults to `false`.
|
||||
- `respect_git_ignore` (boolean, optional): Whether to respect .gitignore patterns when finding files. Defaults to `true`.
|
||||
- `path` (string, optional): The directory to search in. If not specified, the current working directory will be used.
|
||||
- **Behavior:**
|
||||
- Searches for files matching the glob pattern within the specified directory.
|
||||
- Returns a list of absolute paths, sorted with the most recently modified files first.
|
||||
- Ignores common nuisance directories like `node_modules` and `.git` by default.
|
||||
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
|
||||
- Respects .gitignore and .qwenignore patterns by default.
|
||||
- Limits results to 100 files to prevent context overflow.
|
||||
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within /path/to/search/dir, sorted by modification time (newest first):\n---\n/path/to/file1.ts\n/path/to/subdir/file2.ts\n---\n[95 files truncated] ...`
|
||||
- **Confirmation:** No.
|
||||
|
||||
## 5. `search_file_content` (SearchText)
|
||||
## 5. `grep_search` (Grep)
|
||||
|
||||
`search_file_content` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
|
||||
`grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
|
||||
|
||||
- **Tool name:** `search_file_content`
|
||||
- **Display name:** SearchText
|
||||
- **File:** `grep.ts`
|
||||
- **Tool name:** `grep_search`
|
||||
- **Display name:** Grep
|
||||
- **File:** `ripGrep.ts` (with `grep.ts` as fallback)
|
||||
- **Parameters:**
|
||||
- `pattern` (string, required): The regular expression (regex) to search for (e.g., `"function\s+myFunction"`).
|
||||
- `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory.
|
||||
- `include` (string, optional): A glob pattern to filter which files are searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most files (respecting common ignores).
|
||||
- `maxResults` (number, optional): Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.
|
||||
- `pattern` (string, required): The regular expression pattern to search for in file contents (e.g., `"function\\s+myFunction"`, `"log.*Error"`).
|
||||
- `path` (string, optional): File or directory to search in. Defaults to current working directory.
|
||||
- `glob` (string, optional): Glob pattern to filter files (e.g. `"*.js"`, `"src/**/*.{ts,tsx}"`).
|
||||
- `limit` (number, optional): Limit output to first N matching lines. Optional - shows all matches if not specified.
|
||||
- **Behavior:**
|
||||
- Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search.
|
||||
- Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number.
|
||||
- Limits results to a maximum of 20 matches by default to prevent context overflow. When results are truncated, shows a clear warning with guidance on refining searches.
|
||||
- Uses ripgrep for fast search when available; otherwise falls back to a JavaScript-based search implementation.
|
||||
- Returns matching lines with file paths and line numbers.
|
||||
- Case-insensitive by default.
|
||||
- Respects .gitignore and .qwenignore patterns.
|
||||
- Limits output to prevent context overflow.
|
||||
- **Output (`llmContent`):** A formatted string of matches, e.g.:
|
||||
|
||||
```
|
||||
Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"):
|
||||
---
|
||||
File: src/utils.ts
|
||||
L15: export function myFunction() {
|
||||
L22: myFunction.call();
|
||||
---
|
||||
File: src/index.ts
|
||||
L5: import { myFunction } from './utils';
|
||||
src/utils.ts:15:export function myFunction() {
|
||||
src/utils.ts:22: myFunction.call();
|
||||
src/index.ts:5:import { myFunction } from './utils';
|
||||
---
|
||||
|
||||
WARNING: Results truncated to prevent context overflow. To see more results:
|
||||
- Use a more specific pattern to reduce matches
|
||||
- Add file filters with the 'include' parameter (e.g., "*.js", "src/**")
|
||||
- Specify a narrower 'path' to search in a subdirectory
|
||||
- Increase 'maxResults' parameter if you need more matches (current: 20)
|
||||
[0 lines truncated] ...
|
||||
```
|
||||
|
||||
- **Confirmation:** No.
|
||||
|
||||
### `search_file_content` examples
|
||||
### `grep_search` examples
|
||||
|
||||
Search for a pattern with default result limiting:
|
||||
|
||||
```
|
||||
search_file_content(pattern="function\s+myFunction", path="src")
|
||||
grep_search(pattern="function\\s+myFunction", path="src")
|
||||
```
|
||||
|
||||
Search for a pattern with custom result limiting:
|
||||
|
||||
```
|
||||
search_file_content(pattern="function", path="src", maxResults=50)
|
||||
grep_search(pattern="function", path="src", limit=50)
|
||||
```
|
||||
|
||||
Search for a pattern with file filtering and custom result limiting:
|
||||
|
||||
```
|
||||
search_file_content(pattern="function", include="*.js", maxResults=10)
|
||||
grep_search(pattern="function", glob="*.js", limit=10)
|
||||
```
|
||||
|
||||
## 6. `edit` (Edit)
|
||||
|
||||
`edit` replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when `expected_replacements` is specified. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location.
|
||||
`edit` replaces text within a file. By default it requires `old_string` to match a single unique location; set `replace_all` to `true` when you intentionally want to change every occurrence. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location.
|
||||
|
||||
- **Tool name:** `edit`
|
||||
- **Display name:** Edit
|
||||
@@ -150,12 +144,12 @@ search_file_content(pattern="function", include="*.js", maxResults=10)
|
||||
**CRITICAL:** This string must uniquely identify the single instance to change. It should include at least 3 lines of context _before_ and _after_ the target text, matching whitespace and indentation precisely. If `old_string` is empty, the tool attempts to create a new file at `file_path` with `new_string` as content.
|
||||
|
||||
- `new_string` (string, required): The exact literal text to replace `old_string` with.
|
||||
- `expected_replacements` (number, optional): The number of occurrences to replace. Defaults to `1`.
|
||||
- `replace_all` (boolean, optional): Replace all occurrences of `old_string`. Defaults to `false`.
|
||||
|
||||
- **Behavior:**
|
||||
- If `old_string` is empty and `file_path` does not exist, creates a new file with `new_string` as content.
|
||||
- If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence of `old_string`.
|
||||
- If one occurrence is found, it replaces it with `new_string`.
|
||||
- If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence unless `replace_all` is true.
|
||||
- If the match is unique (or `replace_all` is true), it replaces the text with `new_string`.
|
||||
- **Enhanced Reliability (Multi-Stage Edit Correction):** To significantly improve the success rate of edits, especially when the model-provided `old_string` might not be perfectly precise, the tool incorporates a multi-stage edit correction mechanism.
|
||||
- If the initial `old_string` isn't found or matches multiple locations, the tool can leverage the Qwen model to iteratively refine `old_string` (and potentially `new_string`).
|
||||
- This self-correction process attempts to identify the unique segment the model intended to modify, making the `edit` operation more robust even with slightly imperfect initial context.
|
||||
@@ -164,10 +158,10 @@ search_file_content(pattern="function", include="*.js", maxResults=10)
|
||||
- `old_string` is not empty, but the `file_path` does not exist.
|
||||
- `old_string` is empty, but the `file_path` already exists.
|
||||
- `old_string` is not found in the file after attempts to correct it.
|
||||
- `old_string` is found multiple times, and the self-correction mechanism cannot resolve it to a single, unambiguous match.
|
||||
- `old_string` is found multiple times, `replace_all` is false, and the self-correction mechanism cannot resolve it to a single, unambiguous match.
|
||||
- **Output (`llmContent`):**
|
||||
- On success: `Successfully modified file: /path/to/file.txt (1 replacements).` or `Created new file: /path/to/new_file.txt with provided content.`
|
||||
- On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit, expected 1 occurrences but found 2...`).
|
||||
- On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit because the text matches multiple locations...`).
|
||||
- **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file.
|
||||
|
||||
These file system tools provide a foundation for Qwen Code to understand and interact with your local project context.
|
||||
|
||||
@@ -51,7 +51,30 @@ Qwen Code uses the `mcpServers` configuration in your `settings.json` file to lo
|
||||
|
||||
### Configure the MCP server in settings.json
|
||||
|
||||
You can configure MCP servers at the global level in the `~/.qwen/settings.json` file or in your project's root directory, create or open the `.qwen/settings.json` file. Within the file, add the `mcpServers` configuration block.
|
||||
You can configure MCP servers in your `settings.json` file in two main ways: through the top-level `mcpServers` object for specific server definitions, and through the `mcp` object for global settings that control server discovery and execution.
|
||||
|
||||
#### Global MCP Settings (`mcp`)
|
||||
|
||||
The `mcp` object in your `settings.json` allows you to define global rules for all MCP servers.
|
||||
|
||||
- **`mcp.serverCommand`** (string): A global command to start an MCP server.
|
||||
- **`mcp.allowed`** (array of strings): A list of MCP server names to allow. If this is set, only servers from this list (matching the keys in the `mcpServers` object) will be connected to.
|
||||
- **`mcp.excluded`** (array of strings): A list of MCP server names to exclude. Servers in this list will not be connected to.
|
||||
|
||||
**Example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"allowed": ["my-trusted-server"],
|
||||
"excluded": ["experimental-server"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Server-Specific Configuration (`mcpServers`)
|
||||
|
||||
The `mcpServers` object is where you define each individual MCP server you want the CLI to connect to.
|
||||
|
||||
### Configuration Structure
|
||||
|
||||
@@ -92,8 +115,10 @@ Each server configuration supports the following properties:
|
||||
- **`cwd`** (string): Working directory for Stdio transport
|
||||
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms = 10 minutes)
|
||||
- **`trust`** (boolean): When `true`, bypasses all tool call confirmations for this server (default: `false`)
|
||||
- **`includeTools`** (string[]): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (whitelist behavior). If not specified, all tools from the server are enabled by default.
|
||||
- **`includeTools`** (string[]): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default.
|
||||
- **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded.
|
||||
- **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`.
|
||||
- **`targetServiceAccount`** (string): The email address of the Google Cloud Service Account to impersonate. Used with `authProviderType: 'service_account_impersonation'`.
|
||||
|
||||
### OAuth Support for Remote MCP Servers
|
||||
|
||||
@@ -187,6 +212,9 @@ You can specify the authentication provider type using the `authProviderType` pr
|
||||
- **`authProviderType`** (string): Specifies the authentication provider. Can be one of the following:
|
||||
- **`dynamic_discovery`** (default): The CLI will automatically discover the OAuth configuration from the server.
|
||||
- **`google_credentials`**: The CLI will use the Google Application Default Credentials (ADC) to authenticate with the server. When using this provider, you must specify the required scopes.
|
||||
- **`service_account_impersonation`**: The CLI will impersonate a Google Cloud Service Account to authenticate with the server. This is useful for accessing IAP-protected services (this was specifically designed for Cloud Run services).
|
||||
|
||||
#### Google Credentials
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -202,6 +230,24 @@ You can specify the authentication provider type using the `authProviderType` pr
|
||||
}
|
||||
```
|
||||
|
||||
#### Service Account Impersonation
|
||||
|
||||
To authenticate with a server using Service Account Impersonation, you must set the `authProviderType` to `service_account_impersonation` and provide the following properties:
|
||||
|
||||
- **`targetAudience`** (string): The OAuth Client ID allowslisted on the IAP-protected application you are trying to access.
|
||||
- **`targetServiceAccount`** (string): The email address of the Google Cloud Service Account to impersonate.
|
||||
|
||||
The CLI will use your local Application Default Credentials (ADC) to generate an OIDC ID token for the specified service account and audience. This token will then be used to authenticate with the MCP server.
|
||||
|
||||
#### Setup Instructions
|
||||
|
||||
1. **[Create](https://cloud.google.com/iap/docs/oauth-client-creation) or use an existing OAuth 2.0 client ID.** To use an existing OAuth 2.0 client ID, follow the steps in [How to share OAuth Clients](https://cloud.google.com/iap/docs/sharing-oauth-clients).
|
||||
2. **Add the OAuth ID to the allowlist for [programmatic access](https://cloud.google.com/iap/docs/sharing-oauth-clients#programmatic_access) for the application.** Since Cloud Run is not yet a supported resource type in gcloud iap, you must allowlist the Client ID on the project.
|
||||
3. **Create a service account.** [Documentation](https://cloud.google.com/iam/docs/service-accounts-create#creating), [Cloud Console Link](https://console.cloud.google.com/iam-admin/serviceaccounts)
|
||||
4. **Add both the service account and users to the IAP Policy** in the "Security" tab of the Cloud Run service itself or via gcloud.
|
||||
5. **Grant all users and groups** who will access the MCP Server the necessary permissions to [impersonate the service account](https://cloud.google.com/docs/authentication/use-service-account-impersonation) (i.e., `roles/iam.serviceAccountTokenCreator`).
|
||||
6. **[Enable](https://console.cloud.google.com/apis/library/iamcredentials.googleapis.com) the IAM Credentials API** for your project.
|
||||
|
||||
### Example Configurations
|
||||
|
||||
#### Python MCP Server (Stdio)
|
||||
@@ -310,6 +356,21 @@ You can specify the authentication provider type using the `authProviderType` pr
|
||||
}
|
||||
```
|
||||
|
||||
### SSE MCP Server with SA Impersonation
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"myIapProtectedServer": {
|
||||
"url": "https://my-iap-service.run.app/sse",
|
||||
"authProviderType": "service_account_impersonation",
|
||||
"targetAudience": "YOUR_IAP_CLIENT_ID.apps.googleusercontent.com",
|
||||
"targetServiceAccount": "your-sa@your-project.iam.gserviceaccount.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Discovery Process Deep Dive
|
||||
|
||||
When Qwen Code starts, it performs MCP server discovery through the following detailed process:
|
||||
@@ -667,9 +728,13 @@ await server.connect(transport);
|
||||
This can be included in `settings.json` under `mcpServers` with:
|
||||
|
||||
```json
|
||||
"nodeServer": {
|
||||
{
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["filename.ts"],
|
||||
"args": ["filename.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ This document describes the `run_shell_command` tool for Qwen Code.
|
||||
|
||||
## Description
|
||||
|
||||
Use `run_shell_command` to interact with the underlying system, run scripts, or perform command-line operations. `run_shell_command` executes a given shell command. On Windows, the command will be executed with `cmd.exe /c`. On other platforms, the command will be executed with `bash -c`.
|
||||
Use `run_shell_command` to interact with the underlying system, run scripts, or perform command-line operations. `run_shell_command` executes a given shell command, including interactive commands that require user input (e.g., `vim`, `git rebase -i`) if the `tools.shell.enableInteractiveShell` setting is set to `true`.
|
||||
|
||||
On Windows, commands are executed with `cmd.exe /c`. On other platforms, they are executed with `bash -c`.
|
||||
|
||||
### Arguments
|
||||
|
||||
@@ -102,10 +104,67 @@ Start multiple background services:
|
||||
run_shell_command(command="docker-compose up", description="Start all services", is_background=true)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure the behavior of the `run_shell_command` tool by modifying your `settings.json` file or by using the `/settings` command in the Qwen Code.
|
||||
|
||||
### Enabling Interactive Commands
|
||||
|
||||
To enable interactive commands, you need to set the `tools.shell.enableInteractiveShell` setting to `true`. This will use `node-pty` for shell command execution, which allows for interactive sessions. If `node-pty` is not available, it will fall back to the `child_process` implementation, which does not support interactive commands.
|
||||
|
||||
**Example `settings.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"shell": {
|
||||
"enableInteractiveShell": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Showing Color in Output
|
||||
|
||||
To show color in the shell output, you need to set the `tools.shell.showColor` setting to `true`. **Note: This setting only applies when `tools.shell.enableInteractiveShell` is enabled.**
|
||||
|
||||
**Example `settings.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"shell": {
|
||||
"showColor": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Setting the Pager
|
||||
|
||||
You can set a custom pager for the shell output by setting the `tools.shell.pager` setting. The default pager is `cat`. **Note: This setting only applies when `tools.shell.enableInteractiveShell` is enabled.**
|
||||
|
||||
**Example `settings.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"shell": {
|
||||
"pager": "less"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Interactive Commands
|
||||
|
||||
The `run_shell_command` tool now supports interactive commands by integrating a pseudo-terminal (pty). This allows you to run commands that require real-time user input, such as text editors (`vim`, `nano`), terminal-based UIs (`htop`), and interactive version control operations (`git rebase -i`).
|
||||
|
||||
When an interactive command is running, you can send input to it from the Qwen Code. To focus on the interactive shell, press `ctrl+f`. The terminal output, including complex TUIs, will be rendered correctly.
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Security:** Be cautious when executing commands, especially those constructed from user input, to prevent security vulnerabilities.
|
||||
- **Interactive commands:** Avoid commands that require interactive user input, as this can cause the tool to hang. Use non-interactive flags if available (e.g., `npm init -y`).
|
||||
- **Error handling:** Check the `Stderr`, `Error`, and `Exit Code` fields to determine if a command executed successfully.
|
||||
- **Background processes:** When `is_background=true` or when a command contains `&`, the tool will return immediately and the process will continue to run in the background. The `Background PIDs` field will contain the process ID of the background process.
|
||||
- **Background execution choices:** The `is_background` parameter is required and provides explicit control over execution mode. You can also add `&` to the command for manual background execution, but the `is_background` parameter must still be specified. The parameter provides clearer intent and automatically handles the background execution setup.
|
||||
@@ -117,16 +176,16 @@ When `run_shell_command` executes a command, it sets the `QWEN_CODE=1` environme
|
||||
|
||||
## Command Restrictions
|
||||
|
||||
You can restrict the commands that can be executed by the `run_shell_command` tool by using the `coreTools` and `excludeTools` settings in your configuration file.
|
||||
You can restrict the commands that can be executed by the `run_shell_command` tool by using the `tools.core` and `tools.exclude` settings in your configuration file.
|
||||
|
||||
- `coreTools`: To restrict `run_shell_command` to a specific set of commands, add entries to the `coreTools` list in the format `run_shell_command(<command>)`. For example, `"coreTools": ["run_shell_command(git)"]` will only allow `git` commands. Including the generic `run_shell_command` acts as a wildcard, allowing any command not explicitly blocked.
|
||||
- `excludeTools`: To block specific commands, add entries to the `excludeTools` list in the format `run_shell_command(<command>)`. For example, `"excludeTools": ["run_shell_command(rm)"]` will block `rm` commands.
|
||||
- `tools.core`: To restrict `run_shell_command` to a specific set of commands, add entries to the `core` list under the `tools` category in the format `run_shell_command(<command>)`. For example, `"tools": {"core": ["run_shell_command(git)"]}` will only allow `git` commands. Including the generic `run_shell_command` acts as a wildcard, allowing any command not explicitly blocked.
|
||||
- `tools.exclude`: To block specific commands, add entries to the `exclude` list under the `tools` category in the format `run_shell_command(<command>)`. For example, `"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands.
|
||||
|
||||
The validation logic is designed to be secure and flexible:
|
||||
|
||||
1. **Command Chaining Disabled**: The tool automatically splits commands chained with `&&`, `||`, or `;` and validates each part separately. If any part of the chain is disallowed, the entire command is blocked.
|
||||
2. **Prefix Matching**: The tool uses prefix matching. For example, if you allow `git`, you can run `git status` or `git log`.
|
||||
3. **Blocklist Precedence**: The `excludeTools` list is always checked first. If a command matches a blocked prefix, it will be denied, even if it also matches an allowed prefix in `coreTools`.
|
||||
3. **Blocklist Precedence**: The `tools.exclude` list is always checked first. If a command matches a blocked prefix, it will be denied, even if it also matches an allowed prefix in `tools.core`.
|
||||
|
||||
### Command Restriction Examples
|
||||
|
||||
@@ -136,7 +195,9 @@ To allow only `git` and `npm` commands, and block all others:
|
||||
|
||||
```json
|
||||
{
|
||||
"coreTools": ["run_shell_command(git)", "run_shell_command(npm)"]
|
||||
"tools": {
|
||||
"core": ["run_shell_command(git)", "run_shell_command(npm)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -150,8 +211,10 @@ To block `rm` and allow all other commands:
|
||||
|
||||
```json
|
||||
{
|
||||
"coreTools": ["run_shell_command"],
|
||||
"excludeTools": ["run_shell_command(rm)"]
|
||||
"tools": {
|
||||
"core": ["run_shell_command"],
|
||||
"exclude": ["run_shell_command(rm)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -161,12 +224,14 @@ To block `rm` and allow all other commands:
|
||||
|
||||
**Blocklist takes precedence**
|
||||
|
||||
If a command prefix is in both `coreTools` and `excludeTools`, it will be blocked.
|
||||
If a command prefix is in both `tools.core` and `tools.exclude`, it will be blocked.
|
||||
|
||||
```json
|
||||
{
|
||||
"coreTools": ["run_shell_command(git)"],
|
||||
"excludeTools": ["run_shell_command(git push)"]
|
||||
"tools": {
|
||||
"core": ["run_shell_command(git)"],
|
||||
"exclude": ["run_shell_command(git push)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -175,11 +240,13 @@ If a command prefix is in both `coreTools` and `excludeTools`, it will be blocke
|
||||
|
||||
**Block all shell commands**
|
||||
|
||||
To block all shell commands, add the `run_shell_command` wildcard to `excludeTools`:
|
||||
To block all shell commands, add the `run_shell_command` wildcard to `tools.exclude`:
|
||||
|
||||
```json
|
||||
{
|
||||
"excludeTools": ["run_shell_command"]
|
||||
"tools": {
|
||||
"exclude": ["run_shell_command"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,43 +1,186 @@
|
||||
# Web Search Tool (`web_search`)
|
||||
|
||||
This document describes the `web_search` tool.
|
||||
This document describes the `web_search` tool for performing web searches using multiple providers.
|
||||
|
||||
## Description
|
||||
|
||||
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
|
||||
Use `web_search` to perform a web search and get information from the internet. The tool supports multiple search providers and returns a concise answer with source citations when available.
|
||||
|
||||
### Supported Providers
|
||||
|
||||
1. **DashScope** (Official, Free) - Automatically available for Qwen OAuth users (200 requests/minute, 2000 requests/day)
|
||||
2. **Tavily** - High-quality search API with built-in answer generation
|
||||
3. **Google Custom Search** - Google's Custom Search JSON API
|
||||
|
||||
### Arguments
|
||||
|
||||
`web_search` takes one argument:
|
||||
`web_search` takes two arguments:
|
||||
|
||||
- `query` (string, required): The search query.
|
||||
- `query` (string, required): The search query
|
||||
- `provider` (string, optional): Specific provider to use ("dashscope", "tavily", "google")
|
||||
- If not specified, uses the default provider from configuration
|
||||
|
||||
## How to use `web_search`
|
||||
## Configuration
|
||||
|
||||
`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods:
|
||||
### Method 1: Settings File (Recommended)
|
||||
|
||||
1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json`
|
||||
2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file
|
||||
3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI
|
||||
Add to your `settings.json`:
|
||||
|
||||
If the key is not configured, the tool will be disabled and skipped.
|
||||
|
||||
Usage:
|
||||
|
||||
```
|
||||
web_search(query="Your query goes here.")
|
||||
```json
|
||||
{
|
||||
"webSearch": {
|
||||
"provider": [
|
||||
{ "type": "dashscope" },
|
||||
{ "type": "tavily", "apiKey": "tvly-xxxxx" },
|
||||
{
|
||||
"type": "google",
|
||||
"apiKey": "your-google-api-key",
|
||||
"searchEngineId": "your-search-engine-id"
|
||||
}
|
||||
],
|
||||
"default": "dashscope"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `web_search` examples
|
||||
**Notes:**
|
||||
|
||||
Get information on a topic:
|
||||
- DashScope doesn't require an API key (official, free service)
|
||||
- **Qwen OAuth users:** DashScope is automatically added to your provider list, even if not explicitly configured
|
||||
- Configure additional providers (Tavily, Google) if you want to use them alongside DashScope
|
||||
- Set `default` to specify which provider to use by default (if not set, priority order: Tavily > Google > DashScope)
|
||||
|
||||
```
|
||||
web_search(query="latest advancements in AI-powered code generation")
|
||||
### Method 2: Environment Variables
|
||||
|
||||
Set environment variables in your shell or `.env` file:
|
||||
|
||||
```bash
|
||||
# Tavily
|
||||
export TAVILY_API_KEY="tvly-xxxxx"
|
||||
|
||||
# Google
|
||||
export GOOGLE_API_KEY="your-api-key"
|
||||
export GOOGLE_SEARCH_ENGINE_ID="your-engine-id"
|
||||
```
|
||||
|
||||
## Important notes
|
||||
### Method 3: Command Line Arguments
|
||||
|
||||
- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links.
|
||||
- **Citations:** Source links are appended as a numbered list.
|
||||
- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered.
|
||||
Pass API keys when running Qwen Code:
|
||||
|
||||
```bash
|
||||
# Tavily
|
||||
qwen --tavily-api-key tvly-xxxxx
|
||||
|
||||
# Google
|
||||
qwen --google-api-key your-key --google-search-engine-id your-id
|
||||
|
||||
# Specify default provider
|
||||
qwen --web-search-default tavily
|
||||
```
|
||||
|
||||
### Backward Compatibility (Deprecated)
|
||||
|
||||
⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated:
|
||||
|
||||
```json
|
||||
{
|
||||
"advanced": {
|
||||
"tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration.
|
||||
|
||||
## Disabling Web Search
|
||||
|
||||
If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"exclude": ["web_search"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic search (using default provider)
|
||||
|
||||
```
|
||||
web_search(query="latest advancements in AI")
|
||||
```
|
||||
|
||||
### Search with specific provider
|
||||
|
||||
```
|
||||
web_search(query="latest advancements in AI", provider="tavily")
|
||||
```
|
||||
|
||||
### Real-world examples
|
||||
|
||||
```
|
||||
web_search(query="weather in San Francisco today")
|
||||
web_search(query="latest Node.js LTS version", provider="google")
|
||||
web_search(query="best practices for React 19", provider="dashscope")
|
||||
```
|
||||
|
||||
## Provider Details
|
||||
|
||||
### DashScope (Official)
|
||||
|
||||
- **Cost:** Free
|
||||
- **Authentication:** Automatically available when using Qwen OAuth authentication
|
||||
- **Configuration:** No API key required, automatically added to provider list for Qwen OAuth users
|
||||
- **Quota:** 200 requests/minute, 2000 requests/day
|
||||
- **Best for:** General queries, always available as fallback for Qwen OAuth users
|
||||
- **Auto-registration:** If you're using Qwen OAuth, DashScope is automatically added to your provider list even if you don't configure it explicitly
|
||||
|
||||
### Tavily
|
||||
|
||||
- **Cost:** Requires API key (paid service with free tier)
|
||||
- **Sign up:** https://tavily.com
|
||||
- **Features:** High-quality results with AI-generated answers
|
||||
- **Best for:** Research, comprehensive answers with citations
|
||||
|
||||
### Google Custom Search
|
||||
|
||||
- **Cost:** Free tier available (100 queries/day)
|
||||
- **Setup:**
|
||||
1. Enable Custom Search API in Google Cloud Console
|
||||
2. Create a Custom Search Engine at https://programmablesearchengine.google.com
|
||||
- **Features:** Google's search quality
|
||||
- **Best for:** Specific, factual queries
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Response format:** Returns a concise answer with numbered source citations
|
||||
- **Citations:** Source links are appended as a numbered list: [1], [2], etc.
|
||||
- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter
|
||||
- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed
|
||||
- **Default provider selection:** The system automatically selects a default provider based on availability:
|
||||
1. Your explicit `default` configuration (highest priority)
|
||||
2. CLI argument `--web-search-default`
|
||||
3. First available provider by priority: Tavily > Google > DashScope
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Tool not available?**
|
||||
|
||||
- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed
|
||||
- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured
|
||||
- For Tavily/Google: Verify your API keys are correct
|
||||
|
||||
**Provider-specific errors?**
|
||||
|
||||
- Use the `provider` parameter to try a different search provider
|
||||
- Check your API quotas and rate limits
|
||||
- Verify API keys are properly set in configuration
|
||||
|
||||
**Need help?**
|
||||
|
||||
- Check your configuration: Run `qwen` and use the settings dialog
|
||||
- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows)
|
||||
|
||||
@@ -4,24 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import esbuild from 'esbuild';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { writeFileSync, rmSync } from 'node:fs';
|
||||
|
||||
let esbuild;
|
||||
try {
|
||||
esbuild = (await import('esbuild')).default;
|
||||
} catch (_error) {
|
||||
console.warn('esbuild not available, skipping bundle step');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require(path.resolve(__dirname, 'package.json'));
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: ['packages/cli/index.ts'],
|
||||
bundle: true,
|
||||
outfile: 'bundle/gemini.js',
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
external: [
|
||||
// Clean dist directory (cross-platform)
|
||||
rmSync(path.resolve(__dirname, 'dist'), { recursive: true, force: true });
|
||||
|
||||
const external = [
|
||||
'@lydell/node-pty',
|
||||
'node-pty',
|
||||
'@lydell/node-pty-darwin-arm64',
|
||||
@@ -29,7 +33,24 @@ esbuild
|
||||
'@lydell/node-pty-linux-x64',
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
],
|
||||
'tiktoken',
|
||||
];
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: ['packages/cli/index.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/cli.js',
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node20',
|
||||
external,
|
||||
packages: 'bundle',
|
||||
inject: [path.resolve(__dirname, 'scripts/esbuild-shims.js')],
|
||||
banner: {
|
||||
js: `// Force strict mode and setup for ESM
|
||||
"use strict";`,
|
||||
},
|
||||
alias: {
|
||||
'is-in-ci': path.resolve(
|
||||
__dirname,
|
||||
@@ -38,10 +59,20 @@ esbuild
|
||||
},
|
||||
define: {
|
||||
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
|
||||
},
|
||||
banner: {
|
||||
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
|
||||
// Make global available for compatibility
|
||||
global: 'globalThis',
|
||||
},
|
||||
loader: { '.node': 'file' },
|
||||
metafile: true,
|
||||
write: true,
|
||||
keepNames: true,
|
||||
})
|
||||
.catch(() => process.exit(1));
|
||||
.then(({ metafile }) => {
|
||||
if (process.env.DEV === 'true') {
|
||||
writeFileSync('./dist/esbuild.json', JSON.stringify(metafile, null, 2));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('esbuild build failed:', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -12,28 +12,18 @@ import prettierConfig from 'eslint-config-prettier';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import vitest from '@vitest/eslint-plugin';
|
||||
import globals from 'globals';
|
||||
import licenseHeader from 'eslint-plugin-license-header';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
|
||||
// --- ESM way to get __dirname ---
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// --- ---
|
||||
|
||||
// Determine the monorepo root (assuming eslint.config.js is at the root)
|
||||
const projectRoot = __dirname;
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
// Global ignores
|
||||
ignores: [
|
||||
'node_modules/*',
|
||||
'eslint.config.js',
|
||||
'packages/**/dist/**',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
'.integration-tests/**',
|
||||
'packages/**/.integration-test/**',
|
||||
'dist/**',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
@@ -85,6 +75,8 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// We use TypeScript for React components; prop-types are unnecessary
|
||||
'react/prop-types': 'off',
|
||||
// General Best Practice Rules (subset adapted for flat config)
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
|
||||
'arrow-body-style': ['error', 'as-needed'],
|
||||
@@ -121,10 +113,14 @@ export default tseslint.config(
|
||||
{
|
||||
allow: [
|
||||
'react-dom/test-utils',
|
||||
'react-dom/client',
|
||||
'memfs/lib/volume.js',
|
||||
'yargs/**',
|
||||
'msw/node',
|
||||
'**/generated/**'
|
||||
'**/generated/**',
|
||||
'./styles/tailwind.css',
|
||||
'./styles/App.css',
|
||||
'./styles/style.css'
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -161,7 +157,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'],
|
||||
plugins: {
|
||||
vitest,
|
||||
},
|
||||
@@ -169,11 +165,19 @@ export default tseslint.config(
|
||||
...vitest.configs.recommended.rules,
|
||||
'vitest/expect-expect': 'off',
|
||||
'vitest/no-commented-out-tests': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['./scripts/**/*.js', 'esbuild.config.js'],
|
||||
files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
@@ -221,11 +225,26 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// extra settings for core package scripts
|
||||
{
|
||||
files: ['packages/core/scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Prettier config must be last
|
||||
prettierConfig,
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['./integration-tests/**/*.js'],
|
||||
files: ['./integration-tests/**/*.{js,ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
|
||||
8
hello/QWEN.md
Normal file
8
hello/QWEN.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Ink Library Screen Reader Guidance
|
||||
|
||||
When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience.
|
||||
|
||||
## General Principles
|
||||
|
||||
Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users.
|
||||
Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on <Box> and <Text> to provide semantic meaning to screen readers.
|
||||
5
hello/qwen-extension.json
Normal file
5
hello/qwen-extension.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "context-example",
|
||||
"version": "1.0.0",
|
||||
"contextFileName": "QWEN.md"
|
||||
}
|
||||
623
integration-tests/acp-integration.test.ts
Normal file
623
integration-tests/acp-integration.test.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60_000;
|
||||
const INITIAL_PROMPT = 'Create a quick note (smoke test).';
|
||||
const IS_SANDBOX =
|
||||
process.env['GEMINI_SANDBOX'] &&
|
||||
process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false';
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
type UsageMetadata = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
thoughtsTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
cachedTokens?: number | null;
|
||||
};
|
||||
|
||||
type SessionUpdateNotification = {
|
||||
sessionId?: string;
|
||||
update?: {
|
||||
sessionUpdate?: string;
|
||||
availableCommands?: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
input?: { hint: string } | null;
|
||||
}>;
|
||||
content?: {
|
||||
type: string;
|
||||
text?: string;
|
||||
};
|
||||
modeId?: string;
|
||||
_meta?: {
|
||||
usage?: UsageMetadata;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PermissionRequest = {
|
||||
id: number;
|
||||
sessionId?: string;
|
||||
toolCall?: {
|
||||
toolCallId: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
content?: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
path?: string;
|
||||
oldText?: string;
|
||||
newText?: string;
|
||||
}>;
|
||||
};
|
||||
options?: Array<{
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type PermissionHandler = (
|
||||
request: PermissionRequest,
|
||||
) => { optionId: string } | { outcome: 'cancelled' };
|
||||
|
||||
/**
|
||||
* Sets up an ACP test environment with all necessary utilities.
|
||||
*/
|
||||
function setupAcpTest(
|
||||
rig: TestRig,
|
||||
options?: { permissionHandler?: PermissionHandler },
|
||||
) {
|
||||
const pending = new Map<number, PendingRequest>();
|
||||
let nextRequestId = 1;
|
||||
const sessionUpdates: SessionUpdateNotification[] = [];
|
||||
const permissionRequests: PermissionRequest[] = [];
|
||||
const stderr: string[] = [];
|
||||
|
||||
// Default permission handler: auto-approve all
|
||||
const permissionHandler =
|
||||
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
|
||||
|
||||
const agent = spawn(
|
||||
'node',
|
||||
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
|
||||
{
|
||||
cwd: rig.testDir!,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
agent.stderr?.on('data', (chunk) => {
|
||||
stderr.push(chunk.toString());
|
||||
});
|
||||
|
||||
const rl = createInterface({ input: agent.stdout });
|
||||
|
||||
const send = (json: unknown) => {
|
||||
agent.stdin.write(`${JSON.stringify(json)}\n`);
|
||||
};
|
||||
|
||||
const sendResponse = (id: number, result: unknown) => {
|
||||
send({ jsonrpc: '2.0', id, result });
|
||||
};
|
||||
|
||||
const sendRequest = (method: string, params?: unknown) =>
|
||||
new Promise<unknown>((resolve, reject) => {
|
||||
const id = nextRequestId++;
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`Request ${id} (${method}) timed out`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
pending.set(id, { resolve, reject, timeout });
|
||||
send({ jsonrpc: '2.0', id, method, params });
|
||||
});
|
||||
|
||||
const handleResponse = (msg: {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
}) => {
|
||||
const waiter = pending.get(msg.id);
|
||||
if (!waiter) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(waiter.timeout);
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) {
|
||||
waiter.reject(new Error(msg.error.message ?? 'Unknown error'));
|
||||
} else {
|
||||
waiter.resolve(msg.result);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = (msg: {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: SessionUpdateNotification & {
|
||||
path?: string;
|
||||
content?: string;
|
||||
sessionId?: string;
|
||||
toolCall?: PermissionRequest['toolCall'];
|
||||
options?: PermissionRequest['options'];
|
||||
};
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
}) => {
|
||||
if (typeof msg.id !== 'undefined' && ('result' in msg || 'error' in msg)) {
|
||||
handleResponse(
|
||||
msg as {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === 'session/update') {
|
||||
sessionUpdates.push({
|
||||
sessionId: msg.params?.sessionId,
|
||||
update: msg.params?.update,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
msg.method === 'session/request_permission' &&
|
||||
typeof msg.id === 'number'
|
||||
) {
|
||||
// Track permission request
|
||||
const permRequest: PermissionRequest = {
|
||||
id: msg.id,
|
||||
sessionId: msg.params?.sessionId,
|
||||
toolCall: msg.params?.toolCall,
|
||||
options: msg.params?.options,
|
||||
};
|
||||
permissionRequests.push(permRequest);
|
||||
|
||||
// Use custom handler or default
|
||||
const response = permissionHandler(permRequest);
|
||||
if ('outcome' in response) {
|
||||
sendResponse(msg.id, { outcome: response });
|
||||
} else {
|
||||
sendResponse(msg.id, {
|
||||
outcome: { optionId: response.optionId, outcome: 'selected' },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === 'fs/read_text_file' && typeof msg.id === 'number') {
|
||||
try {
|
||||
const content = readFileSync(msg.params?.path ?? '', 'utf8');
|
||||
sendResponse(msg.id, { content });
|
||||
} catch (e) {
|
||||
sendResponse(msg.id, { content: `ERROR: ${(e as Error).message}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === 'fs/write_text_file' && typeof msg.id === 'number') {
|
||||
try {
|
||||
writeFileSync(
|
||||
msg.params?.path ?? '',
|
||||
msg.params?.content ?? '',
|
||||
'utf8',
|
||||
);
|
||||
sendResponse(msg.id, null);
|
||||
} catch (e) {
|
||||
sendResponse(msg.id, { message: (e as Error).message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rl.on('line', (line) => {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
handleMessage(msg);
|
||||
} catch {
|
||||
// Ignore non-JSON output from the agent.
|
||||
}
|
||||
});
|
||||
|
||||
const waitForExit = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (agent.exitCode !== null || agent.signalCode) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
agent.once('exit', () => resolve());
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
rl.close();
|
||||
agent.kill();
|
||||
pending.forEach(({ timeout }) => clearTimeout(timeout));
|
||||
pending.clear();
|
||||
await waitForExit();
|
||||
};
|
||||
|
||||
return {
|
||||
sendRequest,
|
||||
sendResponse,
|
||||
cleanup,
|
||||
stderr,
|
||||
sessionUpdates,
|
||||
permissionRequests,
|
||||
};
|
||||
}
|
||||
|
||||
(IS_SANDBOX ? describe.skip : describe)('acp integration', () => {
|
||||
it('basic smoke test', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp load session');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
const initResult = await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
expect(initResult).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((initResult as any).agentInfo.version).toBeDefined();
|
||||
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
const promptResult = await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [{ type: 'text', text: INITIAL_PROMPT }],
|
||||
});
|
||||
expect(promptResult).toBeDefined();
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns modes on initialize and allows setting approval mode', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp approval mode');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
// Test 1: Initialize and verify modes are returned
|
||||
const initResult = (await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
})) as {
|
||||
protocolVersion: number;
|
||||
modes: {
|
||||
currentModeId: string;
|
||||
availableModes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(initResult).toBeDefined();
|
||||
expect(initResult.protocolVersion).toBe(1);
|
||||
|
||||
// Verify modes data is present
|
||||
expect(initResult.modes).toBeDefined();
|
||||
expect(initResult.modes.currentModeId).toBeDefined();
|
||||
expect(Array.isArray(initResult.modes.availableModes)).toBe(true);
|
||||
expect(initResult.modes.availableModes.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify available modes have expected structure
|
||||
const modeIds = initResult.modes.availableModes.map((m) => m.id);
|
||||
expect(modeIds).toContain('default');
|
||||
expect(modeIds).toContain('yolo');
|
||||
expect(modeIds).toContain('auto-edit');
|
||||
expect(modeIds).toContain('plan');
|
||||
|
||||
// Verify each mode has required fields
|
||||
for (const mode of initResult.modes.availableModes) {
|
||||
expect(mode.id).toBeTruthy();
|
||||
expect(mode.name).toBeTruthy();
|
||||
expect(mode.description).toBeTruthy();
|
||||
}
|
||||
|
||||
// Test 2: Authenticate
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
// Test 3: Create a new session
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
// Test 4: Set approval mode to 'yolo'
|
||||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'yolo',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult).toBeDefined();
|
||||
expect(setModeResult.modeId).toBe('yolo');
|
||||
|
||||
// Test 5: Set approval mode to 'auto-edit'
|
||||
const setModeResult2 = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'auto-edit',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult2).toBeDefined();
|
||||
expect(setModeResult2.modeId).toBe('auto-edit');
|
||||
|
||||
// Test 6: Set approval mode back to 'default'
|
||||
const setModeResult3 = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'default',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult3).toBeDefined();
|
||||
expect(setModeResult3.modeId).toBe('default');
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('receives available_commands_update with slash commands after session creation', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp slash commands');
|
||||
|
||||
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
// Initialize
|
||||
await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
// Create a new session
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
// Wait for available_commands_update to be received
|
||||
await delay(1000);
|
||||
|
||||
// Verify available_commands_update is received
|
||||
const commandsUpdate = sessionUpdates.find(
|
||||
(update) =>
|
||||
update.update?.sessionUpdate === 'available_commands_update',
|
||||
);
|
||||
|
||||
expect(commandsUpdate).toBeDefined();
|
||||
expect(commandsUpdate?.update?.availableCommands).toBeDefined();
|
||||
expect(Array.isArray(commandsUpdate?.update?.availableCommands)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Verify that the 'init' command is present (the only allowed built-in command for ACP)
|
||||
const initCommand = commandsUpdate?.update?.availableCommands?.find(
|
||||
(cmd) => cmd.name === 'init',
|
||||
);
|
||||
expect(initCommand).toBeDefined();
|
||||
expect(initCommand?.description).toBeTruthy();
|
||||
|
||||
// Note: We don't test /init execution here because it triggers a complex
|
||||
// multi-step process (listing files, reading up to 10 files, generating QWEN.md)
|
||||
// that can take 30-60+ seconds, exceeding the request timeout.
|
||||
// The slash command execution path is tested via simpler prompts in other tests.
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('handles exit plan mode with permission request and mode update notification', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp exit plan mode');
|
||||
|
||||
// Track which permission requests we've seen
|
||||
const planModeRequests: PermissionRequest[] = [];
|
||||
|
||||
const { sendRequest, cleanup, stderr, sessionUpdates, permissionRequests } =
|
||||
setupAcpTest(rig, {
|
||||
permissionHandler: (request) => {
|
||||
// Track all permission requests for later verification
|
||||
// Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode
|
||||
if (request.toolCall?.kind === 'switch_mode') {
|
||||
planModeRequests.push(request);
|
||||
// Return proceed_always to switch to auto-edit mode
|
||||
return { optionId: 'proceed_always' };
|
||||
}
|
||||
// Auto-approve all other requests
|
||||
return { optionId: 'proceed_once' };
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Initialize
|
||||
await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
// Create a new session
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
// Set mode to 'plan' to enable plan mode
|
||||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'plan',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult.modeId).toBe('plan');
|
||||
|
||||
// Send a prompt that should trigger the LLM to call exit_plan_mode
|
||||
// The prompt is designed to trigger planning behavior
|
||||
const promptResult = await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Create a simple hello world function in Python. Make a brief plan and when ready, use the exit_plan_mode tool to present it for approval.',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(promptResult).toBeDefined();
|
||||
|
||||
// Give time for all notifications to be processed
|
||||
await delay(1000);
|
||||
|
||||
// Verify: If exit_plan_mode was called, we should have received:
|
||||
// 1. A permission request with kind: "switch_mode"
|
||||
// 2. A current_mode_update notification after approval
|
||||
|
||||
// Check for switch_mode permission requests
|
||||
const switchModeRequests = permissionRequests.filter(
|
||||
(req) => req.toolCall?.kind === 'switch_mode',
|
||||
);
|
||||
|
||||
// Check for current_mode_update notifications
|
||||
const modeUpdateNotifications = sessionUpdates.filter(
|
||||
(update) => update.update?.sessionUpdate === 'current_mode_update',
|
||||
);
|
||||
|
||||
// If the LLM called exit_plan_mode, verify the flow
|
||||
if (switchModeRequests.length > 0) {
|
||||
// Verify permission request structure
|
||||
const permReq = switchModeRequests[0];
|
||||
expect(permReq.toolCall).toBeDefined();
|
||||
expect(permReq.toolCall?.kind).toBe('switch_mode');
|
||||
expect(permReq.toolCall?.status).toBe('pending');
|
||||
expect(permReq.options).toBeDefined();
|
||||
expect(Array.isArray(permReq.options)).toBe(true);
|
||||
|
||||
// Verify options include appropriate choices
|
||||
const optionKinds = permReq.options?.map((opt) => opt.kind) ?? [];
|
||||
expect(optionKinds).toContain('allow_once');
|
||||
expect(optionKinds).toContain('allow_always');
|
||||
|
||||
// After approval, should have received current_mode_update
|
||||
expect(modeUpdateNotifications.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify mode update structure
|
||||
const modeUpdate = modeUpdateNotifications[0];
|
||||
expect(modeUpdate.sessionId).toBe(newSession.sessionId);
|
||||
expect(modeUpdate.update?.modeId).toBeDefined();
|
||||
// Mode should be auto-edit since we approved with proceed_always
|
||||
expect(modeUpdate.update?.modeId).toBe('auto-edit');
|
||||
}
|
||||
|
||||
// Note: If the LLM didn't call exit_plan_mode, that's acceptable
|
||||
// since LLM behavior is non-deterministic. The test setup and structure
|
||||
// is verified regardless.
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('receives usage metadata in agent_message_chunk updates', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp usage metadata');
|
||||
|
||||
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
|
||||
await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [{ type: 'text', text: 'Say "hello".' }],
|
||||
});
|
||||
|
||||
await delay(500);
|
||||
|
||||
// Find updates with usage metadata
|
||||
const updatesWithUsage = sessionUpdates.filter(
|
||||
(u) =>
|
||||
u.update?.sessionUpdate === 'agent_message_chunk' &&
|
||||
u.update?._meta?.usage,
|
||||
);
|
||||
|
||||
expect(updatesWithUsage.length).toBeGreaterThan(0);
|
||||
|
||||
const usage = updatesWithUsage[0].update?._meta?.usage;
|
||||
expect(usage).toBeDefined();
|
||||
expect(
|
||||
typeof usage?.promptTokens === 'number' ||
|
||||
typeof usage?.totalTokens === 'number',
|
||||
).toBe(true);
|
||||
} catch (e) {
|
||||
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
108
integration-tests/context-compress-interactive.test.ts
Normal file
108
integration-tests/context-compress-interactive.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, type } from './test-helper.js';
|
||||
|
||||
describe('Interactive Mode', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should trigger chat compression with /compress command',
|
||||
async () => {
|
||||
await rig.setup('interactive-compress-test', {
|
||||
settings: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: 'openai',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { ptyProcess } = rig.runInteractive();
|
||||
|
||||
let fullOutput = '';
|
||||
ptyProcess.onData((data) => (fullOutput += data));
|
||||
|
||||
// Wait for the app to be ready
|
||||
const isReady = await rig.waitForText('Type your message', 15000);
|
||||
expect(
|
||||
isReady,
|
||||
'CLI did not start up in interactive mode correctly',
|
||||
).toBe(true);
|
||||
|
||||
const longPrompt =
|
||||
'Dont do anything except returning a 1000 token long paragragh with the <name of the scientist who discovered theory of relativity> at the end to indicate end of response. This is a moderately long sentence.';
|
||||
|
||||
await type(ptyProcess, longPrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
await rig.waitForText('einstein', 25000);
|
||||
|
||||
await type(ptyProcess, '/compress');
|
||||
// A small delay to allow React to re-render the command list.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const foundEvent = await rig.waitForTelemetryEvent(
|
||||
'chat_compression',
|
||||
90000,
|
||||
);
|
||||
expect(foundEvent, 'chat_compression telemetry event was not found').toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.skip('should handle compression failure on token inflation', async () => {
|
||||
await rig.setup('interactive-compress-test', {
|
||||
settings: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: 'openai',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { ptyProcess } = rig.runInteractive();
|
||||
|
||||
let fullOutput = '';
|
||||
ptyProcess.onData((data) => (fullOutput += data));
|
||||
|
||||
// Wait for the app to be ready
|
||||
const isReady = await rig.waitForText('Type your message', 25000);
|
||||
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await type(ptyProcess, '/compress');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const foundEvent = await rig.waitForTelemetryEvent(
|
||||
'chat_compression',
|
||||
90000,
|
||||
);
|
||||
expect(foundEvent).toBe(true);
|
||||
|
||||
const compressionFailed = await rig.waitForText(
|
||||
'Nothing to compress.',
|
||||
25000,
|
||||
);
|
||||
|
||||
expect(compressionFailed).toBe(true);
|
||||
});
|
||||
});
|
||||
76
integration-tests/ctrl-c-exit.test.ts
Normal file
76
integration-tests/ctrl-c-exit.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
describe('Ctrl+C exit', () => {
|
||||
// (#9782) Temporarily disabling on windows because it is failing on main and every
|
||||
// PR, which is potentially hiding other failures
|
||||
it.skip('should exit gracefully on second Ctrl+C', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should exit gracefully on second Ctrl+C');
|
||||
|
||||
const { ptyProcess, promise } = rig.runInteractive();
|
||||
|
||||
let output = '';
|
||||
ptyProcess.onData((data) => {
|
||||
output += data;
|
||||
});
|
||||
|
||||
const isReady = await rig.waitForText('Type your message', 15000);
|
||||
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Send first Ctrl+C
|
||||
ptyProcess.write(String.fromCharCode(3));
|
||||
|
||||
// Wait for the exit prompt
|
||||
const showedExitPrompt = await rig.poll(
|
||||
() => output.includes('Press Ctrl+C again to exit'),
|
||||
1500,
|
||||
50,
|
||||
);
|
||||
expect(showedExitPrompt, `Exit prompt not shown. Output: ${output}`).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Send second Ctrl+C
|
||||
ptyProcess.write(String.fromCharCode(3));
|
||||
|
||||
// Wait for process exit with timeout to fail fast
|
||||
const EXIT_TIMEOUT = 5000;
|
||||
const result = await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Process did not exit within ${EXIT_TIMEOUT}ms. Output: ${output}`,
|
||||
),
|
||||
),
|
||||
EXIT_TIMEOUT,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
// Expect a graceful exit (code 0)
|
||||
expect(
|
||||
result.exitCode,
|
||||
`Process exited with code ${result.exitCode}. Output: ${result.output}`,
|
||||
).toBe(0);
|
||||
|
||||
// Check that the quitting message is displayed
|
||||
const quittingMessage = 'Agent powering down. Goodbye!';
|
||||
// The regex below is intentionally matching the ESC control character (\x1b)
|
||||
// to strip ANSI color codes from the terminal output.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
expect(cleanOutput).toContain(quittingMessage);
|
||||
});
|
||||
});
|
||||
@@ -61,4 +61,125 @@ describe('edit', () => {
|
||||
console.log('File edited successfully. New content:', newFileContent);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle $ literally when replacing text ending with $', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup(
|
||||
'should handle $ literally when replacing text ending with $',
|
||||
);
|
||||
|
||||
const fileName = 'regex.yml';
|
||||
const originalContent = "| select('match', '^[sv]d[a-z]$')\n";
|
||||
const expectedContent = "| select('match', '^[sv]d[a-z]$') # updated\n";
|
||||
|
||||
rig.createFile(fileName, originalContent);
|
||||
|
||||
const prompt =
|
||||
"Open regex.yml and append ' # updated' after the line containing ^[sv]d[a-z]$ without breaking the $ character.";
|
||||
|
||||
const result = await rig.run(prompt);
|
||||
const foundToolCall = await rig.waitForToolCall('edit');
|
||||
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
|
||||
expect(foundToolCall, 'Expected to find an edit tool call').toBeTruthy();
|
||||
|
||||
validateModelOutput(result, ['regex.yml'], 'Replace $ literal test');
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe(expectedContent);
|
||||
});
|
||||
|
||||
it.skip('should fail safely when old_string is not found', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should fail safely when old_string is not found');
|
||||
const fileName = 'no_match.txt';
|
||||
const fileContent = 'hello world';
|
||||
rig.createFile(fileName, fileContent);
|
||||
|
||||
const prompt = `replace "goodbye" with "farewell" in ${fileName}`;
|
||||
await rig.run(prompt);
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const editAttempt = toolLogs.find((log) => log.toolRequest.name === 'edit');
|
||||
const readAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'read_file',
|
||||
);
|
||||
|
||||
// VERIFY: The model must have at least tried to read the file or perform an edit.
|
||||
expect(
|
||||
readAttempt || editAttempt,
|
||||
'Expected model to attempt a read_file or edit',
|
||||
).toBeDefined();
|
||||
|
||||
// If the model tried to edit, that specific attempt must have failed.
|
||||
if (editAttempt) {
|
||||
if (editAttempt.toolRequest.success) {
|
||||
console.error('The edit tool succeeded when it was expected to fail');
|
||||
console.error('Tool call args:', editAttempt.toolRequest.args);
|
||||
}
|
||||
expect(
|
||||
editAttempt.toolRequest.success,
|
||||
'If edit is called, it must fail',
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// CRITICAL: The final content of the file must be unchanged.
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe(fileContent);
|
||||
});
|
||||
|
||||
it('should insert a multi-line block of text', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should insert a multi-line block of text');
|
||||
const fileName = 'insert_block.js';
|
||||
const originalContent = 'function hello() {\n // INSERT_CODE_HERE\n}';
|
||||
const newBlock = "console.log('hello');\n console.log('world');";
|
||||
const expectedContent = `function hello() {\n ${newBlock}\n}`;
|
||||
rig.createFile(fileName, originalContent);
|
||||
|
||||
const prompt = `In ${fileName}, replace "// INSERT_CODE_HERE" with:\n${newBlock}`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('edit');
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(foundToolCall, 'Expected to find an edit tool call').toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
|
||||
expect(newFileContent.replace(/\r\n/g, '\n')).toBe(
|
||||
expectedContent.replace(/\r\n/g, '\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete a block of text', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should delete a block of text');
|
||||
const fileName = 'delete_block.txt';
|
||||
const blockToDelete =
|
||||
'## DELETE THIS ##\nThis is a block of text to delete.\n## END DELETE ##';
|
||||
const originalContent = `Hello\n${blockToDelete}\nWorld`;
|
||||
// When deleting the block, a newline remains from the original structure (Hello\n + \nWorld)
|
||||
rig.createFile(fileName, originalContent);
|
||||
|
||||
const prompt = `In ${fileName}, delete the entire block from "## DELETE THIS ##" to "## END DELETE ##" including the markers.`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('edit');
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(foundToolCall, 'Expected to find an edit tool call').toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
|
||||
// Accept either 1 or 2 newlines between Hello and World
|
||||
expect(newFileContent).toMatch(/^Hello\n\n?World$/);
|
||||
});
|
||||
});
|
||||
|
||||
52
integration-tests/extensions-install.test.ts
Normal file
52
integration-tests/extensions-install.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, test } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const extension = `{
|
||||
"name": "test-extension",
|
||||
"version": "0.0.1"
|
||||
}`;
|
||||
|
||||
const extensionUpdate = `{
|
||||
"name": "test-extension",
|
||||
"version": "0.0.2"
|
||||
}`;
|
||||
|
||||
test('installs a local extension, verifies a command, and updates it', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('extension install test');
|
||||
const testServerPath = join(rig.testDir!, 'qwen-extension.json');
|
||||
writeFileSync(testServerPath, extension);
|
||||
try {
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const result = await rig.runCommand(
|
||||
['extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension');
|
||||
|
||||
const listResult = await rig.runCommand(['extensions', 'list']);
|
||||
expect(listResult).toContain('test-extension');
|
||||
writeFileSync(testServerPath, extensionUpdate);
|
||||
const updateResult = await rig.runCommand([
|
||||
'extensions',
|
||||
'update',
|
||||
`test-extension`,
|
||||
]);
|
||||
expect(updateResult).toContain('0.0.2');
|
||||
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
|
||||
await rig.cleanup();
|
||||
});
|
||||
83
integration-tests/file-system-interactive.test.ts
Normal file
83
integration-tests/file-system-interactive.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, type, printDebugInfo } from './test-helper.js';
|
||||
|
||||
describe('Interactive file system', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should perform a read-then-write sequence in interactive mode',
|
||||
async () => {
|
||||
const fileName = 'version.txt';
|
||||
await rig.setup('interactive-read-then-write', {
|
||||
settings: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: 'openai',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
rig.createFile(fileName, '1.0.0');
|
||||
|
||||
const { ptyProcess } = rig.runInteractive();
|
||||
|
||||
// Wait for the app to be ready
|
||||
const isReady = await rig.waitForText('Type your message', 15000);
|
||||
expect(
|
||||
isReady,
|
||||
'CLI did not start up in interactive mode correctly',
|
||||
).toBe(true);
|
||||
|
||||
// Step 1: Read the file
|
||||
const readPrompt = `Read the version from ${fileName}`;
|
||||
await type(ptyProcess, readPrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const readCall = await rig.waitForToolCall('read_file', 30000);
|
||||
expect(readCall, 'Expected to find a read_file tool call').toBe(true);
|
||||
|
||||
const containsExpectedVersion = await rig.waitForText('1.0.0', 15000);
|
||||
expect(
|
||||
containsExpectedVersion,
|
||||
'Expected to see version "1.0.0" in output',
|
||||
).toBe(true);
|
||||
|
||||
// Step 2: Write the file
|
||||
const writePrompt = `now change the version to 1.0.1 in the file`;
|
||||
await type(ptyProcess, writePrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const toolCall = await rig.waitForAnyToolCall(
|
||||
['write_file', 'edit'],
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!toolCall) {
|
||||
printDebugInfo(rig, rig._interactiveOutput, {
|
||||
toolCall,
|
||||
});
|
||||
}
|
||||
|
||||
expect(toolCall, 'Expected to find a write_file or edit tool call').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe('1.0.1');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
describe('file-system', () => {
|
||||
@@ -86,4 +88,169 @@ describe('file-system', () => {
|
||||
console.log('File written successfully with hello message.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly handle file paths with spaces', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should correctly handle file paths with spaces');
|
||||
const fileName = 'my test file.txt';
|
||||
|
||||
const result = await rig.run(`write "hello" to "${fileName}"`);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('write_file');
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a write_file tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('should perform a read-then-write sequence', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should perform a read-then-write sequence');
|
||||
const fileName = 'version.txt';
|
||||
rig.createFile(fileName, '1.0.0');
|
||||
|
||||
const prompt = `Read the version from ${fileName} and write the next version 1.0.1 back to the file.`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const readCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'read_file',
|
||||
);
|
||||
const writeCall = toolLogs.find(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'write_file' ||
|
||||
log.toolRequest.name === 'replace',
|
||||
);
|
||||
|
||||
if (!readCall || !writeCall) {
|
||||
printDebugInfo(rig, result, { readCall, writeCall });
|
||||
}
|
||||
|
||||
expect(readCall, 'Expected to find a read_file tool call').toBeDefined();
|
||||
expect(
|
||||
writeCall,
|
||||
'Expected to find a write_file or replace tool call',
|
||||
).toBeDefined();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe('1.0.1');
|
||||
});
|
||||
|
||||
it.skip('should replace multiple instances of a string', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should replace multiple instances of a string');
|
||||
const fileName = 'ambiguous.txt';
|
||||
const fileContent = 'Hey there, \ntest line\ntest line';
|
||||
const expectedContent = 'Hey there, \nnew line\nnew line';
|
||||
rig.createFile(fileName, fileContent);
|
||||
|
||||
const result = await rig.run(
|
||||
`replace "test line" with "new line" in ${fileName}`,
|
||||
);
|
||||
|
||||
const foundToolCall = await rig.waitForAnyToolCall([
|
||||
'replace',
|
||||
'write_file',
|
||||
]);
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a replace or write_file tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const successfulEdit = toolLogs.some(
|
||||
(log) =>
|
||||
(log.toolRequest.name === 'replace' ||
|
||||
log.toolRequest.name === 'write_file') &&
|
||||
log.toolRequest.success,
|
||||
);
|
||||
if (!successfulEdit) {
|
||||
console.error(
|
||||
'Expected a successful edit tool call, but none was found.',
|
||||
);
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(successfulEdit, 'Expected a successful edit tool call').toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe(expectedContent);
|
||||
});
|
||||
|
||||
it('should fail safely when trying to edit a non-existent file', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup(
|
||||
'should fail safely when trying to edit a non-existent file',
|
||||
);
|
||||
const fileName = 'non_existent.txt';
|
||||
|
||||
const result = await rig.run(`In ${fileName}, replace "a" with "b"`);
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const readAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'read_file',
|
||||
);
|
||||
const writeAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'write_file',
|
||||
);
|
||||
const successfulReplace = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'replace' && log.toolRequest.success,
|
||||
);
|
||||
|
||||
// The model can either investigate (and fail) or do nothing.
|
||||
// If it chose to investigate by reading, that read must have failed.
|
||||
if (readAttempt && readAttempt.toolRequest.success) {
|
||||
console.error(
|
||||
'A read_file attempt succeeded for a non-existent file when it should have failed.',
|
||||
);
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
if (readAttempt) {
|
||||
expect(
|
||||
readAttempt.toolRequest.success,
|
||||
'If model tries to read the file, that attempt must fail',
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// CRITICAL: Verify that no matter what the model did, it never successfully
|
||||
// wrote or replaced anything.
|
||||
if (writeAttempt) {
|
||||
console.error(
|
||||
'A write_file attempt was made when no file should be written.',
|
||||
);
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
writeAttempt,
|
||||
'write_file should not have been called',
|
||||
).toBeUndefined();
|
||||
|
||||
if (successfulReplace) {
|
||||
console.error('A successful replace occurred when it should not have.');
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
successfulReplace,
|
||||
'A successful replace should not have occurred',
|
||||
).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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -22,7 +22,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import {
|
||||
GEMINI_CONFIG_DIR,
|
||||
QWEN_CONFIG_DIR,
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
} from '../packages/core/src/tools/memoryTool.js';
|
||||
|
||||
@@ -30,10 +30,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
const integrationTestsDir = join(rootDir, '.integration-tests');
|
||||
let runDir = ''; // Make runDir accessible in teardown
|
||||
let sdkE2eRunDir = ''; // SDK E2E test run directory
|
||||
|
||||
const memoryFilePath = join(
|
||||
os.homedir(),
|
||||
GEMINI_CONFIG_DIR,
|
||||
QWEN_CONFIG_DIR,
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
);
|
||||
let originalMemoryContent: string | null = null;
|
||||
@@ -48,14 +49,36 @@ export async function setup() {
|
||||
// File doesn't exist, which is fine.
|
||||
}
|
||||
|
||||
// Setup for CLI integration tests
|
||||
runDir = join(integrationTestsDir, `${Date.now()}`);
|
||||
await mkdir(runDir, { recursive: true });
|
||||
|
||||
// Setup for SDK E2E tests (separate directory with prefix)
|
||||
sdkE2eRunDir = join(integrationTestsDir, `sdk-e2e-${Date.now()}`);
|
||||
await mkdir(sdkE2eRunDir, { recursive: true });
|
||||
|
||||
// Clean up old test runs, but keep the latest few for debugging
|
||||
try {
|
||||
const testRuns = await readdir(integrationTestsDir);
|
||||
if (testRuns.length > 5) {
|
||||
const oldRuns = testRuns.sort().slice(0, testRuns.length - 5);
|
||||
|
||||
// Clean up old CLI integration test runs (without sdk-e2e- prefix)
|
||||
const cliTestRuns = testRuns.filter((run) => !run.startsWith('sdk-e2e-'));
|
||||
if (cliTestRuns.length > 5) {
|
||||
const oldRuns = cliTestRuns.sort().slice(0, cliTestRuns.length - 5);
|
||||
await Promise.all(
|
||||
oldRuns.map((oldRun) =>
|
||||
rm(join(integrationTestsDir, oldRun), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up old SDK E2E test runs (with sdk-e2e- prefix)
|
||||
const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-'));
|
||||
if (sdkTestRuns.length > 5) {
|
||||
const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5);
|
||||
await Promise.all(
|
||||
oldRuns.map((oldRun) =>
|
||||
rm(join(integrationTestsDir, oldRun), {
|
||||
@@ -69,24 +92,37 @@ export async function setup() {
|
||||
console.error('Error cleaning up old test runs:', e);
|
||||
}
|
||||
|
||||
// Environment variables for CLI integration tests
|
||||
process.env['INTEGRATION_TEST_FILE_DIR'] = runDir;
|
||||
process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true';
|
||||
process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log');
|
||||
|
||||
// Environment variables for SDK E2E tests
|
||||
process.env['E2E_TEST_FILE_DIR'] = sdkE2eRunDir;
|
||||
process.env['TEST_CLI_PATH'] = join(rootDir, 'dist/cli.js');
|
||||
|
||||
if (process.env['KEEP_OUTPUT']) {
|
||||
console.log(`Keeping output for test run in: ${runDir}`);
|
||||
console.log(`Keeping output for SDK E2E test run in: ${sdkE2eRunDir}`);
|
||||
}
|
||||
process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false';
|
||||
|
||||
console.log(`\nIntegration test output directory: ${runDir}`);
|
||||
console.log(`SDK E2E test output directory: ${sdkE2eRunDir}`);
|
||||
console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`);
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
// Cleanup the test run directory unless KEEP_OUTPUT is set
|
||||
// Cleanup the CLI test run directory unless KEEP_OUTPUT is set
|
||||
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
|
||||
await rm(runDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Cleanup the SDK E2E test run directory unless KEEP_OUTPUT is set
|
||||
if (process.env['KEEP_OUTPUT'] !== 'true' && sdkE2eRunDir) {
|
||||
await rm(sdkE2eRunDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (originalMemoryContent !== null) {
|
||||
await mkdir(dirname(memoryFilePath), { recursive: true });
|
||||
await writeFile(memoryFilePath, originalMemoryContent, 'utf-8');
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as net from 'node:net';
|
||||
import * as child_process from 'node:child_process';
|
||||
import { IdeClient } from '../packages/core/src/ide/ide-client.js';
|
||||
|
||||
import { TestMcpServer } from './test-mcp-server.js';
|
||||
|
||||
describe.skip('IdeClient', () => {
|
||||
it('reads port from file and connects', async () => {
|
||||
const server = new TestMcpServer();
|
||||
const port = await server.start();
|
||||
const pid = process.pid;
|
||||
const portFile = path.join(os.tmpdir(), `qwen-code-ide-server-${pid}.json`);
|
||||
fs.writeFileSync(portFile, JSON.stringify({ port }));
|
||||
process.env['QWEN_CODE_IDE_WORKSPACE_PATH'] = process.cwd();
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus()).toEqual({
|
||||
status: 'connected',
|
||||
details: undefined,
|
||||
});
|
||||
|
||||
fs.unlinkSync(portFile);
|
||||
await server.stop();
|
||||
delete process.env['QWEN_CODE_IDE_WORKSPACE_PATH'];
|
||||
});
|
||||
});
|
||||
|
||||
const getFreePort = (): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, () => {
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
server.close(() => {
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('IdeClient fallback connection logic', () => {
|
||||
let server: TestMcpServer;
|
||||
let envPort: number;
|
||||
let pid: number;
|
||||
let portFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
pid = process.pid;
|
||||
portFile = path.join(os.tmpdir(), `qwen-code-ide-server-${pid}.json`);
|
||||
server = new TestMcpServer();
|
||||
envPort = await server.start();
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = String(envPort);
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
process.env['QWEN_CODE_IDE_WORKSPACE_PATH'] = process.cwd();
|
||||
// Reset instance
|
||||
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
|
||||
undefined;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.stop();
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
delete process.env['QWEN_CODE_IDE_WORKSPACE_PATH'];
|
||||
if (fs.existsSync(portFile)) {
|
||||
fs.unlinkSync(portFile);
|
||||
}
|
||||
});
|
||||
|
||||
it('connects using env var when port file does not exist', async () => {
|
||||
// Ensure port file doesn't exist
|
||||
if (fs.existsSync(portFile)) {
|
||||
fs.unlinkSync(portFile);
|
||||
}
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus()).toEqual({
|
||||
status: 'connected',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to env var when connection with port from file fails', async () => {
|
||||
const filePort = await getFreePort();
|
||||
// Write port file with a port that is not listening
|
||||
fs.writeFileSync(portFile, JSON.stringify({ port: filePort }));
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus()).toEqual({
|
||||
status: 'connected',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('getIdeProcessId', () => {
|
||||
let child: child_process.ChildProcess;
|
||||
|
||||
afterEach(() => {
|
||||
if (child) {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return the pid of the parent process', async () => {
|
||||
// We need to spawn a child process that will run the test
|
||||
// so that we can check that getIdeProcessId returns the pid of the parent
|
||||
const parentPid = process.pid;
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
child = child_process.spawn(
|
||||
'node',
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
const { getIdeProcessId } = require('../packages/core/src/ide/process-utils.js');
|
||||
getIdeProcessId().then(pid => console.log(pid));
|
||||
`,
|
||||
],
|
||||
{
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
let out = '';
|
||||
child.stdout?.on('data', (data) => {
|
||||
out += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(out.trim());
|
||||
} else {
|
||||
reject(new Error(`Child process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(parseInt(output, 10)).toBe(parentPid);
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('IdeClient with proxy', () => {
|
||||
let mcpServer: TestMcpServer;
|
||||
let proxyServer: net.Server;
|
||||
let mcpServerPort: number;
|
||||
let proxyServerPort: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
mcpServer = new TestMcpServer();
|
||||
mcpServerPort = await mcpServer.start();
|
||||
|
||||
proxyServer = net.createServer().listen();
|
||||
proxyServerPort = (proxyServer.address() as net.AddressInfo).port;
|
||||
|
||||
vi.stubEnv('QWEN_CODE_IDE_SERVER_PORT', String(mcpServerPort));
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('QWEN_CODE_IDE_WORKSPACE_PATH', process.cwd());
|
||||
|
||||
// Reset instance
|
||||
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
|
||||
undefined;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
(await IdeClient.getInstance()).disconnect();
|
||||
await mcpServer.stop();
|
||||
proxyServer.close();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should connect to IDE server when HTTP_PROXY, HTTPS_PROXY and NO_PROXY are set', async () => {
|
||||
vi.stubEnv('HTTP_PROXY', `http://localhost:${proxyServerPort}`);
|
||||
vi.stubEnv('HTTPS_PROXY', `http://localhost:${proxyServerPort}`);
|
||||
vi.stubEnv('NO_PROXY', 'example.com,127.0.0.1,::1');
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus()).toEqual({
|
||||
status: 'connected',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
310
integration-tests/json-output.test.ts
Normal file
310
integration-tests/json-output.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
describe('JSON output', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(async () => {
|
||||
rig = new TestRig();
|
||||
await rig.setup('json-output-test');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it('should return a valid JSON array with result message containing response and stats', async () => {
|
||||
const result = await rig.run(
|
||||
'What is the capital of France?',
|
||||
'--output-format',
|
||||
'json',
|
||||
);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
// The output should be an array of messages
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the result message (should be the last message)
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(resultMessage).toHaveProperty('is_error');
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage).toHaveProperty('result');
|
||||
expect(typeof resultMessage.result).toBe('string');
|
||||
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||
|
||||
// Stats may be present if available
|
||||
if ('stats' in resultMessage) {
|
||||
expect(typeof resultMessage.stats).toBe('object');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return line-delimited JSON messages for stream-json output format', async () => {
|
||||
const result = await rig.run(
|
||||
'What is the capital of France?',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
);
|
||||
|
||||
// Stream-json output is line-delimited JSON (one JSON object per line)
|
||||
const lines = result
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
|
||||
// Parse each line as a JSON object
|
||||
const messages: unknown[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
messages.push(parsed);
|
||||
} catch (parseError) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have at least system, assistant, and result messages
|
||||
expect(messages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Find system message
|
||||
const systemMessage = messages.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'system',
|
||||
);
|
||||
expect(systemMessage).toBeDefined();
|
||||
expect(systemMessage).toHaveProperty('subtype');
|
||||
expect(systemMessage).toHaveProperty('session_id');
|
||||
|
||||
// Find assistant message
|
||||
const assistantMessage = messages.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'assistant',
|
||||
);
|
||||
expect(assistantMessage).toBeDefined();
|
||||
expect(assistantMessage).toHaveProperty('message');
|
||||
expect(assistantMessage).toHaveProperty('session_id');
|
||||
|
||||
// Find result message (should be the last message)
|
||||
const resultMessage = messages[messages.length - 1] as {
|
||||
type: string;
|
||||
is_error: boolean;
|
||||
result: string;
|
||||
};
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(
|
||||
typeof resultMessage === 'object' &&
|
||||
resultMessage !== null &&
|
||||
'type' in resultMessage &&
|
||||
resultMessage.type === 'result',
|
||||
).toBe(true);
|
||||
expect(resultMessage).toHaveProperty('is_error');
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage).toHaveProperty('result');
|
||||
expect(typeof resultMessage.result).toBe('string');
|
||||
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||
});
|
||||
|
||||
it('should include stream events when using stream-json with include-partial-messages', async () => {
|
||||
const result = await rig.run(
|
||||
'What is the capital of France?',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--include-partial-messages',
|
||||
);
|
||||
|
||||
// Stream-json output is line-delimited JSON (one JSON object per line)
|
||||
const lines = result
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
|
||||
// Parse each line as a JSON object
|
||||
const messages: unknown[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
messages.push(parsed);
|
||||
} catch (parseError) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have more messages than without include-partial-messages
|
||||
// because we're including stream events
|
||||
expect(messages.length).toBeGreaterThan(3);
|
||||
|
||||
// Find stream_event messages
|
||||
const streamEvents = messages.filter(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'stream_event',
|
||||
);
|
||||
expect(streamEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify stream event structure
|
||||
const firstStreamEvent = streamEvents[0];
|
||||
expect(firstStreamEvent).toHaveProperty('event');
|
||||
expect(firstStreamEvent).toHaveProperty('session_id');
|
||||
expect(firstStreamEvent).toHaveProperty('uuid');
|
||||
|
||||
// Check for expected stream event types
|
||||
const eventTypes = streamEvents.map((event: unknown) =>
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
'event' in event &&
|
||||
typeof event.event === 'object' &&
|
||||
event.event !== null &&
|
||||
'type' in event.event
|
||||
? event.event.type
|
||||
: null,
|
||||
);
|
||||
|
||||
// Should have message_start event
|
||||
expect(eventTypes).toContain('message_start');
|
||||
|
||||
// Should have content_block_start event
|
||||
expect(eventTypes).toContain('content_block_start');
|
||||
|
||||
// Should have content_block_delta events
|
||||
expect(eventTypes).toContain('content_block_delta');
|
||||
|
||||
// Should have content_block_stop event
|
||||
expect(eventTypes).toContain('content_block_stop');
|
||||
|
||||
// Should have message_stop event
|
||||
expect(eventTypes).toContain('message_stop');
|
||||
|
||||
// Verify that we still have the complete assistant message
|
||||
const assistantMessage = messages.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'assistant',
|
||||
);
|
||||
expect(assistantMessage).toBeDefined();
|
||||
expect(assistantMessage).toHaveProperty('message');
|
||||
|
||||
// Verify that we still have the result message
|
||||
const resultMessage = messages[messages.length - 1] as {
|
||||
type: string;
|
||||
is_error: boolean;
|
||||
result: string;
|
||||
};
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(
|
||||
typeof resultMessage === 'object' &&
|
||||
resultMessage !== null &&
|
||||
'type' in resultMessage &&
|
||||
resultMessage.type === 'result',
|
||||
).toBe(true);
|
||||
expect(resultMessage).toHaveProperty('is_error');
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage).toHaveProperty('result');
|
||||
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||
});
|
||||
|
||||
it('should return a JSON error for enforced auth mismatch before running', async () => {
|
||||
const originalOpenaiApiKey = process.env['OPENAI_API_KEY'];
|
||||
process.env['OPENAI_API_KEY'] = 'test-key';
|
||||
await rig.setup('json-output-auth-mismatch', {
|
||||
settings: {
|
||||
security: { auth: { enforcedType: 'qwen-oauth' } },
|
||||
},
|
||||
});
|
||||
|
||||
let thrown: Error | undefined;
|
||||
try {
|
||||
await rig.run('Hello', '--output-format', 'json');
|
||||
expect.fail('Expected process to exit with error');
|
||||
} catch (e) {
|
||||
thrown = e as Error;
|
||||
} finally {
|
||||
process.env['OPENAI_API_KEY'] = originalOpenaiApiKey;
|
||||
}
|
||||
|
||||
expect(thrown).toBeDefined();
|
||||
const message = (thrown as Error).message;
|
||||
|
||||
// The error JSON is written to stdout as a CLIResultMessageError
|
||||
// Extract stdout from the error message
|
||||
const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/);
|
||||
expect(
|
||||
stdoutMatch,
|
||||
'Expected to find stdout in the error message',
|
||||
).toBeTruthy();
|
||||
|
||||
const stdout = stdoutMatch![1];
|
||||
let parsed: unknown[];
|
||||
try {
|
||||
// Parse the JSON array from stdout
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse the following JSON:', stdout);
|
||||
throw new Error(
|
||||
`Test failed: Could not parse JSON from stdout. Details: ${parseError}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The output should be an array of messages
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the result message with error
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result' &&
|
||||
'is_error' in msg &&
|
||||
msg.is_error === true,
|
||||
) as {
|
||||
type: string;
|
||||
is_error: boolean;
|
||||
subtype: string;
|
||||
error?: { message: string; type?: string };
|
||||
};
|
||||
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(resultMessage.is_error).toBe(true);
|
||||
expect(resultMessage).toHaveProperty('subtype');
|
||||
expect(resultMessage.subtype).toBe('error_during_execution');
|
||||
expect(resultMessage).toHaveProperty('error');
|
||||
expect(resultMessage.error).toBeDefined();
|
||||
expect(resultMessage.error?.message).toContain(
|
||||
'configured auth type is qwen-oauth',
|
||||
);
|
||||
expect(resultMessage.error?.message).toContain(
|
||||
'current auth type is openai',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe('list_directory', () => {
|
||||
50, // check every 50ms
|
||||
);
|
||||
|
||||
const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`;
|
||||
const prompt = `Can you list the files in the current directory.`;
|
||||
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
|
||||
@@ -5,14 +5,26 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* This test verifies we can match maximum schema depth errors from Gemini
|
||||
* and then detect and warn about the potential tools that caused the error.
|
||||
* This test verifies we can provide MCP tools with recursive input schemas
|
||||
* (in JSON, using the $ref keyword) and both the GenAI SDK and the Gemini
|
||||
* API calls succeed. Note that prior to
|
||||
* https://github.com/googleapis/js-genai/commit/36f6350705ecafc47eaea3f3eecbcc69512edab7#diff-fdde9372aec859322b7c5a5efe467e0ad25a57210c7229724586ee90ea4f5a30
|
||||
* the Gemini API call would fail for such tools because the schema was
|
||||
* passed not as a JSON string but using the Gemini API's tool parameter
|
||||
* schema object which has stricter typing and recursion restrictions.
|
||||
* If this test fails, it's likely because either the GenAI SDK or Gemini API
|
||||
* has become more restrictive about the type of tool parameter schemas that
|
||||
* are accepted. If this occurs: Gemini CLI previously attempted to detect
|
||||
* such tools and proactively remove them from the set of tools provided in
|
||||
* the Gemini API call (as FunctionDeclaration objects). It may be appropriate
|
||||
* to resurrect that behavior but note that it's difficult to keep the
|
||||
* GCLI filters in sync with the Gemini API restrictions and behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, expect } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
// Create a minimal MCP server that doesn't require external dependencies
|
||||
// This implements the MCP protocol directly using Node.js built-ins
|
||||
@@ -180,15 +192,16 @@ describe('mcp server with cyclic tool schema is detected', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should error and suggest disabling the cyclic tool', async () => {
|
||||
// Just run any command to trigger the schema depth error.
|
||||
// If this test starts failing, check `isSchemaDepthError` from
|
||||
// geminiChat.ts to see if it needs to be updated.
|
||||
// Or, possibly it could mean that gemini has fixed the issue.
|
||||
const output = await rig.run('hello');
|
||||
it('mcp tool with cyclic schema should be accessible', async () => {
|
||||
const mcp_list_output = await rig.runCommand(['mcp', 'list']);
|
||||
|
||||
expect(output).toMatch(
|
||||
/Skipping tool 'tool_with_cyclic_schema' from MCP server 'cyclic-schema-server' because it has missing types in its parameter schema/,
|
||||
);
|
||||
// Verify the cyclic schema server is configured
|
||||
expect(mcp_list_output).toContain('cyclic-schema-server');
|
||||
});
|
||||
|
||||
it('gemini api call should be successful with cyclic mcp tool schema', async () => {
|
||||
// Run any command and verify that we get a non-error response from
|
||||
// the Gemini API.
|
||||
await rig.run('hello');
|
||||
});
|
||||
});
|
||||
|
||||
62
integration-tests/mixed-input-crash.test.ts
Normal file
62
integration-tests/mixed-input-crash.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
describe('mixed input crash prevention', () => {
|
||||
it('should not crash when using mixed prompt inputs', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('should not crash when using mixed prompt inputs');
|
||||
|
||||
// Test: echo "say '1'." | gemini --prompt-interactive="say '2'." say '3'.
|
||||
const stdinContent = "say '1'.";
|
||||
|
||||
try {
|
||||
await rig.run(
|
||||
{ stdin: stdinContent },
|
||||
'--prompt-interactive',
|
||||
"say '2'.",
|
||||
"say '3'.",
|
||||
);
|
||||
throw new Error('Expected the command to fail, but it succeeded');
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const err = error as Error;
|
||||
|
||||
expect(err.message).toContain('Process exited with code 1');
|
||||
expect(err.message).toContain(
|
||||
'--prompt-interactive flag cannot be used when input is piped',
|
||||
);
|
||||
expect(err.message).not.toContain('setRawMode is not a function');
|
||||
expect(err.message).not.toContain('unexpected critical error');
|
||||
}
|
||||
|
||||
const lastRequest = rig.readLastApiRequest();
|
||||
expect(lastRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should provide clear error message for mixed input', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('should provide clear error message for mixed input');
|
||||
|
||||
try {
|
||||
await rig.run(
|
||||
{ stdin: 'test input' },
|
||||
'--prompt-interactive',
|
||||
'test prompt',
|
||||
);
|
||||
throw new Error('Expected the command to fail, but it succeeded');
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const err = error as Error;
|
||||
|
||||
expect(err.message).toContain(
|
||||
'--prompt-interactive flag cannot be used when input is piped',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ describe('read_many_files', () => {
|
||||
rig.createFile('file1.txt', 'file 1 content');
|
||||
rig.createFile('file2.txt', 'file 2 content');
|
||||
|
||||
const prompt = `Please use read_many_files to read file1.txt and file2.txt and show me what's in them`;
|
||||
const prompt = `Use the read_many_files tool to read the contents of file1.txt and file2.txt and then print the contents of each file.`;
|
||||
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
@@ -41,11 +41,7 @@ describe('read_many_files', () => {
|
||||
'Expected to find either read_many_files or multiple read_file tool calls',
|
||||
).toBeTruthy();
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(
|
||||
result,
|
||||
['file 1 content', 'file 2 content'],
|
||||
'Read many files test',
|
||||
);
|
||||
// Validate model output - will throw if no output
|
||||
validateModelOutput(result, null, 'Read many files test');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,4 +67,64 @@ describe('run_shell_command', () => {
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'test-stdin', 'Shell command stdin test');
|
||||
});
|
||||
|
||||
it('should propagate environment variables to the child process', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should propagate environment variables');
|
||||
|
||||
const varName = 'GEMINI_CLI_TEST_VAR';
|
||||
const varValue = `test-value-${Math.random().toString(36).substring(7)}`;
|
||||
process.env[varName] = varValue;
|
||||
|
||||
try {
|
||||
const prompt = `Use echo to learn the value of the environment variable named ${varName} and tell me what it is.`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command');
|
||||
|
||||
if (!foundToolCall || !result.includes(varValue)) {
|
||||
printDebugInfo(rig, result, {
|
||||
'Found tool call': foundToolCall,
|
||||
'Contains varValue': result.includes(varValue),
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
validateModelOutput(result, varValue, 'Env var propagation test');
|
||||
expect(result).toContain(varValue);
|
||||
} finally {
|
||||
delete process.env[varName];
|
||||
}
|
||||
});
|
||||
|
||||
it('should run a platform-specific file listing command', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should run platform-specific file listing');
|
||||
const fileName = `test-file-${Math.random().toString(36).substring(7)}.txt`;
|
||||
rig.createFile(fileName, 'test content');
|
||||
|
||||
const prompt = `Run a shell command to list the files in the current directory and tell me what they are.`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command');
|
||||
|
||||
// Debugging info
|
||||
if (!foundToolCall || !result.includes(fileName)) {
|
||||
printDebugInfo(rig, result, {
|
||||
'Found tool call': foundToolCall,
|
||||
'Contains fileName': result.includes(fileName),
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
validateModelOutput(result, fileName, 'Platform-specific listing test');
|
||||
expect(result).toContain(fileName);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ import { describe, it, expect } from 'vitest';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe('save_memory', () => {
|
||||
it('should be able to save to memory', async () => {
|
||||
// Skipped due to flaky model behavior - the model sometimes answers the question
|
||||
// directly without calling the save_memory tool, even when prompted to "remember"
|
||||
it.skip('should be able to save to memory', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should be able to save to memory');
|
||||
|
||||
|
||||
486
integration-tests/sdk-typescript/abort-and-lifecycle.test.ts
Normal file
486
integration-tests/sdk-typescript/abort-and-lifecycle.test.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* E2E tests based on abort-and-lifecycle.ts example
|
||||
* Tests AbortController integration and process lifecycle management
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
AbortError,
|
||||
isAbortError,
|
||||
isSDKAssistantMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
} from '@qwen-code/sdk';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
describe('AbortController and Process Lifecycle (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('abort-and-lifecycle');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
describe('Basic AbortController Usage', () => {
|
||||
it('should support AbortController cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Abort after 5 seconds
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 5000);
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long story about TypeScript programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
|
||||
// Should receive some content before abort
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here - query should be aborted
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle abort during query execution', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let receivedFirstMessage = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
if (!receivedFirstMessage) {
|
||||
// Abort immediately after receiving first assistant message
|
||||
receivedFirstMessage = true;
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
// Should have received at least one message before abort
|
||||
expect(receivedFirstMessage).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle abort immediately after query starts', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long essay',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort immediately after query initialization
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May or may not receive messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Process Lifecycle Monitoring', () => {
|
||||
it('should handle normal process completion', async () => {
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let completedSuccessfully = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
completedSuccessfully = true;
|
||||
} catch (error) {
|
||||
// Should not throw for normal completion
|
||||
expect(false).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(completedSuccessfully).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle process cleanup after error', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello world',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 50);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Expected to potentially have errors
|
||||
} finally {
|
||||
// Should cleanup successfully even after error
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Stream Control', () => {
|
||||
it('should support endInput() method', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let receivedResponse = false;
|
||||
let endInputCalled = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message) && !endInputCalled) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock =>
|
||||
block.type === 'text',
|
||||
);
|
||||
const text = textBlocks.map((b: TextBlock) => b.text).join('');
|
||||
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
receivedResponse = true;
|
||||
|
||||
// End input after receiving first response
|
||||
q.endInput();
|
||||
endInputCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(receivedResponse).toBe(true);
|
||||
expect(endInputCalled).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Recovery', () => {
|
||||
it('should handle invalid executable path', async () => {
|
||||
try {
|
||||
const q = query({
|
||||
prompt: 'Hello world',
|
||||
options: {
|
||||
pathToQwenExecutable: '/nonexistent/path/to/cli',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Should not reach here - query() should throw immediately
|
||||
for await (const _message of q) {
|
||||
// Should not reach here
|
||||
}
|
||||
|
||||
// Should not reach here
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect((error as Error).message).toBeDefined();
|
||||
expect((error as Error).message).toContain(
|
||||
'Invalid pathToQwenExecutable',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw AbortError with correct properties', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Explain the concept of async programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort after allowing query to start
|
||||
setTimeout(() => controller.abort(), 1000);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May receive some messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
// Verify error type and helper functions
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBeDefined();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Debugging with stderr callback', () => {
|
||||
it('should capture stderr messages when debug is enabled', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 50);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not capture stderr when debug is disabled', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
// Should have minimal or no stderr output when debug is false
|
||||
expect(stderrMessages.length).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Abort with Cleanup', () => {
|
||||
it('should cleanup properly after abort', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long essay about programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort immediately
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May receive some messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AbortError) {
|
||||
expect(true).toBe(true); // Expected abort error
|
||||
} else {
|
||||
throw error; // Unexpected error
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed after abort
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple abort calls gracefully', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Count to 100',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Multiple abort calls
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
setTimeout(() => controller.abort(), 200);
|
||||
setTimeout(() => controller.abort(), 300);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Should be interrupted
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Management Edge Cases', () => {
|
||||
it('should handle close() called multiple times', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start the query
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
// Close multiple times
|
||||
await q.close();
|
||||
await q.close();
|
||||
await q.close();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle abort after close', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start and close immediately
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
await q.close();
|
||||
|
||||
// Abort after close
|
||||
controller.abort();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
636
integration-tests/sdk-typescript/configuration-options.test.ts
Normal file
636
integration-tests/sdk-typescript/configuration-options.test.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for SDK configuration options:
|
||||
* - logLevel: Controls SDK internal logging verbosity
|
||||
* - env: Environment variables passed to CLI process
|
||||
* - authType: Authentication type for AI service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
type SDKMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
createSharedTestOptions,
|
||||
assertSuccessfulCompletion,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
describe('Configuration Options (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('configuration-options');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('logLevel Option', () => {
|
||||
it('should respect logLevel: debug and capture detailed logs', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 1 + 1? Just answer the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
logLevel: 'debug',
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Debug level should produce verbose logging
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// Debug logs should contain detailed information like [DEBUG]
|
||||
const hasDebugLogs = stderrMessages.some(
|
||||
(msg) => msg.includes('[DEBUG]') || msg.includes('debug'),
|
||||
);
|
||||
expect(hasDebugLogs).toBe(true);
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect logLevel: info and filter out debug messages', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2? Just answer the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
logLevel: 'info',
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Info level should filter out debug messages
|
||||
// Check that we don't have [DEBUG] level messages from the SDK logger
|
||||
const sdkDebugLogs = stderrMessages.filter(
|
||||
(msg) =>
|
||||
msg.includes('[DEBUG]') && msg.includes('[ProcessTransport]'),
|
||||
);
|
||||
expect(sdkDebugLogs.length).toBe(0);
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect logLevel: warn and only show warnings and errors', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
logLevel: 'warn',
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Warn level should filter out info and debug messages from SDK
|
||||
const sdkInfoOrDebugLogs = stderrMessages.filter(
|
||||
(msg) =>
|
||||
(msg.includes('[DEBUG]') || msg.includes('[INFO]')) &&
|
||||
(msg.includes('[ProcessTransport]') ||
|
||||
msg.includes('[createQuery]') ||
|
||||
msg.includes('[Query]')),
|
||||
);
|
||||
expect(sdkInfoOrDebugLogs.length).toBe(0);
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect logLevel: error and only show error messages', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello world',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
logLevel: 'error',
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Error level should filter out all non-error messages from SDK
|
||||
const sdkNonErrorLogs = stderrMessages.filter(
|
||||
(msg) =>
|
||||
(msg.includes('[DEBUG]') ||
|
||||
msg.includes('[INFO]') ||
|
||||
msg.includes('[WARN]')) &&
|
||||
(msg.includes('[ProcessTransport]') ||
|
||||
msg.includes('[createQuery]') ||
|
||||
msg.includes('[Query]')),
|
||||
);
|
||||
expect(sdkNonErrorLogs.length).toBe(0);
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use logLevel over debug flag when both are provided', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 3 + 3?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: true, // Would normally enable debug logging
|
||||
logLevel: 'error', // But logLevel should take precedence
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
|
||||
// logLevel: error should suppress debug/info/warn even with debug: true
|
||||
const sdkNonErrorLogs = stderrMessages.filter(
|
||||
(msg) =>
|
||||
(msg.includes('[DEBUG]') ||
|
||||
msg.includes('[INFO]') ||
|
||||
msg.includes('[WARN]')) &&
|
||||
(msg.includes('[ProcessTransport]') ||
|
||||
msg.includes('[createQuery]') ||
|
||||
msg.includes('[Query]')),
|
||||
);
|
||||
expect(sdkNonErrorLogs.length).toBe(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('env Option', () => {
|
||||
it('should pass custom environment variables to CLI process', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 1 + 1? Just the number please.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
env: {
|
||||
CUSTOM_TEST_VAR: 'test_value_12345',
|
||||
ANOTHER_VAR: 'another_value',
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// The query should complete successfully with custom env vars
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow overriding existing environment variables', async () => {
|
||||
// Store original value for comparison
|
||||
const originalPath = process.env['PATH'];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
env: {
|
||||
// Override an existing env var (not PATH as it might break things)
|
||||
MY_TEST_OVERRIDE: 'overridden_value',
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Query should complete successfully
|
||||
assertSuccessfulCompletion(messages);
|
||||
|
||||
// Verify original process env is not modified
|
||||
expect(process.env['PATH']).toBe(originalPath);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with empty env object', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
env: {},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should support setting model-related environment variables', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
env: {
|
||||
// Common model-related env vars that CLI might respect
|
||||
OPENAI_API_KEY: process.env['OPENAI_API_KEY'] || 'test-key',
|
||||
},
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Should complete (may succeed or fail based on API key validity)
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not leak env vars between query instances', async () => {
|
||||
// First query with specific env var
|
||||
const q1 = query({
|
||||
prompt: 'Say one',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
env: {
|
||||
ISOLATED_VAR_1: 'value_1',
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q1) {
|
||||
// Consume messages
|
||||
}
|
||||
} finally {
|
||||
await q1.close();
|
||||
}
|
||||
|
||||
// Second query with different env var
|
||||
const q2 = query({
|
||||
prompt: 'Say two',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
env: {
|
||||
ISOLATED_VAR_2: 'value_2',
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q2) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Second query should complete successfully
|
||||
assertSuccessfulCompletion(messages);
|
||||
|
||||
// Verify process.env is not polluted by either query
|
||||
expect(process.env['ISOLATED_VAR_1']).toBeUndefined();
|
||||
expect(process.env['ISOLATED_VAR_2']).toBeUndefined();
|
||||
} finally {
|
||||
await q2.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('authType Option', () => {
|
||||
it('should accept authType: openai', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 1 + 1? Just the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
authType: 'openai',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Query should complete with openai auth type
|
||||
assertSuccessfulCompletion(messages);
|
||||
|
||||
// Verify we got an assistant response
|
||||
const assistantMessages = messages.filter(isSDKAssistantMessage);
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Skip - qwen-oauth requires user interaction which is not possible in CI environments
|
||||
it.skip('should accept authType: qwen-oauth', async () => {
|
||||
// Note: qwen-oauth requires credentials in ~/.qwen and user interaction
|
||||
// Without credentials, the auth process will timeout waiting for user
|
||||
// This test verifies the option is accepted and passed correctly to CLI
|
||||
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
authType: 'qwen-oauth',
|
||||
debug: true,
|
||||
logLevel: 'debug',
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
// Use a timeout to avoid hanging when credentials are not configured
|
||||
const timeoutPromise = new Promise<'timeout'>((resolve) =>
|
||||
setTimeout(() => resolve('timeout'), 20000),
|
||||
);
|
||||
|
||||
const collectMessages = async () => {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
return 'completed';
|
||||
};
|
||||
|
||||
const result = await Promise.race([collectMessages(), timeoutPromise]);
|
||||
|
||||
if (result === 'timeout') {
|
||||
// Timeout is expected when OAuth credentials are not configured
|
||||
// Verify that CLI was spawned with correct --auth-type argument
|
||||
const hasAuthTypeArg = stderrMessages.some((msg) =>
|
||||
msg.includes('--auth-type'),
|
||||
);
|
||||
expect(hasAuthTypeArg).toBe(true);
|
||||
} else {
|
||||
// If credentials exist and auth completed, verify we got messages
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use default auth when authType is not specified', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2? Just the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
// authType not specified - should use default
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Query should complete with default auth
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should properly pass authType to CLI process', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hi',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
authType: 'openai',
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// There should be spawn log containing auth-type
|
||||
const hasAuthTypeArg = stderrMessages.some((msg) =>
|
||||
msg.includes('--auth-type'),
|
||||
);
|
||||
expect(hasAuthTypeArg).toBe(true);
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combined Options', () => {
|
||||
it('should work with logLevel, env, and authType together', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 3 + 3? Just the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
logLevel: 'debug',
|
||||
env: {
|
||||
COMBINED_TEST_VAR: 'combined_value',
|
||||
},
|
||||
authType: 'openai',
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// All three options should work together
|
||||
expect(stderrMessages.length).toBeGreaterThan(0); // logLevel: debug produces logs
|
||||
expect(assistantText).toMatch(/6/); // Query should work
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain system message consistency with all options', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
logLevel: 'info',
|
||||
env: {
|
||||
SYSTEM_MSG_TEST: 'test',
|
||||
},
|
||||
authType: 'openai',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Should have system init message
|
||||
const systemMessages = messages.filter(isSDKSystemMessage);
|
||||
const initMessage = systemMessages.find((m) => m.subtype === 'init');
|
||||
|
||||
expect(initMessage).toBeDefined();
|
||||
expect(initMessage!.session_id).toBeDefined();
|
||||
expect(initMessage!.tools).toBeDefined();
|
||||
expect(initMessage!.permission_mode).toBeDefined();
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
405
integration-tests/sdk-typescript/mcp-server.test.ts
Normal file
405
integration-tests/sdk-typescript/mcp-server.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for MCP (Model Context Protocol) server integration via SDK
|
||||
* Tests that the SDK can properly interact with MCP servers configured in qwen-code
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
isSDKAssistantMessage,
|
||||
isSDKResultMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKUserMessage,
|
||||
type SDKMessage,
|
||||
type ToolUseBlock,
|
||||
type SDKSystemMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
createMCPServer,
|
||||
extractText,
|
||||
findToolUseBlocks,
|
||||
createSharedTestOptions,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
...createSharedTestOptions(),
|
||||
permissionMode: 'yolo' as const,
|
||||
};
|
||||
|
||||
describe('MCP Server Integration (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let serverScriptPath: string;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create isolated test environment using SDKTestHelper
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('mcp-server-integration');
|
||||
|
||||
// Create MCP server using the helper utility
|
||||
const mcpServer = await createMCPServer(helper, 'math', 'test-math-server');
|
||||
serverScriptPath = mcpServer.scriptPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup test directory
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('Basic MCP Tool Usage', () => {
|
||||
it('should use MCP add tool to add two numbers', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the add tool to calculate 5 + 10. Just give me the result.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'add');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains expected answer
|
||||
expect(assistantText).toMatch(/15/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
if (isSDKResultMessage(lastMessage)) {
|
||||
expect(lastMessage.subtype).toBe('success');
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use MCP multiply tool to multiply two numbers', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the multiply tool to calculate 6 * 7. Just give me the result.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'multiply');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains expected answer
|
||||
expect(assistantText).toMatch(/42/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Server Discovery', () => {
|
||||
it('should list MCP servers in system init message', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let systemMessage: SDKSystemMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKSystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate MCP server is listed
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.mcp_servers).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||
|
||||
// Find our test server
|
||||
const testServer = systemMessage!.mcp_servers?.find(
|
||||
(server) => server.name === 'test-math-server',
|
||||
);
|
||||
expect(testServer).toBeDefined();
|
||||
|
||||
// Note: tools are not exposed in the mcp_servers array in system message
|
||||
// They are available through the MCP protocol but not in the init message
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex MCP Operations', () => {
|
||||
it('should chain multiple MCP tool calls', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'First use add to calculate 10 + 5, then multiply the result by 2. Give me the final answer.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message);
|
||||
toolUseBlocks.forEach((block) => {
|
||||
toolCalls.push(block.name);
|
||||
});
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate both tools were called
|
||||
expect(toolCalls).toContain('add');
|
||||
expect(toolCalls).toContain('multiply');
|
||||
|
||||
// Validate result: (10 + 5) * 2 = 30
|
||||
expect(assistantText).toMatch(/30/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple calls to the same MCP tool', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the add tool twice: first add 1 + 2, then add 3 + 4. Tell me both results.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
const addToolCalls: ToolUseBlock[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'add');
|
||||
addToolCalls.push(...toolUseBlocks);
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate add tool was called at least twice
|
||||
expect(addToolCalls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Validate results contain expected answers: 3 and 7
|
||||
expect(assistantText).toMatch(/3/);
|
||||
expect(assistantText).toMatch(/7/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Tool Message Flow', () => {
|
||||
it('should receive proper message sequence for MCP tool usage', async () => {
|
||||
const q = query({
|
||||
prompt: 'Use add to calculate 2 + 3',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messageTypes: string[] = [];
|
||||
let foundToolUse = false;
|
||||
let foundToolResult = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageTypes.push(message.type);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message);
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
expect(toolUseBlocks[0].name).toBe('add');
|
||||
expect(toolUseBlocks[0].input).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
if (isSDKUserMessage(message)) {
|
||||
const content = message.message.content;
|
||||
const contentArray = Array.isArray(content)
|
||||
? content
|
||||
: [{ type: 'text', text: content }];
|
||||
const toolResultBlock = contentArray.find(
|
||||
(block) => block.type === 'tool_result',
|
||||
);
|
||||
if (toolResultBlock) {
|
||||
foundToolResult = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate message flow
|
||||
expect(foundToolUse).toBe(true);
|
||||
expect(foundToolResult).toBe(true);
|
||||
expect(messageTypes).toContain('system');
|
||||
expect(messageTypes).toContain('assistant');
|
||||
expect(messageTypes).toContain('user');
|
||||
expect(messageTypes).toContain('result');
|
||||
|
||||
// Result should be last message
|
||||
expect(messageTypes[messageTypes.length - 1]).toBe('result');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle gracefully when MCP tool is not available', async () => {
|
||||
const q = query({
|
||||
prompt: 'Use the subtract tool to calculate 10 - 5',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Should complete without crashing
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
|
||||
// Assistant should indicate tool is not available or provide alternative
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
559
integration-tests/sdk-typescript/multi-turn.test.ts
Normal file
559
integration-tests/sdk-typescript/multi-turn.test.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* E2E tests based on multi-turn.ts example
|
||||
* Tests multi-turn conversation functionality with real CLI
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
isSDKUserMessage,
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKResultMessage,
|
||||
isSDKPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
type SDKUserMessage,
|
||||
type SDKAssistantMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
type SDKMessage,
|
||||
type ControlMessage,
|
||||
type ToolUseBlock,
|
||||
} from '@qwen-code/sdk';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
/**
|
||||
* Determine the message type using protocol type guards
|
||||
*/
|
||||
function getMessageType(message: SDKMessage | ControlMessage): string {
|
||||
if (isSDKUserMessage(message)) {
|
||||
return '🧑 USER';
|
||||
} else if (isSDKAssistantMessage(message)) {
|
||||
return '🤖 ASSISTANT';
|
||||
} else if (isSDKSystemMessage(message)) {
|
||||
return `🖥️ SYSTEM(${message.subtype})`;
|
||||
} else if (isSDKResultMessage(message)) {
|
||||
return `✅ RESULT(${message.subtype})`;
|
||||
} else if (isSDKPartialAssistantMessage(message)) {
|
||||
return '⏳ STREAM_EVENT';
|
||||
} else if (isControlRequest(message)) {
|
||||
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
|
||||
} else if (isControlResponse(message)) {
|
||||
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
|
||||
} else if (isControlCancel(message)) {
|
||||
return '🛑 CONTROL_CANCEL';
|
||||
} else {
|
||||
return '❓ UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract text from ContentBlock array
|
||||
*/
|
||||
function extractText(content: ContentBlock[]): string {
|
||||
return content
|
||||
.filter((block): block is TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('Multi-Turn Conversations (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('multi-turn');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('AsyncIterable Prompt Support', () => {
|
||||
it('should handle multi-turn conversation using AsyncIterable prompt', async () => {
|
||||
// Create multi-turn conversation generator
|
||||
async function* createMultiTurnConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 1 + 1?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 2 + 2?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 3 + 3?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
// Create multi-turn query using AsyncIterable prompt
|
||||
const q = query({
|
||||
prompt: createMultiTurnConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
const assistantMessages: SDKAssistantMessage[] = [];
|
||||
const assistantTexts: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
const text = extractText(message.message.content);
|
||||
assistantTexts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Validate content of responses
|
||||
expect(assistantTexts[0]).toMatch(/2/);
|
||||
expect(assistantTexts[1]).toMatch(/4/);
|
||||
expect(assistantTexts[2]).toMatch(/6/);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain session context across turns', async () => {
|
||||
async function* createContextualConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'Suppose we have 3 rabbits and 4 carrots. How many animals are there?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'How many animals are there? Only output the number',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createContextualConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessages: SDKAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The second response should reference the color blue
|
||||
const secondResponse = extractText(
|
||||
assistantMessages[1].message.content,
|
||||
);
|
||||
expect(secondResponse.toLowerCase()).toContain('3');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Usage in Multi-Turn', () => {
|
||||
it('should handle tool usage across multiple turns', async () => {
|
||||
async function* createToolConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Create a file named test.txt with content "hello"',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Now read the test.txt file',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createToolConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'yolo',
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let toolUseCount = 0;
|
||||
const assistantMessages: SDKAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
const hasToolUseBlock = message.message.content.some(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (hasToolUseBlock) {
|
||||
toolUseCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(toolUseCount).toBeGreaterThan(0);
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Validate second response mentions the file content
|
||||
const secondResponse = extractText(
|
||||
assistantMessages[assistantMessages.length - 1].message.content,
|
||||
);
|
||||
expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Flow and Sequencing', () => {
|
||||
it('should process messages in correct sequence', async () => {
|
||||
async function* createSequentialConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'First question: What is 1 + 1?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Second question: What is 2 + 2?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createSequentialConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messageSequence: string[] = [];
|
||||
const assistantResponses: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
const messageType = getMessageType(message);
|
||||
messageSequence.push(messageType);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const text = extractText(message.message.content);
|
||||
assistantResponses.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageSequence.length).toBeGreaterThan(0);
|
||||
expect(assistantResponses.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Should end with result
|
||||
expect(messageSequence[messageSequence.length - 1]).toContain('RESULT');
|
||||
|
||||
// Should have assistant responses
|
||||
expect(messageSequence.some((type) => type.includes('ASSISTANT'))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle conversation completion correctly', async () => {
|
||||
async function* createSimpleConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Goodbye',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createSimpleConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let completedNaturally = false;
|
||||
let messageCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageCount++;
|
||||
|
||||
if (isSDKResultMessage(message)) {
|
||||
completedNaturally = true;
|
||||
expect(message.subtype).toBe('success');
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageCount).toBeGreaterThan(0);
|
||||
expect(completedNaturally).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling in Multi-Turn', () => {
|
||||
it('should handle empty conversation gracefully', async () => {
|
||||
async function* createEmptyConversation(): AsyncIterable<SDKUserMessage> {
|
||||
// Generator that yields nothing
|
||||
/* eslint-disable no-constant-condition */
|
||||
if (false) {
|
||||
yield {} as SDKUserMessage; // Unreachable, but satisfies TypeScript
|
||||
}
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createEmptyConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Should handle empty conversation without crashing
|
||||
expect(true).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle conversation with delays', async () => {
|
||||
async function* createDelayedConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'First message',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
// Longer delay to test patience
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Second message after delay',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createDelayedConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessages: SDKAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Partial Messages in Multi-Turn', () => {
|
||||
it('should receive partial messages when includePartialMessages is enabled', async () => {
|
||||
async function* createMultiTurnConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 1 + 1?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 2 + 2?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createMultiTurnConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
includePartialMessages: true,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let partialMessageCount = 0;
|
||||
let assistantMessageCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKPartialAssistantMessage(message)) {
|
||||
partialMessageCount++;
|
||||
}
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(partialMessageCount).toBeGreaterThan(0);
|
||||
expect(assistantMessageCount).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
1249
integration-tests/sdk-typescript/permission-control.test.ts
Normal file
1249
integration-tests/sdk-typescript/permission-control.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
456
integration-tests/sdk-typescript/sdk-mcp-server.test.ts
Normal file
456
integration-tests/sdk-typescript/sdk-mcp-server.test.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for SDK-embedded MCP servers
|
||||
*
|
||||
* Tests that the SDK can create and manage MCP servers running in the SDK process
|
||||
* using the tool() and createSdkMcpServer() APIs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
query,
|
||||
tool,
|
||||
createSdkMcpServer,
|
||||
isSDKAssistantMessage,
|
||||
isSDKResultMessage,
|
||||
isSDKSystemMessage,
|
||||
type SDKMessage,
|
||||
type SDKSystemMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
findToolUseBlocks,
|
||||
createSharedTestOptions,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
...createSharedTestOptions(),
|
||||
permissionMode: 'yolo' as const,
|
||||
};
|
||||
|
||||
describe('SDK MCP Server Integration (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('sdk-mcp-server-integration');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('Basic SDK MCP Tool Usage', () => {
|
||||
it('should use SDK MCP tool to perform a simple calculation', async () => {
|
||||
// Define a simple calculator tool using the tool() API with Zod schema
|
||||
const calculatorTool = tool(
|
||||
'calculate_sum',
|
||||
'Calculate the sum of two numbers',
|
||||
z.object({
|
||||
a: z.number().describe('First number'),
|
||||
b: z.number().describe('Second number'),
|
||||
}).shape,
|
||||
async (args) => ({
|
||||
content: [{ type: 'text', text: String(args.a + args.b) }],
|
||||
}),
|
||||
);
|
||||
|
||||
// Create SDK MCP server with the tool
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-calculator',
|
||||
version: '1.0.0',
|
||||
tools: [calculatorTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the calculate_sum tool to add 25 and 17. Output the result of tool only.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
mcpServers: {
|
||||
'sdk-calculator': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'calculate_sum');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains expected answer: 25 + 17 = 42
|
||||
expect(assistantText).toMatch(/42/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
if (isSDKResultMessage(lastMessage)) {
|
||||
expect(lastMessage.subtype).toBe('success');
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use SDK MCP tool with string operations', async () => {
|
||||
// Define a string manipulation tool with Zod schema
|
||||
const stringTool = tool(
|
||||
'reverse_string',
|
||||
'Reverse a string',
|
||||
{
|
||||
text: z.string().describe('The text to reverse'),
|
||||
},
|
||||
async (args) => ({
|
||||
content: [
|
||||
{ type: 'text', text: args.text.split('').reverse().join('') },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-string-utils',
|
||||
version: '1.0.0',
|
||||
tools: [stringTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt: `Use the 'reverse_string' tool to process the word "hello world". Output the tool result only.`,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
mcpServers: {
|
||||
'sdk-string-utils': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'reverse_string');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains reversed string: "olleh"
|
||||
expect(assistantText.toLowerCase()).toMatch(/olleh/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple SDK MCP Tools', () => {
|
||||
it('should use multiple tools from the same SDK MCP server', async () => {
|
||||
// Define the Zod schema shape for two numbers
|
||||
const twoNumbersSchema = {
|
||||
a: z.number().describe('First number'),
|
||||
b: z.number().describe('Second number'),
|
||||
};
|
||||
|
||||
// Define multiple tools
|
||||
const addTool = tool(
|
||||
'sdk_add',
|
||||
'Add two numbers',
|
||||
twoNumbersSchema,
|
||||
async (args) => ({
|
||||
content: [{ type: 'text', text: String(args.a + args.b) }],
|
||||
}),
|
||||
);
|
||||
|
||||
const multiplyTool = tool(
|
||||
'sdk_multiply',
|
||||
'Multiply two numbers',
|
||||
twoNumbersSchema,
|
||||
async (args) => ({
|
||||
content: [{ type: 'text', text: String(args.a * args.b) }],
|
||||
}),
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-math',
|
||||
version: '1.0.0',
|
||||
tools: [addTool, multiplyTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'First use sdk_add to calculate 10 + 5, then use sdk_multiply to multiply the result by 3. Give me the final answer.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'sdk-math': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message);
|
||||
toolUseBlocks.forEach((block) => {
|
||||
toolCalls.push(block.name);
|
||||
});
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate both tools were called
|
||||
expect(toolCalls).toContain('sdk_add');
|
||||
expect(toolCalls).toContain('sdk_multiply');
|
||||
|
||||
// Validate result: (10 + 5) * 3 = 45
|
||||
expect(assistantText).toMatch(/45/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SDK MCP Server Discovery', () => {
|
||||
it('should list SDK MCP servers in system init message', async () => {
|
||||
// Define echo tool with Zod schema
|
||||
const echoTool = tool(
|
||||
'echo',
|
||||
'Echo a message',
|
||||
{
|
||||
message: z.string().describe('Message to echo'),
|
||||
},
|
||||
async (args) => ({
|
||||
content: [{ type: 'text', text: args.message }],
|
||||
}),
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-echo',
|
||||
version: '1.0.0',
|
||||
tools: [echoTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'sdk-echo': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let systemMessage: SDKSystemMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKSystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate MCP server is listed
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.mcp_servers).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||
|
||||
// Find our SDK MCP server
|
||||
const sdkServer = systemMessage!.mcp_servers?.find(
|
||||
(server) => server.name === 'sdk-echo',
|
||||
);
|
||||
expect(sdkServer).toBeDefined();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SDK MCP Tool Error Handling', () => {
|
||||
it('should handle tool errors gracefully', async () => {
|
||||
// Define a tool that throws an error with Zod schema
|
||||
const errorTool = tool(
|
||||
'maybe_fail',
|
||||
'A tool that may fail based on input',
|
||||
{
|
||||
shouldFail: z.boolean().describe('If true, the tool will fail'),
|
||||
},
|
||||
async (args) => {
|
||||
if (args.shouldFail) {
|
||||
throw new Error('Tool intentionally failed');
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'Success!' }] };
|
||||
},
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-error-test',
|
||||
version: '1.0.0',
|
||||
tools: [errorTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the maybe_fail tool with shouldFail set to true. Tell me what happens.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'sdk-error-test': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'maybe_fail');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool should be called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Query should complete (even with tool error)
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Async Tool Handlers', () => {
|
||||
it('should handle async tool handlers with delays', async () => {
|
||||
// Define a tool with async delay using Zod schema
|
||||
const delayedTool = tool(
|
||||
'delayed_response',
|
||||
'Returns a value after a delay',
|
||||
{
|
||||
delay: z.number().describe('Delay in milliseconds (max 100)'),
|
||||
value: z.string().describe('Value to return'),
|
||||
},
|
||||
async (args) => {
|
||||
// Cap delay at 100ms for test performance
|
||||
const actualDelay = Math.min(args.delay, 100);
|
||||
await new Promise((resolve) => setTimeout(resolve, actualDelay));
|
||||
return {
|
||||
content: [{ type: 'text', text: `Delayed result: ${args.value}` }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const serverConfig = createSdkMcpServer({
|
||||
name: 'sdk-async',
|
||||
version: '1.0.0',
|
||||
tools: [delayedTool],
|
||||
});
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the delayed_response tool with delay=50 and value="test_async". Tell me the result.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'sdk-async': serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(
|
||||
message,
|
||||
'delayed_response',
|
||||
);
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains the delayed response
|
||||
expect(assistantText.toLowerCase()).toMatch(/test_async/i);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
528
integration-tests/sdk-typescript/single-turn.test.ts
Normal file
528
integration-tests/sdk-typescript/single-turn.test.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* E2E tests for single-turn query execution
|
||||
* Tests basic query patterns with simple prompts and clear output expectations
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKResultMessage,
|
||||
isSDKPartialAssistantMessage,
|
||||
type SDKMessage,
|
||||
type SDKSystemMessage,
|
||||
type SDKAssistantMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
createSharedTestOptions,
|
||||
assertSuccessfulCompletion,
|
||||
collectMessagesByType,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
describe('Single-Turn Query (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('single-turn');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
describe('Simple Text Queries', () => {
|
||||
it('should answer basic arithmetic question', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2? Just give me the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: true,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate we got messages
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate assistant response content
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
expect(assistantText).toMatch(/4/);
|
||||
|
||||
// Validate message flow ends with success
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should answer simple factual question', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is the capital of France? One word answer.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate content
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
expect(assistantText.toLowerCase()).toContain('paris');
|
||||
|
||||
// Validate completion
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle greeting and self-description', async () => {
|
||||
const q = query({
|
||||
prompt: 'Say hello and tell me your name in one sentence.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate content contains greeting
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/);
|
||||
|
||||
// Validate message types
|
||||
const assistantMessages = collectMessagesByType(
|
||||
messages,
|
||||
isSDKAssistantMessage,
|
||||
);
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('System Initialization', () => {
|
||||
it('should receive system message with initialization info', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let systemMessage: SDKSystemMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKSystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate system message exists and has required fields
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.type).toBe('system');
|
||||
expect(systemMessage!.subtype).toBe('init');
|
||||
expect(systemMessage!.uuid).toBeDefined();
|
||||
expect(systemMessage!.session_id).toBeDefined();
|
||||
expect(systemMessage!.cwd).toBeDefined();
|
||||
expect(systemMessage!.tools).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.tools)).toBe(true);
|
||||
expect(systemMessage!.mcp_servers).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||
expect(systemMessage!.model).toBeDefined();
|
||||
expect(systemMessage!.permission_mode).toBeDefined();
|
||||
expect(systemMessage!.qwen_code_version).toBeDefined();
|
||||
|
||||
// Validate system message appears early in sequence
|
||||
const systemMessageIndex = messages.findIndex(
|
||||
(msg) => isSDKSystemMessage(msg) && msg.subtype === 'init',
|
||||
);
|
||||
expect(systemMessageIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(systemMessageIndex).toBeLessThan(3);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain session ID consistency', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let systemMessage: SDKSystemMessage | null = null;
|
||||
const sessionId = q.getSessionId();
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKSystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session IDs are consistent
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.session_id).toBeDefined();
|
||||
expect(systemMessage!.uuid).toBeDefined();
|
||||
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Flow', () => {
|
||||
it('should follow expected message sequence', async () => {
|
||||
const q = query({
|
||||
prompt: 'Say hi',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messageTypes: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageTypes.push(message.type);
|
||||
}
|
||||
|
||||
// Validate message sequence
|
||||
expect(messageTypes.length).toBeGreaterThan(0);
|
||||
expect(messageTypes).toContain('assistant');
|
||||
expect(messageTypes[messageTypes.length - 1]).toBe('result');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should complete iteration naturally', async () => {
|
||||
const q = query({
|
||||
prompt: 'Say goodbye',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let completedNaturally = false;
|
||||
let messageCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageCount++;
|
||||
|
||||
if (isSDKResultMessage(message)) {
|
||||
completedNaturally = true;
|
||||
expect(message.subtype).toBe('success');
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageCount).toBeGreaterThan(0);
|
||||
expect(completedNaturally).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Options', () => {
|
||||
it('should respect debug option and capture stderr', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
|
||||
// Debug mode should produce stderr output
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect cwd option', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 1 + 1?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let hasResponse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
hasResponse = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasResponse).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should receive partial messages when includePartialMessages is enabled', async () => {
|
||||
const q = query({
|
||||
prompt: 'Count from 1 to 5',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
includePartialMessages: true,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let partialMessageCount = 0;
|
||||
let assistantMessageCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKPartialAssistantMessage(message)) {
|
||||
partialMessageCount++;
|
||||
}
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(partialMessageCount).toBeGreaterThan(0);
|
||||
expect(assistantMessageCount).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Type Recognition', () => {
|
||||
it('should correctly identify all message types', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 5 + 5?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate type guards work correctly
|
||||
const assistantMessages = collectMessagesByType(
|
||||
messages,
|
||||
isSDKAssistantMessage,
|
||||
);
|
||||
const resultMessages = collectMessagesByType(
|
||||
messages,
|
||||
isSDKResultMessage,
|
||||
);
|
||||
const systemMessages = collectMessagesByType(
|
||||
messages,
|
||||
isSDKSystemMessage,
|
||||
);
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
expect(resultMessages.length).toBeGreaterThan(0);
|
||||
expect(systemMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate assistant message structure
|
||||
const firstAssistant = assistantMessages[0];
|
||||
expect(firstAssistant.message.content).toBeDefined();
|
||||
expect(Array.isArray(firstAssistant.message.content)).toBe(true);
|
||||
|
||||
// Validate result message structure
|
||||
const resultMessage = resultMessages[0];
|
||||
expect(resultMessage.subtype).toBe('success');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract text content from assistant messages', async () => {
|
||||
const q = query({
|
||||
prompt: 'Count from 1 to 3',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let assistantMessage: SDKAssistantMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessage).not.toBeNull();
|
||||
expect(assistantMessage!.message.content).toBeDefined();
|
||||
|
||||
// Validate content contains expected numbers
|
||||
const text = extractText(assistantMessage!.message.content);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
expect(text).toMatch(/1/);
|
||||
expect(text).toMatch(/2/);
|
||||
expect(text).toMatch(/3/);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should throw if CLI not found', async () => {
|
||||
try {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
pathToQwenExecutable: '/nonexistent/path/to/cli',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
for await (const _message of q) {
|
||||
// Should not reach here
|
||||
}
|
||||
|
||||
expect(false).toBe(true); // Should have thrown
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect((error as Error).message).toContain(
|
||||
'Invalid pathToQwenExecutable',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Management', () => {
|
||||
it('should cleanup subprocess on close()', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start and immediately close
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
// Should close without error
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed
|
||||
});
|
||||
|
||||
it('should handle close() called multiple times', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start the query
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
// Close multiple times
|
||||
await q.close();
|
||||
await q.close();
|
||||
await q.close();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
614
integration-tests/sdk-typescript/subagents.test.ts
Normal file
614
integration-tests/sdk-typescript/subagents.test.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for subagent configuration and execution
|
||||
* Tests subagent delegation and task completion
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
isSDKAssistantMessage,
|
||||
type SDKMessage,
|
||||
type SubagentConfig,
|
||||
type ContentBlock,
|
||||
type ToolUseBlock,
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
createSharedTestOptions,
|
||||
findToolUseBlocks,
|
||||
assertSuccessfulCompletion,
|
||||
findSystemMessage,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
describe('Subagents (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testWorkDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create isolated test environment using SDKTestHelper
|
||||
helper = new SDKTestHelper();
|
||||
testWorkDir = await helper.setup('subagent-tests');
|
||||
|
||||
// Create a simple test file for subagent to work with
|
||||
await helper.createFile('test.txt', 'Hello from test file\n');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup test directory
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('Subagent Configuration', () => {
|
||||
it('should accept session-level subagent configuration', async () => {
|
||||
const simpleSubagent: SubagentConfig = {
|
||||
name: 'simple-greeter',
|
||||
description: 'A simple subagent that responds to greetings',
|
||||
systemPrompt:
|
||||
'You are a friendly greeter. When given a task, respond with a cheerful greeting.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello, let simple-greeter to say hi back to me.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [simpleSubagent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate system message includes the subagent
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('simple-greeter');
|
||||
|
||||
// Validate successful completion
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept multiple subagent configurations', async () => {
|
||||
const greeterAgent: SubagentConfig = {
|
||||
name: 'greeter',
|
||||
description: 'Responds to greetings',
|
||||
systemPrompt: 'You are a friendly greeter.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const mathAgent: SubagentConfig = {
|
||||
name: 'math-helper',
|
||||
description: 'Helps with math problems',
|
||||
systemPrompt: 'You are a math expert. Solve math problems clearly.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 5 + 5?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [greeterAgent, mathAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate both subagents are registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('greeter');
|
||||
expect(systemMessage!.agents).toContain('math-helper');
|
||||
expect(systemMessage!.agents!.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle subagent with custom model config', async () => {
|
||||
const customModelAgent: SubagentConfig = {
|
||||
name: 'custom-model-agent',
|
||||
description: 'Agent with custom model configuration',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'session',
|
||||
modelConfig: {
|
||||
temp: 0.7,
|
||||
top_p: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [customModelAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate subagent is registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('custom-model-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle subagent with run config', async () => {
|
||||
const limitedAgent: SubagentConfig = {
|
||||
name: 'limited-agent',
|
||||
description: 'Agent with execution limits',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'session',
|
||||
runConfig: {
|
||||
max_turns: 5,
|
||||
max_time_minutes: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [limitedAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate subagent is registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('limited-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle subagent with specific tools', async () => {
|
||||
const toolRestrictedAgent: SubagentConfig = {
|
||||
name: 'read-only-agent',
|
||||
description: 'Agent that can only read files',
|
||||
systemPrompt:
|
||||
'You are a file reading assistant. Read files when asked.',
|
||||
level: 'session',
|
||||
tools: ['read_file', 'list_directory'],
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [toolRestrictedAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate subagent is registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('read-only-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subagent Execution', () => {
|
||||
it('should delegate task to subagent when appropriate', async () => {
|
||||
const fileReaderAgent: SubagentConfig = {
|
||||
name: 'file-reader',
|
||||
description: 'Reads and reports file contents',
|
||||
systemPrompt: `You are a file reading assistant. When given a task to read a file, use the read_file tool to read it and report its contents back. Be concise in your response.`,
|
||||
level: 'session',
|
||||
tools: ['read_file', 'list_directory'],
|
||||
};
|
||||
|
||||
const testFile = helper.getPath('test.txt');
|
||||
const q = query({
|
||||
prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [fileReaderAgent],
|
||||
debug: false,
|
||||
permissionMode: 'yolo',
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let foundTaskTool = false;
|
||||
let taskToolUseId: string | null = null;
|
||||
let foundSubagentToolCall = false;
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
// Check for task tool use in content blocks (main agent calling subagent)
|
||||
const taskToolBlocks = findToolUseBlocks(message, 'task');
|
||||
if (taskToolBlocks.length > 0) {
|
||||
foundTaskTool = true;
|
||||
taskToolUseId = taskToolBlocks[0].id;
|
||||
}
|
||||
|
||||
// Check if this message is from a subagent (has parent_tool_use_id)
|
||||
if (message.parent_tool_use_id !== null) {
|
||||
// This is a subagent message
|
||||
const subagentToolBlocks = findToolUseBlocks(message);
|
||||
if (subagentToolBlocks.length > 0) {
|
||||
foundSubagentToolCall = true;
|
||||
// Verify parent_tool_use_id matches the task tool use id
|
||||
expect(message.parent_tool_use_id).toBe(taskToolUseId);
|
||||
}
|
||||
}
|
||||
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate task tool was used (subagent delegation)
|
||||
expect(foundTaskTool).toBe(true);
|
||||
expect(taskToolUseId).not.toBeNull();
|
||||
|
||||
// Validate subagent actually made tool calls with proper parent_tool_use_id
|
||||
expect(foundSubagentToolCall).toBe(true);
|
||||
|
||||
// Validate we got a response
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate successful completion
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
}, 60000); // Increase timeout for subagent execution
|
||||
|
||||
it('should complete simple task with subagent', async () => {
|
||||
const simpleTaskAgent: SubagentConfig = {
|
||||
name: 'simple-calculator',
|
||||
description: 'Performs simple arithmetic calculations',
|
||||
systemPrompt:
|
||||
'You are a calculator. When given a math problem, solve it and provide just the answer.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Use the simple-calculator subagent to calculate 15 + 27.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [simpleTaskAgent],
|
||||
debug: false,
|
||||
permissionMode: 'yolo',
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let foundTaskTool = false;
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
// Check for task tool use (main agent delegating to subagent)
|
||||
const toolUseBlock = message.message.content.find(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use' && block.name === 'task',
|
||||
);
|
||||
if (toolUseBlock) {
|
||||
foundTaskTool = true;
|
||||
}
|
||||
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate task tool was used (subagent was called)
|
||||
expect(foundTaskTool).toBe(true);
|
||||
|
||||
// Validate we got a response
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate successful completion
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('should verify subagent execution with comprehensive parent_tool_use_id checks', async () => {
|
||||
const comprehensiveAgent: SubagentConfig = {
|
||||
name: 'comprehensive-agent',
|
||||
description: 'Agent for comprehensive testing',
|
||||
systemPrompt:
|
||||
'You are a helpful assistant. When asked to list files, use the list_directory tool.',
|
||||
level: 'session',
|
||||
tools: ['list_directory', 'read_file'],
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: `Use the comprehensive-agent subagent to list the files in ${testWorkDir}.`,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [comprehensiveAgent],
|
||||
debug: false,
|
||||
permissionMode: 'yolo',
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let taskToolUseId: string | null = null;
|
||||
const subagentToolCalls: ToolUseBlock[] = [];
|
||||
const mainAgentToolCalls: ToolUseBlock[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
// Collect all tool use blocks
|
||||
const toolUseBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
|
||||
for (const toolUse of toolUseBlocks) {
|
||||
if (toolUse.name === 'task') {
|
||||
// This is the main agent calling the subagent
|
||||
taskToolUseId = toolUse.id;
|
||||
mainAgentToolCalls.push(toolUse);
|
||||
}
|
||||
|
||||
// If this message has parent_tool_use_id, it's from a subagent
|
||||
if (message.parent_tool_use_id !== null) {
|
||||
subagentToolCalls.push(toolUse);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Criterion 1: When a subagent is called, there must be a 'task' tool being called
|
||||
expect(taskToolUseId).not.toBeNull();
|
||||
expect(mainAgentToolCalls.length).toBeGreaterThan(0);
|
||||
expect(mainAgentToolCalls.some((tc) => tc.name === 'task')).toBe(true);
|
||||
|
||||
// Criterion 2: A tool call from a subagent is identified by a non-null parent_tool_use_id
|
||||
// All subagent tool calls should have parent_tool_use_id set to the task tool's id
|
||||
expect(subagentToolCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify all subagent messages have the correct parent_tool_use_id
|
||||
const subagentMessages = messages.filter(
|
||||
(msg): msg is SDKMessage & { parent_tool_use_id: string } =>
|
||||
isSDKAssistantMessage(msg) && msg.parent_tool_use_id !== null,
|
||||
);
|
||||
|
||||
expect(subagentMessages.length).toBeGreaterThan(0);
|
||||
for (const subagentMsg of subagentMessages) {
|
||||
expect(subagentMsg.parent_tool_use_id).toBe(taskToolUseId);
|
||||
}
|
||||
|
||||
// Verify no main agent tool calls (except task) have parent_tool_use_id
|
||||
const mainAgentMessages = messages.filter(
|
||||
(msg): msg is SDKMessage =>
|
||||
isSDKAssistantMessage(msg) && msg.parent_tool_use_id === null,
|
||||
);
|
||||
|
||||
for (const mainMsg of mainAgentMessages) {
|
||||
if (isSDKAssistantMessage(mainMsg)) {
|
||||
// Main agent messages should not have parent_tool_use_id
|
||||
expect(mainMsg.parent_tool_use_id).toBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// Validate successful completion
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe('Subagent Error Handling', () => {
|
||||
it('should handle empty subagent array', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Should still work with empty agents array
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle subagent with minimal configuration', async () => {
|
||||
const minimalAgent: SubagentConfig = {
|
||||
name: 'minimal-agent',
|
||||
description: 'Minimal configuration agent',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [minimalAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate minimal agent is registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('minimal-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subagent Integration', () => {
|
||||
it('should work with other SDK options', async () => {
|
||||
const testAgent: SubagentConfig = {
|
||||
name: 'test-agent',
|
||||
description: 'Test agent for integration',
|
||||
systemPrompt: 'You are a test assistant.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [testAgent],
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
permissionMode: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate subagent works with debug mode
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('test-agent');
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain session consistency with subagents', async () => {
|
||||
const sessionAgent: SubagentConfig = {
|
||||
name: 'session-agent',
|
||||
description: 'Agent for session testing',
|
||||
systemPrompt: 'You are a session test assistant.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [sessionAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate session consistency
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.session_id).toBeDefined();
|
||||
expect(systemMessage!.uuid).toBeDefined();
|
||||
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
|
||||
expect(systemMessage!.agents).toContain('session-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
317
integration-tests/sdk-typescript/system-control.test.ts
Normal file
317
integration-tests/sdk-typescript/system-control.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* E2E tests for system controller features:
|
||||
* - setModel API for dynamic model switching
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
type SDKUserMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
/**
|
||||
* Factory function that creates a streaming input with a control point.
|
||||
* After the first message is yielded, the generator waits for a resume signal,
|
||||
* allowing the test code to call query instance methods like setModel.
|
||||
*
|
||||
* @param firstMessage - The first user message to send
|
||||
* @param secondMessage - The second user message to send after control operations
|
||||
* @returns Object containing the async generator and a resume function
|
||||
*/
|
||||
function createStreamingInputWithControlPoint(
|
||||
firstMessage: string,
|
||||
secondMessage: string,
|
||||
): {
|
||||
generator: AsyncIterable<SDKUserMessage>;
|
||||
resume: () => void;
|
||||
} {
|
||||
let resumeResolve: (() => void) | null = null;
|
||||
const resumePromise = new Promise<void>((resolve) => {
|
||||
resumeResolve = resolve;
|
||||
});
|
||||
|
||||
const generator = (async function* () {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: firstMessage,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
await resumePromise;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: secondMessage,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
})();
|
||||
|
||||
const resume = () => {
|
||||
if (resumeResolve) {
|
||||
resumeResolve();
|
||||
}
|
||||
};
|
||||
|
||||
return { generator, resume };
|
||||
}
|
||||
|
||||
describe('System Control (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('system-control');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('setModel API', () => {
|
||||
it('should change model dynamically during streaming input', async () => {
|
||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||
'Tell me the model name.',
|
||||
'Tell me the model name now again.',
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
model: 'qwen3-max',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resolvers: {
|
||||
first?: () => void;
|
||||
second?: () => void;
|
||||
} = {};
|
||||
const firstResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.first = resolve;
|
||||
});
|
||||
const secondResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.second = resolve;
|
||||
});
|
||||
|
||||
let firstResponseReceived = false;
|
||||
let secondResponseReceived = false;
|
||||
const systemMessages: Array<{ model?: string }> = [];
|
||||
|
||||
// Consume messages in a single loop
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isSDKSystemMessage(message)) {
|
||||
systemMessages.push({ model: message.model });
|
||||
}
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
} else if (!secondResponseReceived) {
|
||||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Wait for first response
|
||||
await Promise.race([
|
||||
firstResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for first response')),
|
||||
15000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(firstResponseReceived).toBe(true);
|
||||
|
||||
// Perform control operation: set model
|
||||
await q.setModel('qwen3-vl-plus');
|
||||
|
||||
// Resume the input stream
|
||||
resume();
|
||||
|
||||
// Wait for second response
|
||||
await Promise.race([
|
||||
secondResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for second response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(secondResponseReceived).toBe(true);
|
||||
|
||||
// Verify system messages - model should change from qwen3-max to qwen3-vl-plus
|
||||
expect(systemMessages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
|
||||
expect(systemMessages[1].model).toBe('qwen3-vl-plus');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple model changes in sequence', async () => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
let resumeResolve1: (() => void) | null = null;
|
||||
let resumeResolve2: (() => void) | null = null;
|
||||
const resumePromise1 = new Promise<void>((resolve) => {
|
||||
resumeResolve1 = resolve;
|
||||
});
|
||||
const resumePromise2 = new Promise<void>((resolve) => {
|
||||
resumeResolve2 = resolve;
|
||||
});
|
||||
|
||||
const generator = (async function* () {
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: { role: 'user', content: 'First message' },
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await resumePromise1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: { role: 'user', content: 'Second message' },
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await resumePromise2;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: { role: 'user', content: 'Third message' },
|
||||
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 systemMessages: Array<{ model?: string }> = [];
|
||||
let responseCount = 0;
|
||||
const resolvers: Array<() => void> = [];
|
||||
const responsePromises = [
|
||||
new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||
new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||
new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||
];
|
||||
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isSDKSystemMessage(message)) {
|
||||
systemMessages.push({ model: message.model });
|
||||
}
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
if (responseCount < resolvers.length) {
|
||||
resolvers[responseCount]?.();
|
||||
responseCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Wait for first response
|
||||
await Promise.race([
|
||||
responsePromises[0],
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout 1')), 10000),
|
||||
),
|
||||
]);
|
||||
|
||||
// First model change
|
||||
await q.setModel('qwen3-turbo');
|
||||
resumeResolve1!();
|
||||
|
||||
// Wait for second response
|
||||
await Promise.race([
|
||||
responsePromises[1],
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout 2')), 10000),
|
||||
),
|
||||
]);
|
||||
|
||||
// Second model change
|
||||
await q.setModel('qwen3-vl-plus');
|
||||
resumeResolve2!();
|
||||
|
||||
// Wait for third response
|
||||
await Promise.race([
|
||||
responsePromises[2],
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout 3')), 10000),
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify we received system messages for each model
|
||||
expect(systemMessages.length).toBeGreaterThanOrEqual(3);
|
||||
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
|
||||
expect(systemMessages[1].model).toBe('qwen3-turbo');
|
||||
expect(systemMessages[2].model).toBe('qwen3-vl-plus');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when setModel 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.setModel('qwen3-turbo')).rejects.toThrow(
|
||||
'Query is closed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
981
integration-tests/sdk-typescript/test-helper.ts
Normal file
981
integration-tests/sdk-typescript/test-helper.ts
Normal file
@@ -0,0 +1,981 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* SDK E2E Test Helper
|
||||
* Provides utilities for SDK e2e tests including test isolation,
|
||||
* file management, MCP server setup, and common test utilities.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile, readFile, rm, chmod } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKAssistantMessage,
|
||||
SDKSystemMessage,
|
||||
SDKUserMessage,
|
||||
ContentBlock,
|
||||
TextBlock,
|
||||
ToolUseBlock,
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKResultMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
|
||||
// ============================================================================
|
||||
// Core Test Helper Class
|
||||
// ============================================================================
|
||||
|
||||
export interface SDKTestHelperOptions {
|
||||
/**
|
||||
* Optional settings for .qwen/settings.json
|
||||
*/
|
||||
settings?: Record<string, unknown>;
|
||||
/**
|
||||
* Whether to create .qwen/settings.json
|
||||
*/
|
||||
createQwenConfig?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for SDK E2E tests
|
||||
* Provides isolated test environments for each test case
|
||||
*/
|
||||
export class SDKTestHelper {
|
||||
testDir: string | null = null;
|
||||
testName?: string;
|
||||
private baseDir: string;
|
||||
|
||||
constructor() {
|
||||
this.baseDir = process.env['E2E_TEST_FILE_DIR']!;
|
||||
if (!this.baseDir) {
|
||||
throw new Error('E2E_TEST_FILE_DIR environment variable not set');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup an isolated test directory for a specific test
|
||||
*/
|
||||
async setup(
|
||||
testName: string,
|
||||
options: SDKTestHelperOptions = {},
|
||||
): Promise<string> {
|
||||
this.testName = testName;
|
||||
const sanitizedName = this.sanitizeTestName(testName);
|
||||
this.testDir = join(this.baseDir, sanitizedName);
|
||||
|
||||
await mkdir(this.testDir, { recursive: true });
|
||||
|
||||
// Optionally create .qwen/settings.json for CLI configuration
|
||||
if (options.createQwenConfig !== false) {
|
||||
const qwenDir = join(this.testDir, '.qwen');
|
||||
await mkdir(qwenDir, { recursive: true });
|
||||
|
||||
const optionsSettings = options.settings ?? {};
|
||||
const generalSettings =
|
||||
typeof optionsSettings['general'] === 'object' &&
|
||||
optionsSettings['general'] !== null
|
||||
? (optionsSettings['general'] as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const settings = {
|
||||
...optionsSettings,
|
||||
telemetry: {
|
||||
enabled: false, // SDK tests don't need telemetry
|
||||
},
|
||||
general: {
|
||||
...generalSettings,
|
||||
chatRecording: false, // SDK tests don't need chat recording
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
join(qwenDir, 'settings.json'),
|
||||
JSON.stringify(settings, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
return this.testDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file in the test directory
|
||||
*/
|
||||
async createFile(fileName: string, content: string): Promise<string> {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
const filePath = join(this.testDir, fileName);
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from the test directory
|
||||
*/
|
||||
async readFile(fileName: string): Promise<string> {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
const filePath = join(this.testDir, fileName);
|
||||
return await readFile(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subdirectory in the test directory
|
||||
*/
|
||||
async mkdir(dirName: string): Promise<string> {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
const dirPath = join(this.testDir, dirName);
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists in the test directory
|
||||
*/
|
||||
fileExists(fileName: string): boolean {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
const filePath = join(this.testDir, fileName);
|
||||
return existsSync(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to a file in the test directory
|
||||
*/
|
||||
getPath(fileName: string): string {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
return join(this.testDir, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup test directory
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.testDir && process.env['KEEP_OUTPUT'] !== 'true') {
|
||||
try {
|
||||
await rm(this.testDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
if (process.env['VERBOSE'] === 'true') {
|
||||
console.warn('Cleanup warning:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize test name to create valid directory name
|
||||
*/
|
||||
private sanitizeTestName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.substring(0, 100); // Limit length
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Server Utilities
|
||||
// ============================================================================
|
||||
|
||||
export interface MCPServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export interface MCPServerResult {
|
||||
scriptPath: string;
|
||||
config: MCPServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in MCP server template: Math server with add and multiply tools
|
||||
*/
|
||||
const MCP_MATH_SERVER_SCRIPT = `#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const fs = require('fs');
|
||||
|
||||
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
|
||||
const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true';
|
||||
function debug(msg) {
|
||||
if (debugEnabled) {
|
||||
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
|
||||
}
|
||||
}
|
||||
|
||||
debug('MCP server starting...');
|
||||
|
||||
// Simple JSON-RPC implementation for MCP
|
||||
class SimpleJSONRPC {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
this.rl.on('line', (line) => {
|
||||
debug(\`Received line: \${line}\`);
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
debug(\`Parsed message: \${JSON.stringify(message)}\`);
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
debug(\`Parse error: \${e.message}\`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
const msgStr = JSON.stringify(message);
|
||||
debug(\`Sending message: \${msgStr}\`);
|
||||
process.stdout.write(msgStr + '\\n');
|
||||
}
|
||||
|
||||
async handleMessage(message) {
|
||||
if (message.method && this.handlers.has(message.method)) {
|
||||
try {
|
||||
const result = await this.handlers.get(message.method)(message.params || {});
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Method not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
on(method, handler) {
|
||||
this.handlers.set(method, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP server
|
||||
const rpc = new SimpleJSONRPC();
|
||||
|
||||
// Handle initialize
|
||||
rpc.on('initialize', async (params) => {
|
||||
debug('Handling initialize request');
|
||||
return {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'test-math-server',
|
||||
version: '1.0.0'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tools/list
|
||||
rpc.on('tools/list', async () => {
|
||||
debug('Handling tools/list request');
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add two numbers together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' }
|
||||
},
|
||||
required: ['a', 'b']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'multiply',
|
||||
description: 'Multiply two numbers together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' }
|
||||
},
|
||||
required: ['a', 'b']
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tools/call
|
||||
rpc.on('tools/call', async (params) => {
|
||||
debug(\`Handling tools/call request for tool: \${params.name}\`);
|
||||
|
||||
if (params.name === 'add') {
|
||||
const { a, b } = params.arguments;
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: String(a + b)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (params.name === 'multiply') {
|
||||
const { a, b } = params.arguments;
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: String(a * b)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Unknown tool: ' + params.name);
|
||||
});
|
||||
|
||||
// Send initialization notification
|
||||
rpc.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialized'
|
||||
});
|
||||
`;
|
||||
|
||||
/**
|
||||
* Create an MCP server script in the test directory
|
||||
* @param helper - SDKTestHelper instance
|
||||
* @param type - Type of MCP server ('math' or provide custom script)
|
||||
* @param serverName - Name of the MCP server (default: 'test-math-server')
|
||||
* @param customScript - Custom MCP server script (if type is not 'math')
|
||||
* @returns Object with scriptPath and config
|
||||
*/
|
||||
export async function createMCPServer(
|
||||
helper: SDKTestHelper,
|
||||
type: 'math' | 'custom' = 'math',
|
||||
serverName: string = 'test-math-server',
|
||||
customScript?: string,
|
||||
): Promise<MCPServerResult> {
|
||||
if (!helper.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
|
||||
const script = type === 'math' ? MCP_MATH_SERVER_SCRIPT : customScript;
|
||||
if (!script) {
|
||||
throw new Error('Custom script required when type is "custom"');
|
||||
}
|
||||
|
||||
const scriptPath = join(helper.testDir, `${serverName}.cjs`);
|
||||
await writeFile(scriptPath, script, 'utf-8');
|
||||
|
||||
// Make script executable on Unix-like systems
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(scriptPath, 0o755);
|
||||
}
|
||||
|
||||
return {
|
||||
scriptPath,
|
||||
config: {
|
||||
command: 'node',
|
||||
args: [scriptPath],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message & Content Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract text from ContentBlock array
|
||||
*/
|
||||
export function extractText(content: ContentBlock[]): string {
|
||||
return content
|
||||
.filter((block): block is TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect messages by type
|
||||
*/
|
||||
export function collectMessagesByType<T extends SDKMessage>(
|
||||
messages: SDKMessage[],
|
||||
predicate: (msg: SDKMessage) => msg is T,
|
||||
): T[] {
|
||||
return messages.filter(predicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tool use blocks in a message
|
||||
*/
|
||||
export function findToolUseBlocks(
|
||||
message: SDKAssistantMessage,
|
||||
toolName?: string,
|
||||
): ToolUseBlock[] {
|
||||
const toolUseBlocks = message.message.content.filter(
|
||||
(block): block is ToolUseBlock => block.type === 'tool_use',
|
||||
);
|
||||
|
||||
if (toolName) {
|
||||
return toolUseBlocks.filter((block) => block.name === toolName);
|
||||
}
|
||||
|
||||
return toolUseBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all assistant text from messages
|
||||
*/
|
||||
export function getAssistantText(messages: SDKMessage[]): string {
|
||||
return messages
|
||||
.filter(isSDKAssistantMessage)
|
||||
.map((msg) => extractText(msg.message.content))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find system message with optional subtype filter
|
||||
*/
|
||||
export function findSystemMessage(
|
||||
messages: SDKMessage[],
|
||||
subtype?: string,
|
||||
): SDKSystemMessage | null {
|
||||
const systemMessages = messages.filter(isSDKSystemMessage);
|
||||
|
||||
if (subtype) {
|
||||
return systemMessages.find((msg) => msg.subtype === subtype) || null;
|
||||
}
|
||||
|
||||
return systemMessages[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all tool calls in messages
|
||||
*/
|
||||
export function findToolCalls(
|
||||
messages: SDKMessage[],
|
||||
toolName?: string,
|
||||
): Array<{ message: SDKAssistantMessage; toolUse: ToolUseBlock }> {
|
||||
const results: Array<{
|
||||
message: SDKAssistantMessage;
|
||||
toolUse: ToolUseBlock;
|
||||
}> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, toolName);
|
||||
for (const toolUse of toolUseBlocks) {
|
||||
results.push({ message, toolUse });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tool result for a specific tool use ID
|
||||
*/
|
||||
export function findToolResult(
|
||||
messages: SDKMessage[],
|
||||
toolUseId: string,
|
||||
): { content: string; isError: boolean } | null {
|
||||
for (const message of messages) {
|
||||
if (message.type === 'user' && 'message' in message) {
|
||||
const userMsg = message as SDKUserMessage;
|
||||
const content = userMsg.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (
|
||||
block.type === 'tool_result' &&
|
||||
(block as { tool_use_id?: string }).tool_use_id === toolUseId
|
||||
) {
|
||||
const resultBlock = block as {
|
||||
content?: string | ContentBlock[];
|
||||
is_error?: boolean;
|
||||
};
|
||||
let resultContent = '';
|
||||
if (typeof resultBlock.content === 'string') {
|
||||
resultContent = resultBlock.content;
|
||||
} else if (Array.isArray(resultBlock.content)) {
|
||||
resultContent = resultBlock.content
|
||||
.filter((b): b is TextBlock => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('');
|
||||
}
|
||||
return {
|
||||
content: resultContent,
|
||||
isError: resultBlock.is_error ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all tool results for a specific tool name
|
||||
*/
|
||||
export function findToolResults(
|
||||
messages: SDKMessage[],
|
||||
toolName: string,
|
||||
): Array<{ toolUseId: string; content: string; isError: boolean }> {
|
||||
const results: Array<{
|
||||
toolUseId: string;
|
||||
content: string;
|
||||
isError: boolean;
|
||||
}> = [];
|
||||
|
||||
// First find all tool calls for this tool
|
||||
const toolCalls = findToolCalls(messages, toolName);
|
||||
|
||||
// Then find the result for each tool call
|
||||
for (const { toolUse } of toolCalls) {
|
||||
const result = findToolResult(messages, toolUse.id);
|
||||
if (result) {
|
||||
results.push({
|
||||
toolUseId: toolUse.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all tool result blocks from messages (without requiring tool name)
|
||||
*/
|
||||
export function findAllToolResultBlocks(
|
||||
messages: SDKMessage[],
|
||||
): Array<{ toolUseId: string; content: string; isError: boolean }> {
|
||||
const results: Array<{
|
||||
toolUseId: string;
|
||||
content: string;
|
||||
isError: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type === 'user' && 'message' in message) {
|
||||
const userMsg = message as SDKUserMessage;
|
||||
const content = userMsg.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result' && 'tool_use_id' in block) {
|
||||
const resultBlock = block as {
|
||||
tool_use_id: string;
|
||||
content?: string | ContentBlock[];
|
||||
is_error?: boolean;
|
||||
};
|
||||
let resultContent = '';
|
||||
if (typeof resultBlock.content === 'string') {
|
||||
resultContent = resultBlock.content;
|
||||
} else if (Array.isArray(resultBlock.content)) {
|
||||
resultContent = (resultBlock.content as ContentBlock[])
|
||||
.filter((b): b is TextBlock => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('');
|
||||
}
|
||||
results.push({
|
||||
toolUseId: resultBlock.tool_use_id,
|
||||
content: resultContent,
|
||||
isError: resultBlock.is_error ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any tool results exist in messages
|
||||
*/
|
||||
export function hasAnyToolResults(messages: SDKMessage[]): boolean {
|
||||
return findAllToolResultBlocks(messages).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any successful (non-error) tool results exist
|
||||
*/
|
||||
export function hasSuccessfulToolResults(messages: SDKMessage[]): boolean {
|
||||
return findAllToolResultBlocks(messages).some((r) => !r.isError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any error tool results exist
|
||||
*/
|
||||
export function hasErrorToolResults(messages: SDKMessage[]): boolean {
|
||||
return findAllToolResultBlocks(messages).some((r) => r.isError);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Streaming Input Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a simple streaming input from an array of message contents
|
||||
*/
|
||||
export async function* createStreamingInput(
|
||||
messageContents: string[],
|
||||
sessionId?: string,
|
||||
): AsyncIterable<SDKUserMessage> {
|
||||
const sid = sessionId || crypto.randomUUID();
|
||||
|
||||
for (const content of messageContents) {
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sid,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: content,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
// Small delay between messages
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a controlled streaming input with pause/resume capability
|
||||
*/
|
||||
export function createControlledStreamingInput(
|
||||
messageContents: string[],
|
||||
sessionId?: string,
|
||||
): {
|
||||
generator: AsyncIterable<SDKUserMessage>;
|
||||
resume: () => void;
|
||||
resumeAll: () => void;
|
||||
} {
|
||||
const sid = sessionId || crypto.randomUUID();
|
||||
const resumeResolvers: Array<() => void> = [];
|
||||
const resumePromises: Array<Promise<void>> = [];
|
||||
|
||||
// Create a resume promise for each message after the first
|
||||
for (let i = 1; i < messageContents.length; i++) {
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resumeResolvers.push(resolve);
|
||||
});
|
||||
resumePromises.push(promise);
|
||||
}
|
||||
|
||||
const generator = (async function* () {
|
||||
// Yield first message immediately
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sid,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: messageContents[0],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
// For subsequent messages, wait for resume
|
||||
for (let i = 1; i < messageContents.length; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await resumePromises[i - 1];
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sid,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: messageContents[i],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
})();
|
||||
|
||||
let currentResumeIndex = 0;
|
||||
|
||||
return {
|
||||
generator,
|
||||
resume: () => {
|
||||
if (currentResumeIndex < resumeResolvers.length) {
|
||||
resumeResolvers[currentResumeIndex]();
|
||||
currentResumeIndex++;
|
||||
}
|
||||
},
|
||||
resumeAll: () => {
|
||||
resumeResolvers.forEach((resolve) => resolve());
|
||||
currentResumeIndex = resumeResolvers.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Assertion Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Assert that messages follow expected type sequence
|
||||
*/
|
||||
export function assertMessageSequence(
|
||||
messages: SDKMessage[],
|
||||
expectedTypes: string[],
|
||||
): void {
|
||||
const actualTypes = messages.map((msg) => msg.type);
|
||||
|
||||
if (actualTypes.length < expectedTypes.length) {
|
||||
throw new Error(
|
||||
`Expected at least ${expectedTypes.length} messages, got ${actualTypes.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < expectedTypes.length; i++) {
|
||||
if (actualTypes[i] !== expectedTypes[i]) {
|
||||
throw new Error(
|
||||
`Expected message ${i} to be type '${expectedTypes[i]}', got '${actualTypes[i]}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a specific tool was called
|
||||
*/
|
||||
export function assertToolCalled(
|
||||
messages: SDKMessage[],
|
||||
toolName: string,
|
||||
): void {
|
||||
const toolCalls = findToolCalls(messages, toolName);
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
const allToolCalls = findToolCalls(messages);
|
||||
const allToolNames = allToolCalls.map((tc) => tc.toolUse.name);
|
||||
throw new Error(
|
||||
`Expected tool '${toolName}' to be called. Found tools: ${allToolNames.length > 0 ? allToolNames.join(', ') : 'none'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the conversation completed successfully
|
||||
*/
|
||||
export function assertSuccessfulCompletion(messages: SDKMessage[]): void {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
if (!isSDKResultMessage(lastMessage)) {
|
||||
throw new Error(
|
||||
`Expected last message to be a result message, got '${lastMessage.type}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (lastMessage.subtype !== 'success') {
|
||||
throw new Error(
|
||||
`Expected successful completion, got result subtype '${lastMessage.subtype}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a condition to be true with timeout
|
||||
*/
|
||||
export async function waitFor(
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
options: {
|
||||
timeout?: number;
|
||||
interval?: number;
|
||||
errorMessage?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const {
|
||||
timeout = 5000,
|
||||
interval = 100,
|
||||
errorMessage = 'Condition not met within timeout',
|
||||
} = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const result = await predicate();
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Debug and Validation Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate model output and warn about unexpected content
|
||||
* Inspired by integration-tests test-helper
|
||||
*/
|
||||
export function validateModelOutput(
|
||||
result: string,
|
||||
expectedContent: string | (string | RegExp)[] | null = null,
|
||||
testName = '',
|
||||
): boolean {
|
||||
// First, check if there's any output at all
|
||||
if (!result || result.trim().length === 0) {
|
||||
throw new Error('Expected model to return some output');
|
||||
}
|
||||
|
||||
// If expectedContent is provided, check for it and warn if missing
|
||||
if (expectedContent) {
|
||||
const contents = Array.isArray(expectedContent)
|
||||
? expectedContent
|
||||
: [expectedContent];
|
||||
const missingContent = contents.filter((content) => {
|
||||
if (typeof content === 'string') {
|
||||
return !result.toLowerCase().includes(content.toLowerCase());
|
||||
} else if (content instanceof RegExp) {
|
||||
return !content.test(result);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (missingContent.length > 0) {
|
||||
console.warn(
|
||||
`Warning: Model did not include expected content in response: ${missingContent.join(', ')}.`,
|
||||
'This is not ideal but not a test failure.',
|
||||
);
|
||||
console.warn(
|
||||
'The tool was called successfully, which is the main requirement.',
|
||||
);
|
||||
return false;
|
||||
} else if (process.env['VERBOSE'] === 'true') {
|
||||
console.log(`${testName}: Model output validated successfully.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print debug information when tests fail
|
||||
*/
|
||||
export function printDebugInfo(
|
||||
messages: SDKMessage[],
|
||||
context: Record<string, unknown> = {},
|
||||
): void {
|
||||
console.error('Test failed - Debug info:');
|
||||
console.error('Message count:', messages.length);
|
||||
|
||||
// Print message types
|
||||
const messageTypes = messages.map((m) => m.type);
|
||||
console.error('Message types:', messageTypes.join(', '));
|
||||
|
||||
// Print assistant text
|
||||
const assistantText = getAssistantText(messages);
|
||||
console.error(
|
||||
'Assistant text (first 500 chars):',
|
||||
assistantText.substring(0, 500),
|
||||
);
|
||||
if (assistantText.length > 500) {
|
||||
console.error(
|
||||
'Assistant text (last 500 chars):',
|
||||
assistantText.substring(assistantText.length - 500),
|
||||
);
|
||||
}
|
||||
|
||||
// Print tool calls
|
||||
const toolCalls = findToolCalls(messages);
|
||||
console.error(
|
||||
'Tool calls found:',
|
||||
toolCalls.map((tc) => tc.toolUse.name),
|
||||
);
|
||||
|
||||
// Print any additional context provided
|
||||
Object.entries(context).forEach(([key, value]) => {
|
||||
console.error(`${key}:`, value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detailed error message for tool call expectations
|
||||
*/
|
||||
export function createToolCallErrorMessage(
|
||||
expectedTools: string | string[],
|
||||
foundTools: string[],
|
||||
messages: SDKMessage[],
|
||||
): string {
|
||||
const expectedStr = Array.isArray(expectedTools)
|
||||
? expectedTools.join(' or ')
|
||||
: expectedTools;
|
||||
|
||||
const assistantText = getAssistantText(messages);
|
||||
const preview = assistantText
|
||||
? assistantText.substring(0, 200) + '...'
|
||||
: 'no output';
|
||||
|
||||
return (
|
||||
`Expected to find ${expectedStr} tool call(s). ` +
|
||||
`Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` +
|
||||
`Output preview: ${preview}`
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shared Test Options Helper
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create shared test options with CLI path
|
||||
*/
|
||||
export function createSharedTestOptions(
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const TEST_CLI_PATH = process.env['TEST_CLI_PATH'];
|
||||
if (!TEST_CLI_PATH) {
|
||||
throw new Error('TEST_CLI_PATH environment variable not set');
|
||||
}
|
||||
|
||||
return {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
742
integration-tests/sdk-typescript/tool-control.test.ts
Normal file
742
integration-tests/sdk-typescript/tool-control.test.ts
Normal file
@@ -0,0 +1,742 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for tool control parameters:
|
||||
* - coreTools: Limit available tools to a specific set
|
||||
* - excludeTools: Block specific tools from execution
|
||||
* - allowedTools: Auto-approve specific tools without confirmation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query, isSDKAssistantMessage, type SDKMessage } from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
findToolCalls,
|
||||
findToolResults,
|
||||
assertSuccessfulCompletion,
|
||||
createSharedTestOptions,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
const TEST_TIMEOUT = 60000;
|
||||
|
||||
describe('Tool Control Parameters (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('tool-control');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('coreTools parameter', () => {
|
||||
it(
|
||||
'should only allow specified tools when coreTools is set',
|
||||
async () => {
|
||||
// Create a test file
|
||||
await helper.createFile('test.txt', 'original content');
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Read the file test.txt and then write "modified" to test.txt. Finally, list the directory.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'yolo',
|
||||
// Only allow read_file and write_file, exclude list_directory
|
||||
coreTools: ['read_file', 'write_file'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Should have read_file and write_file calls
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
expect(toolNames).toContain('read_file');
|
||||
expect(toolNames).toContain('write_file');
|
||||
|
||||
// Should NOT have list_directory since it's not in coreTools
|
||||
expect(toolNames).not.toContain('list_directory');
|
||||
|
||||
// Verify file was modified
|
||||
const content = await helper.readFile('test.txt');
|
||||
expect(content).toContain('modified');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should work with minimal tool set',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2? Just answer with the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
// Only allow thinking, no file operations
|
||||
coreTools: [],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Should answer without any tool calls
|
||||
expect(assistantText).toMatch(/4/);
|
||||
|
||||
// Should have no tool calls
|
||||
const toolCalls = findToolCalls(messages);
|
||||
expect(toolCalls.length).toBe(0);
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('excludeTools parameter', () => {
|
||||
it(
|
||||
'should block excluded tools from execution',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test content');
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Read test.txt and then write empty content to it to clear it.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'yolo',
|
||||
coreTools: ['read_file', 'write_file'],
|
||||
// Block all write_file tool
|
||||
excludeTools: ['write_file'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Should be able to read the file
|
||||
expect(toolNames).toContain('read_file');
|
||||
|
||||
// The excluded tools should have been called but returned permission declined
|
||||
// Check if write_file was attempted and got permission denied
|
||||
const writeFileResults = findToolResults(messages, 'write_file');
|
||||
if (writeFileResults.length > 0) {
|
||||
// Tool was called but should have permission declined message
|
||||
for (const result of writeFileResults) {
|
||||
expect(result.content).toMatch(/permission.*declined/i);
|
||||
}
|
||||
}
|
||||
|
||||
// File content should remain unchanged (because write was denied)
|
||||
const content = await helper.readFile('test.txt');
|
||||
expect(content).toBe('test content');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should block multiple excluded tools',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test content');
|
||||
|
||||
const q = query({
|
||||
prompt: 'Read test.txt, list the directory, and run "echo hello".',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'yolo',
|
||||
// Block multiple tools
|
||||
excludeTools: ['list_directory', 'run_shell_command'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Should be able to read
|
||||
expect(toolNames).toContain('read_file');
|
||||
|
||||
// Excluded tools should have been attempted but returned permission declined
|
||||
const listDirResults = findToolResults(messages, 'list_directory');
|
||||
if (listDirResults.length > 0) {
|
||||
for (const result of listDirResults) {
|
||||
expect(result.content).toMatch(/permission.*declined/i);
|
||||
}
|
||||
}
|
||||
|
||||
const shellResults = findToolResults(messages, 'run_shell_command');
|
||||
if (shellResults.length > 0) {
|
||||
for (const result of shellResults) {
|
||||
expect(result.content).toMatch(/permission.*declined/i);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should block all shell commands when run_shell_command is excluded',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Run "echo hello" and "ls -la" commands.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'yolo',
|
||||
// Block all shell commands - excludeTools blocks entire tools
|
||||
excludeTools: ['run_shell_command'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// All shell commands should have permission declined
|
||||
const shellResults = findToolResults(messages, 'run_shell_command');
|
||||
for (const result of shellResults) {
|
||||
expect(result.content).toMatch(/permission.*declined/i);
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'excludeTools should take priority over allowedTools',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test content');
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Clear the content of test.txt by writing empty string to it.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
// Conflicting settings: exclude takes priority
|
||||
excludeTools: ['write_file'],
|
||||
allowedTools: ['write_file'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// write_file should have been attempted but returned permission declined
|
||||
const writeFileResults = findToolResults(messages, 'write_file');
|
||||
if (writeFileResults.length > 0) {
|
||||
// Tool was called but should have permission declined message (exclude takes priority)
|
||||
for (const result of writeFileResults) {
|
||||
expect(result.content).toMatch(/permission.*declined/i);
|
||||
}
|
||||
}
|
||||
|
||||
// File content should remain unchanged (because write was denied)
|
||||
const content = await helper.readFile('test.txt');
|
||||
expect(content).toBe('test content');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('allowedTools parameter', () => {
|
||||
it(
|
||||
'should auto-approve allowed tools without canUseTool callback',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'original');
|
||||
|
||||
let canUseToolCalled = false;
|
||||
|
||||
const q = query({
|
||||
prompt: 'Read test.txt and write "modified" to it.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
coreTools: ['read_file', 'write_file'],
|
||||
// Allow write_file without confirmation
|
||||
allowedTools: ['read_file', 'write_file'],
|
||||
canUseTool: async (_toolName) => {
|
||||
canUseToolCalled = true;
|
||||
return { behavior: 'deny', message: 'Should not be called' };
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Should have executed the tools
|
||||
expect(toolNames).toContain('read_file');
|
||||
expect(toolNames).toContain('write_file');
|
||||
|
||||
// canUseTool should NOT have been called (tools are in allowedTools)
|
||||
expect(canUseToolCalled).toBe(false);
|
||||
|
||||
// Verify file was modified
|
||||
const content = await helper.readFile('test.txt');
|
||||
expect(content).toContain('modified');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should allow specific shell commands with pattern matching',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Run "echo hello" and "ls -la" commands.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
// Allow specific shell commands
|
||||
allowedTools: ['ShellTool(echo )', 'ShellTool(ls )'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const shellCalls = toolCalls.filter(
|
||||
(tc) => tc.toolUse.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
// Should have executed shell commands
|
||||
expect(shellCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// All shell commands should be echo or ls
|
||||
for (const call of shellCalls) {
|
||||
const input = call.toolUse.input as { command?: string };
|
||||
if (input.command) {
|
||||
expect(input.command).toMatch(/^(echo |ls )/);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should fall back to canUseTool for non-allowed tools',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test');
|
||||
|
||||
const canUseToolCalls: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Read test.txt and append an empty line to it.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
// Only allow read_file, list_directory should trigger canUseTool
|
||||
coreTools: ['read_file', 'write_file'],
|
||||
allowedTools: ['read_file'],
|
||||
canUseTool: async (toolName) => {
|
||||
canUseToolCalls.push(toolName);
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: {},
|
||||
};
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Both tools should have been executed
|
||||
expect(toolNames).toContain('read_file');
|
||||
expect(toolNames).toContain('write_file');
|
||||
|
||||
// canUseTool should have been called for write_file (not in allowedTools)
|
||||
// but NOT for read_file (in allowedTools)
|
||||
expect(canUseToolCalls).toContain('write_file');
|
||||
expect(canUseToolCalls).not.toContain('read_file');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should work with permissionMode: auto-edit',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test');
|
||||
|
||||
const canUseToolCalls: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Read test.txt, write "new" to it, and list the directory.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'auto-edit',
|
||||
// Allow list_directory in addition to auto-approved edit tools
|
||||
allowedTools: ['list_directory'],
|
||||
canUseTool: async (toolName) => {
|
||||
canUseToolCalls.push(toolName);
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Should not be called',
|
||||
};
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// All tools should have been executed
|
||||
expect(toolNames).toContain('read_file');
|
||||
expect(toolNames).toContain('write_file');
|
||||
expect(toolNames).toContain('list_directory');
|
||||
|
||||
// canUseTool should NOT have been called
|
||||
// (edit tools auto-approved, list_directory in allowedTools)
|
||||
expect(canUseToolCalls.length).toBe(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Combined tool control scenarios', () => {
|
||||
it(
|
||||
'should work with coreTools + allowedTools',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test');
|
||||
|
||||
const q = query({
|
||||
prompt: 'Read test.txt and write "modified" to it.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
// Limit to specific tools
|
||||
coreTools: ['read_file', 'write_file', 'list_directory'],
|
||||
// Auto-approve write operations
|
||||
allowedTools: ['write_file'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Should use allowed tools from coreTools
|
||||
expect(toolNames).toContain('read_file');
|
||||
expect(toolNames).toContain('write_file');
|
||||
|
||||
// Should NOT use tools outside coreTools
|
||||
expect(toolNames).not.toContain('run_shell_command');
|
||||
|
||||
// Verify file was modified
|
||||
const content = await helper.readFile('test.txt');
|
||||
expect(content).toContain('modified');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should work with coreTools + excludeTools',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test');
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Read test.txt, write "new content" to it, and list directory.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'yolo',
|
||||
// Allow file operations
|
||||
coreTools: ['read_file', 'write_file', 'edit', 'list_directory'],
|
||||
// But exclude edit
|
||||
excludeTools: ['edit'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Should use non-excluded tools from coreTools
|
||||
expect(toolNames).toContain('read_file');
|
||||
|
||||
// Should NOT use excluded tool
|
||||
expect(toolNames).not.toContain('edit');
|
||||
|
||||
// File should still exist
|
||||
expect(helper.fileExists('test.txt')).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should work with all three parameters together',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test');
|
||||
|
||||
const canUseToolCalls: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt:
|
||||
'Read test.txt, write "modified" to it, and list the directory.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
// Limit available tools
|
||||
coreTools: ['read_file', 'write_file', 'list_directory', 'edit'],
|
||||
// Block edit
|
||||
excludeTools: ['edit'],
|
||||
// Auto-approve write
|
||||
allowedTools: ['write_file'],
|
||||
canUseTool: async (toolName) => {
|
||||
canUseToolCalls.push(toolName);
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: {},
|
||||
};
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Should use allowed tools
|
||||
expect(toolNames).toContain('read_file');
|
||||
expect(toolNames).toContain('write_file');
|
||||
|
||||
// Should NOT use excluded tool
|
||||
expect(toolNames).not.toContain('edit');
|
||||
|
||||
// canUseTool should be called for tools not in allowedTools
|
||||
// but should NOT be called for write_file (in allowedTools)
|
||||
expect(canUseToolCalls).not.toContain('write_file');
|
||||
|
||||
// Verify file was modified
|
||||
const content = await helper.readFile('test.txt');
|
||||
expect(content).toContain('modified');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Edge cases and error handling', () => {
|
||||
it(
|
||||
'should handle non-existent tool names in excludeTools',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test');
|
||||
|
||||
const q = query({
|
||||
prompt: 'Read test.txt.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'yolo',
|
||||
// Non-existent tool names should be ignored
|
||||
excludeTools: ['non_existent_tool', 'another_fake_tool'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Should work normally
|
||||
expect(toolNames).toContain('read_file');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle non-existent tool names in allowedTools',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'test');
|
||||
|
||||
const q = query({
|
||||
prompt: 'Read test.txt.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'yolo',
|
||||
// Non-existent tool names should be ignored
|
||||
allowedTools: ['non_existent_tool', 'read_file'],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Should work normally
|
||||
expect(toolNames).toContain('read_file');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { ShellExecutionService } from '../packages/core/src/services/shellExecutionService.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('ShellExecutionService programmatic integration tests', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a dedicated directory for this test suite to avoid conflicts.
|
||||
testDir = path.join(
|
||||
process.env['INTEGRATION_TEST_FILE_DIR']!,
|
||||
'shell-service-tests',
|
||||
);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('should execute a simple cross-platform command (echo)', async () => {
|
||||
const command = 'echo "hello from the service"';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Output can vary slightly between shells (e.g., quotes), so check for inclusion.
|
||||
expect(result.output).toContain('hello from the service');
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'win32')(
|
||||
'should execute "dir" on Windows',
|
||||
async () => {
|
||||
const testFile = 'test-file-windows.txt';
|
||||
await fs.writeFile(path.join(testDir, testFile), 'windows test');
|
||||
|
||||
const command = 'dir';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain(testFile);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should execute "ls -l" on Unix',
|
||||
async () => {
|
||||
const testFile = 'test-file-unix.txt';
|
||||
await fs.writeFile(path.join(testDir, testFile), 'unix test');
|
||||
|
||||
const command = 'ls -l';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain(testFile);
|
||||
},
|
||||
);
|
||||
|
||||
it('should abort a running process', async () => {
|
||||
// A command that runs for a bit. 'sleep' on unix, 'timeout' on windows.
|
||||
const command = process.platform === 'win32' ? 'timeout /t 20' : 'sleep 20';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
// Abort shortly after starting
|
||||
setTimeout(() => abortController.abort(), 50);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
// For debugging the flaky test.
|
||||
console.log('Abort test result:', result);
|
||||
|
||||
expect(result.aborted).toBe(true);
|
||||
// A clean exit is exitCode 0 and no signal. If the process was truly
|
||||
// aborted, it should not have exited cleanly.
|
||||
const exitedCleanly = result.exitCode === 0 && result.signal === null;
|
||||
expect(exitedCleanly, 'Process should not have exited cleanly').toBe(false);
|
||||
});
|
||||
|
||||
it('should propagate environment variables to the child process', async () => {
|
||||
const varName = 'QWEN_CODE_TEST_VAR';
|
||||
const varValue = `test-value`;
|
||||
process.env[varName] = varValue;
|
||||
|
||||
try {
|
||||
const command =
|
||||
process.platform === 'win32' ? `echo %${varName}%` : `echo $${varName}`;
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain(varValue);
|
||||
} finally {
|
||||
// Clean up the env var to prevent side-effects on other tests.
|
||||
delete process.env[varName];
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user