mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-31 14:09:15 +00:00
Compare commits
1 Commits
v0.4.1
...
v0.4.0-pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13eb86d50f |
237
.github/workflows/release-sdk.yml
vendored
237
.github/workflows/release-sdk.yml
vendored
@@ -1,237 +0,0 @@
|
||||
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}"
|
||||
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
@@ -79,6 +79,7 @@
|
||||
"--",
|
||||
"-p",
|
||||
"${input:prompt}",
|
||||
"-y",
|
||||
"--output-format",
|
||||
"stream-json"
|
||||
],
|
||||
|
||||
@@ -88,12 +88,6 @@ 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
|
||||
|
||||
@@ -145,6 +145,16 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **`nodesc`** or **`nodescriptions`**:
|
||||
- **Description:** Hide tool descriptions, showing only the tool names.
|
||||
|
||||
- **`/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.
|
||||
|
||||
|
||||
@@ -671,4 +671,4 @@ Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM
|
||||
- **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.
|
||||
- **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 and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details.
|
||||
|
||||
@@ -81,6 +81,14 @@ The Welcome Back feature works seamlessly with the `/summary` command:
|
||||
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:
|
||||
|
||||
@@ -22,7 +22,6 @@ export default tseslint.config(
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
'.integration-tests/**',
|
||||
'packages/**/.integration-test/**',
|
||||
'dist/**',
|
||||
],
|
||||
},
|
||||
@@ -75,8 +74,6 @@ 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'],
|
||||
@@ -113,14 +110,10 @@ export default tseslint.config(
|
||||
{
|
||||
allow: [
|
||||
'react-dom/test-utils',
|
||||
'react-dom/client',
|
||||
'memfs/lib/volume.js',
|
||||
'yargs/**',
|
||||
'msw/node',
|
||||
'**/generated/**',
|
||||
'./styles/tailwind.css',
|
||||
'./styles/App.css',
|
||||
'./styles/style.css'
|
||||
'**/generated/**'
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -157,7 +150,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'],
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
||||
plugins: {
|
||||
vitest,
|
||||
},
|
||||
@@ -165,19 +158,11 @@ 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', 'packages/*/scripts/**/*.js'],
|
||||
files: ['./scripts/**/*.js', 'esbuild.config.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
@@ -244,7 +229,7 @@ export default tseslint.config(
|
||||
prettierConfig,
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['./integration-tests/**/*.{js,ts,tsx}'],
|
||||
files: ['./integration-tests/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
|
||||
@@ -25,14 +25,6 @@ type PendingRequest = {
|
||||
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?: {
|
||||
@@ -47,9 +39,6 @@ type SessionUpdateNotification = {
|
||||
text?: string;
|
||||
};
|
||||
modeId?: string;
|
||||
_meta?: {
|
||||
usage?: UsageMetadata;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -598,52 +587,4 @@ function setupAcpTest(
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -30,7 +30,6 @@ 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(),
|
||||
@@ -49,36 +48,14 @@ 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);
|
||||
|
||||
// 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);
|
||||
if (testRuns.length > 5) {
|
||||
const oldRuns = testRuns.sort().slice(0, testRuns.length - 5);
|
||||
await Promise.all(
|
||||
oldRuns.map((oldRun) =>
|
||||
rm(join(integrationTestsDir, oldRun), {
|
||||
@@ -92,37 +69,24 @@ 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 CLI test run directory unless KEEP_OUTPUT is set
|
||||
// Cleanup the 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,486 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,640 +0,0 @@
|
||||
/**
|
||||
* @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 in containerized sandbox environments - qwen-oauth requires user interaction
|
||||
// which is not possible in Docker/Podman CI environments
|
||||
it.skipIf(
|
||||
process.env['SANDBOX'] === 'sandbox:docker' ||
|
||||
process.env['SANDBOX'] === 'sandbox:podman',
|
||||
)('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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,405 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,559 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,456 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,528 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,614 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,317 +0,0 @@
|
||||
/**
|
||||
* 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,970 +0,0 @@
|
||||
/**
|
||||
* @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) {
|
||||
const qwenDir = join(this.testDir, '.qwen');
|
||||
await mkdir(qwenDir, { recursive: true });
|
||||
|
||||
const settings = {
|
||||
telemetry: {
|
||||
enabled: false, // SDK tests don't need telemetry
|
||||
},
|
||||
...options.settings,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,744 +0,0 @@
|
||||
/**
|
||||
* @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', {
|
||||
createQwenConfig: false,
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -213,7 +213,7 @@ describe('simple-mcp-server', () => {
|
||||
it('should add two numbers', async () => {
|
||||
// Test directory is already set up in before hook
|
||||
// Just run the command - MCP server config is in settings.json
|
||||
const output = await rig.run('add 5 and 10, use tool if you can.');
|
||||
const output = await rig.run('add 5 and 10');
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('add');
|
||||
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@qwen-code/sdk": ["../packages/sdk-typescript/dist/index.d.ts"]
|
||||
}
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"references": [{ "path": "../packages/core" }]
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const timeoutMinutes = Number(process.env['TB_TIMEOUT_MINUTES'] || '5');
|
||||
const timeoutMinutes = Number(process.env.TB_TIMEOUT_MINUTES || '5');
|
||||
const testTimeoutMs = timeoutMinutes * 60 * 1000;
|
||||
|
||||
export default defineConfig({
|
||||
@@ -28,13 +25,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Use built SDK bundle for e2e tests
|
||||
'@qwen-code/sdk': resolve(
|
||||
__dirname,
|
||||
'../packages/sdk-typescript/dist/index.mjs',
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
4067
package-lock.json
generated
4067
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.0-preview.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0-preview.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
@@ -37,10 +37,6 @@
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"test:integration:sdk:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests sdk-typescript",
|
||||
"test:integration:sdk:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript",
|
||||
"test:integration:cli:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||
"test:integration:cli:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
|
||||
"test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'",
|
||||
"test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'",
|
||||
@@ -93,7 +89,7 @@
|
||||
"eslint-plugin-license-header": "^0.8.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"glob": "^10.5.0",
|
||||
"glob": "^10.4.5",
|
||||
"globals": "^16.0.0",
|
||||
"google-artifactregistry-auth": "^3.4.0",
|
||||
"husky": "^9.1.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.0-preview.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0-preview.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.5.0",
|
||||
"glob": "^10.4.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "^6.2.3",
|
||||
"ink-gradient": "^3.0.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"tar": "^7.5.1",
|
||||
"undici": "^7.10.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
|
||||
@@ -316,23 +316,6 @@ export const annotationsSchema = z.object({
|
||||
priority: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export const usageSchema = z.object({
|
||||
promptTokens: z.number().optional().nullable(),
|
||||
completionTokens: z.number().optional().nullable(),
|
||||
thoughtsTokens: z.number().optional().nullable(),
|
||||
totalTokens: z.number().optional().nullable(),
|
||||
cachedTokens: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type Usage = z.infer<typeof usageSchema>;
|
||||
|
||||
export const sessionUpdateMetaSchema = z.object({
|
||||
usage: usageSchema.optional().nullable(),
|
||||
durationMs: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
|
||||
|
||||
export const requestPermissionResponseSchema = z.object({
|
||||
outcome: requestPermissionOutcomeSchema,
|
||||
});
|
||||
@@ -517,12 +500,10 @@ export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_thought_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
describe('readTextFile ENOENT handling', () => {
|
||||
it('parses path from ACP ENOENT message (quoted)', async () => {
|
||||
const client = {
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/remote/file.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to requested path when none provided', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-2',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.readTextFile('/fallback/path.txt'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/fallback/path.txt',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,20 +30,6 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
limit: null,
|
||||
});
|
||||
|
||||
if (response.content.startsWith('ERROR: ENOENT:')) {
|
||||
// Treat ACP error strings as structured ENOENT errors without
|
||||
// assuming a specific platform format.
|
||||
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
|
||||
const err = new Error(response.content) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
const rawPath = match?.groups?.['path']?.trim();
|
||||
err['path'] = rawPath
|
||||
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
|
||||
: filePath;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
|
||||
@@ -411,48 +411,4 @@ describe('HistoryReplayer', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usage metadata replay', () => {
|
||||
it('should emit usage metadata after assistant message content', async () => {
|
||||
const record: ChatRecord = {
|
||||
uuid: 'assistant-uuid',
|
||||
parentUuid: 'user-uuid',
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'assistant',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hello!' }],
|
||||
},
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 150,
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello!' },
|
||||
});
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: undefined,
|
||||
totalTokens: 150,
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
} from '@google/genai';
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
@@ -55,9 +52,6 @@ export class HistoryReplayer {
|
||||
if (record.message) {
|
||||
await this.replayContent(record.message, 'assistant');
|
||||
}
|
||||
if (record.usageMetadata) {
|
||||
await this.replayUsageMetadata(record.usageMetadata);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
@@ -94,22 +88,11 @@ export class HistoryReplayer {
|
||||
toolName: functionName,
|
||||
callId,
|
||||
args: part.functionCall.args as Record<string, unknown>,
|
||||
status: 'in_progress',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays usage metadata.
|
||||
* @param usageMetadata - The usage metadata to replay
|
||||
*/
|
||||
private async replayUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
): Promise<void> {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a tool result record.
|
||||
*/
|
||||
@@ -135,54 +118,6 @@ export class HistoryReplayer {
|
||||
// Note: args aren't stored in tool_result records by default
|
||||
args: undefined,
|
||||
});
|
||||
|
||||
// Special handling: Task tool execution summary contains token usage
|
||||
const { resultDisplay } = result ?? {};
|
||||
if (
|
||||
!!resultDisplay &&
|
||||
typeof resultDisplay === 'object' &&
|
||||
'type' in resultDisplay &&
|
||||
(resultDisplay as { type?: unknown }).type === 'task_execution'
|
||||
) {
|
||||
await this.emitTaskUsageFromResultDisplay(
|
||||
resultDisplay as TaskResultDisplay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits token usage from a TaskResultDisplay execution summary, if present.
|
||||
*/
|
||||
private async emitTaskUsageFromResultDisplay(
|
||||
resultDisplay: TaskResultDisplay,
|
||||
): Promise<void> {
|
||||
const summary = resultDisplay.executionSummary;
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageMetadata: GenerateContentResponseUsageMetadata = {};
|
||||
|
||||
if (Number.isFinite(summary.inputTokens)) {
|
||||
usageMetadata.promptTokenCount = summary.inputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.outputTokens)) {
|
||||
usageMetadata.candidatesTokenCount = summary.outputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.thoughtTokens)) {
|
||||
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.cachedTokens)) {
|
||||
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.totalTokens)) {
|
||||
usageMetadata.totalTokenCount = summary.totalTokens;
|
||||
}
|
||||
|
||||
// Only emit if we captured at least one token metric
|
||||
if (Object.keys(usageMetadata).length > 0) {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Content,
|
||||
FunctionCall,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
} from '@google/genai';
|
||||
import type { Content, FunctionCall, Part } from '@google/genai';
|
||||
import type {
|
||||
Config,
|
||||
GeminiChat,
|
||||
@@ -60,7 +55,6 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||
|
||||
/**
|
||||
@@ -85,7 +79,6 @@ export class Session implements SessionContext {
|
||||
private readonly historyReplayer: HistoryReplayer;
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly planEmitter: PlanEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
|
||||
// Implement SessionContext interface
|
||||
readonly sessionId: string;
|
||||
@@ -103,7 +96,6 @@ export class Session implements SessionContext {
|
||||
this.toolCallEmitter = new ToolCallEmitter(this);
|
||||
this.planEmitter = new PlanEmitter(this);
|
||||
this.historyReplayer = new HistoryReplayer(this);
|
||||
this.messageEmitter = new MessageEmitter(this);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
@@ -200,8 +192,6 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
@@ -232,16 +222,18 @@ export class Session implements SessionContext {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
part.thought,
|
||||
);
|
||||
}
|
||||
}
|
||||
const content: acp.ContentBlock = {
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
this.sendUpdate({
|
||||
sessionUpdate: part.thought
|
||||
? 'agent_thought_chunk'
|
||||
: 'agent_message_chunk',
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
@@ -259,15 +251,6 @@ export class Session implements SessionContext {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (usageMetadata) {
|
||||
const durationMs = Date.now() - streamStartTime;
|
||||
await this.messageEmitter.emitUsageMetadata(
|
||||
usageMetadata,
|
||||
'',
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
@@ -461,9 +444,7 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO
|
||||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
@@ -541,7 +522,6 @@ export class Session implements SessionContext {
|
||||
callId,
|
||||
toolName: fc.name,
|
||||
args,
|
||||
status: 'in_progress',
|
||||
};
|
||||
await this.toolCallEmitter.emitStart(startParams);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'pending',
|
||||
status: 'in_progress',
|
||||
title: 'read_file',
|
||||
content: [],
|
||||
locations: [],
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
SubAgentUsageEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
import { z } from 'zod';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import type * as acp from '../acp.js';
|
||||
|
||||
/**
|
||||
@@ -64,7 +62,6 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
|
||||
*/
|
||||
export class SubAgentTracker {
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
private readonly toolStates = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -79,7 +76,6 @@ export class SubAgentTracker {
|
||||
private readonly client: acp.Client,
|
||||
) {
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
this.messageEmitter = new MessageEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,19 +92,16 @@ export class SubAgentTracker {
|
||||
const onToolCall = this.createToolCallHandler(abortSignal);
|
||||
const onToolResult = this.createToolResultHandler(abortSignal);
|
||||
const onApproval = this.createApprovalHandler(abortSignal);
|
||||
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
|
||||
|
||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
|
||||
return [
|
||||
() => {
|
||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
// Clean up any remaining states
|
||||
this.toolStates.clear();
|
||||
},
|
||||
@@ -259,20 +252,6 @@ export class SubAgentTracker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for usage metadata events.
|
||||
*/
|
||||
private createUsageMetadataHandler(
|
||||
abortSignal: AbortSignal,
|
||||
): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentUsageEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts confirmation details to permission options for the client.
|
||||
*/
|
||||
|
||||
@@ -148,59 +148,4 @@ describe('MessageEmitter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUsageMetadata', () => {
|
||||
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
thoughtsTokenCount: 25,
|
||||
totalTokenCount: 175,
|
||||
cachedContentTokenCount: 10,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: 25,
|
||||
totalTokens: 175,
|
||||
cachedTokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include durationMs in _meta when provided', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 1,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'done' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
thoughtsTokens: 2,
|
||||
totalTokens: 17,
|
||||
cachedTokens: 1,
|
||||
},
|
||||
durationMs: 1234,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { Usage } from '../../schema.js';
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
@@ -26,16 +24,6 @@ export class MessageEmitter extends BaseEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent message chunk.
|
||||
*/
|
||||
@@ -47,28 +35,12 @@ export class MessageEmitter extends BaseEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits usage metadata.
|
||||
* Emits an agent thought chunk.
|
||||
*/
|
||||
async emitUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
text: string = '',
|
||||
durationMs?: number,
|
||||
): Promise<void> {
|
||||
const usage: Usage = {
|
||||
promptTokens: usageMetadata.promptTokenCount,
|
||||
completionTokens: usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokens: usageMetadata.thoughtsTokenCount,
|
||||
totalTokens: usageMetadata.totalTokenCount,
|
||||
cachedTokens: usageMetadata.cachedContentTokenCount,
|
||||
};
|
||||
|
||||
const meta =
|
||||
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
|
||||
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
_meta: meta,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'pending',
|
||||
status: 'in_progress',
|
||||
title: 'unknown_tool', // Falls back to tool name
|
||||
content: [],
|
||||
locations: [],
|
||||
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-456',
|
||||
status: 'pending',
|
||||
status: 'in_progress',
|
||||
title: 'edit_file: Test tool description',
|
||||
content: [],
|
||||
locations: [{ path: '/test/file.ts', line: 10 }],
|
||||
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-fail',
|
||||
status: 'pending',
|
||||
status: 'in_progress',
|
||||
title: 'failing_tool', // Fallback to tool name
|
||||
content: [],
|
||||
locations: [], // Fallback to empty
|
||||
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'test output',
|
||||
text: '{"output":"test output"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: 'Function output' },
|
||||
content: { type: 'text', text: '{"output":"Function output"}' },
|
||||
},
|
||||
],
|
||||
rawOutput: 'raw result',
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: params.callId,
|
||||
status: params.status || 'pending',
|
||||
status: 'in_progress',
|
||||
title,
|
||||
content: [],
|
||||
locations,
|
||||
@@ -275,18 +275,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
// Handle functionResponse parts - stringify the response
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
try {
|
||||
const resp = part.functionResponse.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const outputField = resp['output'];
|
||||
const errorField = resp['error'];
|
||||
const responseText =
|
||||
typeof outputField === 'string'
|
||||
? outputField
|
||||
: typeof errorField === 'string'
|
||||
? errorField
|
||||
: JSON.stringify(resp);
|
||||
const responseText = JSON.stringify(part.functionResponse.response);
|
||||
result.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
|
||||
@@ -35,8 +35,6 @@ export interface ToolCallStartParams {
|
||||
callId: string;
|
||||
/** Arguments passed to the tool */
|
||||
args?: Record<string, unknown>;
|
||||
/** Status of the tool call */
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import {
|
||||
ApprovalMode,
|
||||
AuthType,
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
@@ -134,11 +133,6 @@ export interface CliArgs {
|
||||
continue: boolean | undefined;
|
||||
/** Resume a specific session by its ID */
|
||||
resume: string | undefined;
|
||||
maxSessionTurns: number | undefined;
|
||||
coreTools: string[] | undefined;
|
||||
excludeTools: string[] | undefined;
|
||||
authType: string | undefined;
|
||||
channel: string | undefined;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(
|
||||
@@ -298,11 +292,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
description: 'Channel identifier (VSCode, ACP, SDK, CI)',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -422,36 +411,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description:
|
||||
'Resume a specific session by its ID. Use without an ID to show session picker.',
|
||||
})
|
||||
.option('max-session-turns', {
|
||||
type: 'number',
|
||||
description: 'Maximum number of session turns',
|
||||
})
|
||||
.option('core-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Core tool paths',
|
||||
coerce: (tools: string[]) =>
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('exclude-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Tools to exclude',
|
||||
coerce: (tools: string[]) =>
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('allowed-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Tools to allow, will bypass confirmation',
|
||||
coerce: (tools: string[]) =>
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('auth-type', {
|
||||
type: 'string',
|
||||
choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH],
|
||||
description: 'Authentication type',
|
||||
})
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
@@ -565,12 +524,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
|
||||
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
|
||||
if (result['experimentalAcp'] && !result['channel']) {
|
||||
(result as Record<string, unknown>)['channel'] = 'ACP';
|
||||
}
|
||||
|
||||
return result as unknown as CliArgs;
|
||||
}
|
||||
|
||||
@@ -792,14 +745,8 @@ export async function loadCliConfig(
|
||||
interactive = false;
|
||||
}
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||
// so tools should not be excluded in that case.
|
||||
const extraExcludes: string[] = [];
|
||||
if (
|
||||
!interactive &&
|
||||
!argv.experimentalAcp &&
|
||||
inputFormat !== InputFormat.STREAM_JSON
|
||||
) {
|
||||
if (!interactive && !argv.experimentalAcp) {
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
@@ -823,7 +770,6 @@ export async function loadCliConfig(
|
||||
settings,
|
||||
activeExtensions,
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
argv.excludeTools,
|
||||
);
|
||||
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
||||
|
||||
@@ -904,7 +850,7 @@ export async function loadCliConfig(
|
||||
debugMode,
|
||||
question,
|
||||
fullContext: argv.allFiles || false,
|
||||
coreTools: argv.coreTools || settings.tools?.core || undefined,
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
@@ -937,16 +883,13 @@ export async function loadCliConfig(
|
||||
model: resolvedModel,
|
||||
extensionContextFilePaths,
|
||||
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
||||
maxSessionTurns:
|
||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
blockedMcpServers,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
authType:
|
||||
(argv.authType as AuthType | undefined) ||
|
||||
settings.security?.auth?.selectedType,
|
||||
authType: settings.security?.auth?.selectedType,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
@@ -995,7 +938,6 @@ export async function loadCliConfig(
|
||||
output: {
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
channel: argv.channel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1055,10 +997,8 @@ function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extensions: Extension[],
|
||||
extraExcludes?: string[] | undefined,
|
||||
cliExcludeTools?: string[] | undefined,
|
||||
): string[] {
|
||||
const allExcludeTools = new Set([
|
||||
...(cliExcludeTools || []),
|
||||
...(settings.tools?.exclude || []),
|
||||
...(extraExcludes || []),
|
||||
]);
|
||||
|
||||
@@ -481,11 +481,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
includePartialMessages: undefined,
|
||||
continue: undefined,
|
||||
resume: undefined,
|
||||
coreTools: undefined,
|
||||
excludeTools: undefined,
|
||||
authType: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
channel: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
|
||||
@@ -276,11 +276,8 @@ export async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
// For stream-json mode, don't read stdin here - it should be forwarded to the sandbox
|
||||
// and consumed by StreamJsonInputReader inside the container
|
||||
const inputFormat = argv.inputFormat as string | undefined;
|
||||
let stdinData = '';
|
||||
if (!process.stdin.isTTY && inputFormat !== 'stream-json') {
|
||||
if (!process.stdin.isTTY) {
|
||||
stdinData = await readStdin();
|
||||
}
|
||||
|
||||
@@ -386,18 +383,7 @@ export async function main() {
|
||||
|
||||
setMaxSizedBoxDebugging(isDebugMode);
|
||||
|
||||
// Check input format early to determine initialization flow
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
|
||||
// For stream-json mode, defer config.initialize() until after the initialize control request
|
||||
// For other modes, initialize normally
|
||||
let initializationResult: InitializationResult | undefined;
|
||||
if (inputFormat !== InputFormat.STREAM_JSON) {
|
||||
initializationResult = await initializeApp(config, settings);
|
||||
}
|
||||
const initializationResult = await initializeApp(config, settings);
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.selectedType ===
|
||||
@@ -431,15 +417,19 @@ export async function main() {
|
||||
settings,
|
||||
startupWarnings,
|
||||
process.cwd(),
|
||||
initializationResult!,
|
||||
initializationResult,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-stream-json mode, initialize config here
|
||||
if (inputFormat !== InputFormat.STREAM_JSON) {
|
||||
await config.initialize();
|
||||
}
|
||||
await config.initialize();
|
||||
|
||||
// Check input format BEFORE reading stdin
|
||||
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
|
||||
// Only read stdin if NOT in stream-json mode
|
||||
// In stream-json mode, stdin is used for protocol messages (control requests, etc.)
|
||||
@@ -452,8 +442,7 @@ export async function main() {
|
||||
}
|
||||
|
||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||
(argv.authType as AuthType) ||
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
config,
|
||||
settings,
|
||||
|
||||
@@ -110,6 +110,7 @@ export default {
|
||||
'open full Qwen Code documentation in your browser',
|
||||
'Configuration not available.': 'Configuration not available.',
|
||||
'change the auth method': 'change the auth method',
|
||||
'Show quit confirmation dialog': 'Show quit confirmation dialog',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Copy the last result or code snippet to clipboard',
|
||||
|
||||
@@ -689,6 +690,18 @@ export default {
|
||||
'A custom command wants to run the following shell commands:':
|
||||
'A custom command wants to run the following shell commands:',
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs - Quit Confirmation
|
||||
// ============================================================================
|
||||
'What would you like to do before exiting?':
|
||||
'What would you like to do before exiting?',
|
||||
'Quit immediately (/quit)': 'Quit immediately (/quit)',
|
||||
'Generate summary and quit (/summary)':
|
||||
'Generate summary and quit (/summary)',
|
||||
'Save conversation and quit (/chat save)':
|
||||
'Save conversation and quit (/chat save)',
|
||||
'Cancel (stay in application)': 'Cancel (stay in application)',
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs - Pro Quota
|
||||
// ============================================================================
|
||||
|
||||
@@ -108,6 +108,7 @@ export default {
|
||||
'在浏览器中打开完整的 Qwen Code 文档',
|
||||
'Configuration not available.': '配置不可用',
|
||||
'change the auth method': '更改认证方法',
|
||||
'Show quit confirmation dialog': '显示退出确认对话框',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'将最后的结果或代码片段复制到剪贴板',
|
||||
|
||||
@@ -654,6 +655,15 @@ export default {
|
||||
'A custom command wants to run the following shell commands:':
|
||||
'自定义命令想要运行以下 shell 命令:',
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs - Quit Confirmation
|
||||
// ============================================================================
|
||||
'What would you like to do before exiting?': '退出前您想要做什么?',
|
||||
'Quit immediately (/quit)': '立即退出 (/quit)',
|
||||
'Generate summary and quit (/summary)': '生成摘要并退出 (/summary)',
|
||||
'Save conversation and quit (/chat save)': '保存对话并退出 (/chat save)',
|
||||
'Cancel (stay in application)': '取消(留在应用程序中)',
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs - Pro Quota
|
||||
// ============================================================================
|
||||
|
||||
@@ -16,12 +16,9 @@
|
||||
* Controllers:
|
||||
* - SystemController: initialize, interrupt, set_model, supported_commands
|
||||
* - PermissionController: can_use_tool, set_permission_mode
|
||||
* - SdkMcpController: mcp_server_status (mcp_message handled via callback)
|
||||
* - MCPController: mcp_message, mcp_server_status
|
||||
* - HookController: hook_callback
|
||||
*
|
||||
* Note: mcp_message requests are NOT routed through the dispatcher. CLI MCP
|
||||
* clients send messages via SdkMcpController.createSendSdkMcpMessage() callback.
|
||||
*
|
||||
* Note: Control request types are centrally defined in the ControlRequestType
|
||||
* enum in packages/sdk/typescript/src/types/controlRequests.ts
|
||||
*/
|
||||
@@ -29,8 +26,8 @@
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { IPendingRequestRegistry } from './controllers/baseController.js';
|
||||
import { SystemController } from './controllers/systemController.js';
|
||||
import { PermissionController } from './controllers/permissionController.js';
|
||||
import { SdkMcpController } from './controllers/sdkMcpController.js';
|
||||
// import { PermissionController } from './controllers/permissionController.js';
|
||||
// import { MCPController } from './controllers/mcpController.js';
|
||||
// import { HookController } from './controllers/hookController.js';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
@@ -67,8 +64,8 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
|
||||
// Make controllers publicly accessible
|
||||
readonly systemController: SystemController;
|
||||
readonly permissionController: PermissionController;
|
||||
readonly sdkMcpController: SdkMcpController;
|
||||
// readonly permissionController: PermissionController;
|
||||
// readonly mcpController: MCPController;
|
||||
// readonly hookController: HookController;
|
||||
|
||||
// Central pending request registries
|
||||
@@ -86,16 +83,12 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
this,
|
||||
'SystemController',
|
||||
);
|
||||
this.permissionController = new PermissionController(
|
||||
context,
|
||||
this,
|
||||
'PermissionController',
|
||||
);
|
||||
this.sdkMcpController = new SdkMcpController(
|
||||
context,
|
||||
this,
|
||||
'SdkMcpController',
|
||||
);
|
||||
// this.permissionController = new PermissionController(
|
||||
// context,
|
||||
// this,
|
||||
// 'PermissionController',
|
||||
// );
|
||||
// this.mcpController = new MCPController(context, this, 'MCPController');
|
||||
// this.hookController = new HookController(context, this, 'HookController');
|
||||
|
||||
// Listen for main abort signal
|
||||
@@ -235,10 +228,10 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
}
|
||||
this.pendingOutgoingRequests.clear();
|
||||
|
||||
// Cleanup controllers
|
||||
// Cleanup controllers (MCP controller will close all clients)
|
||||
this.systemController.cleanup();
|
||||
this.permissionController.cleanup();
|
||||
this.sdkMcpController.cleanup();
|
||||
// this.permissionController.cleanup();
|
||||
// this.mcpController.cleanup();
|
||||
// this.hookController.cleanup();
|
||||
}
|
||||
|
||||
@@ -298,47 +291,6 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending incoming requests (for debugging)
|
||||
*/
|
||||
getPendingIncomingRequestCount(): number {
|
||||
return this.pendingIncomingRequests.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all incoming request handlers to complete.
|
||||
*
|
||||
* Uses polling since we don't have direct Promise references to handlers.
|
||||
* The pendingIncomingRequests map is managed by BaseController:
|
||||
* - Registered when handler starts (in handleRequest)
|
||||
* - Deregistered when handler completes (success or error)
|
||||
*
|
||||
* @param pollIntervalMs - How often to check (default 50ms)
|
||||
* @param timeoutMs - Maximum wait time (default 30s)
|
||||
*/
|
||||
async waitForPendingIncomingRequests(
|
||||
pollIntervalMs: number = 50,
|
||||
timeoutMs: number = 30000,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (this.pendingIncomingRequests.size > 0) {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] Timeout waiting for ${this.pendingIncomingRequests.size} pending incoming requests`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
if (this.context.debugMode && this.pendingIncomingRequests.size === 0) {
|
||||
console.error('[ControlDispatcher] All incoming requests completed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the controller that handles the given request subtype
|
||||
*/
|
||||
@@ -350,12 +302,13 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
case 'supported_commands':
|
||||
return this.systemController;
|
||||
|
||||
case 'can_use_tool':
|
||||
case 'set_permission_mode':
|
||||
return this.permissionController;
|
||||
// case 'can_use_tool':
|
||||
// case 'set_permission_mode':
|
||||
// return this.permissionController;
|
||||
|
||||
case 'mcp_server_status':
|
||||
return this.sdkMcpController;
|
||||
// case 'mcp_message':
|
||||
// case 'mcp_server_status':
|
||||
// return this.mcpController;
|
||||
|
||||
// case 'hook_callback':
|
||||
// return this.hookController;
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { ControlDispatcher } from './ControlDispatcher.js';
|
||||
import type {
|
||||
PermissionServiceAPI,
|
||||
// PermissionServiceAPI,
|
||||
SystemServiceAPI,
|
||||
// McpServiceAPI,
|
||||
// HookServiceAPI,
|
||||
@@ -61,31 +61,43 @@ export class ControlService {
|
||||
* Handles tool execution permissions, approval checks, and callbacks.
|
||||
* Delegates to the shared PermissionController instance.
|
||||
*/
|
||||
get permission(): PermissionServiceAPI {
|
||||
const controller = this.dispatcher.permissionController;
|
||||
return {
|
||||
/**
|
||||
* Build UI suggestions for tool confirmation dialogs
|
||||
*
|
||||
* Creates actionable permission suggestions based on tool confirmation details.
|
||||
*
|
||||
* @param confirmationDetails - Tool confirmation details
|
||||
* @returns Array of permission suggestions or null
|
||||
*/
|
||||
buildPermissionSuggestions:
|
||||
controller.buildPermissionSuggestions.bind(controller),
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool call status updates
|
||||
*
|
||||
* Returns callback function for integration with CoreToolScheduler.
|
||||
*
|
||||
* @returns Callback function for tool call updates
|
||||
*/
|
||||
getToolCallUpdateCallback:
|
||||
controller.getToolCallUpdateCallback.bind(controller),
|
||||
};
|
||||
}
|
||||
// get permission(): PermissionServiceAPI {
|
||||
// const controller = this.dispatcher.permissionController;
|
||||
// return {
|
||||
// /**
|
||||
// * Check if a tool should be allowed based on current permission settings
|
||||
// *
|
||||
// * Evaluates permission mode and tool registry to determine if execution
|
||||
// * should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
// *
|
||||
// * @param toolRequest - Tool call request information
|
||||
// * @param confirmationDetails - Optional confirmation details for UI
|
||||
// * @returns Permission decision with optional updated arguments
|
||||
// */
|
||||
// shouldAllowTool: controller.shouldAllowTool.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Build UI suggestions for tool confirmation dialogs
|
||||
// *
|
||||
// * Creates actionable permission suggestions based on tool confirmation details.
|
||||
// *
|
||||
// * @param confirmationDetails - Tool confirmation details
|
||||
// * @returns Array of permission suggestions or null
|
||||
// */
|
||||
// buildPermissionSuggestions:
|
||||
// controller.buildPermissionSuggestions.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Get callback for monitoring tool call status updates
|
||||
// *
|
||||
// * Returns callback function for integration with CoreToolScheduler.
|
||||
// *
|
||||
// * @returns Callback function for tool call updates
|
||||
// */
|
||||
// getToolCallUpdateCallback:
|
||||
// controller.getToolCallUpdateCallback.bind(controller),
|
||||
// };
|
||||
// }
|
||||
|
||||
/**
|
||||
* System Domain API
|
||||
|
||||
@@ -117,41 +117,16 @@ export abstract class BaseController {
|
||||
* Send an outgoing control request to SDK
|
||||
*
|
||||
* Manages lifecycle: register -> send -> wait for response -> deregister
|
||||
* Respects the provided AbortSignal for cancellation.
|
||||
*/
|
||||
async sendControlRequest(
|
||||
payload: ControlRequestPayload,
|
||||
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ControlResponse> {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
|
||||
return new Promise<ControlResponse>((resolve, reject) => {
|
||||
// Setup abort handler
|
||||
const abortHandler = () => {
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(new Error('Request aborted'));
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[${this.controllerName}] Outgoing request aborted: ${requestId}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
// Setup timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(new Error('Control request timeout'));
|
||||
if (this.context.debugMode) {
|
||||
@@ -161,27 +136,12 @@ export abstract class BaseController {
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Wrap resolve/reject to clean up abort listener
|
||||
const wrappedResolve = (response: ControlResponse) => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
const wrappedReject = (error: Error) => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// Register with central registry
|
||||
this.registry.registerOutgoingRequest(
|
||||
requestId,
|
||||
this.controllerName,
|
||||
wrappedResolve,
|
||||
wrappedReject,
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
@@ -195,9 +155,6 @@ export abstract class BaseController {
|
||||
try {
|
||||
this.context.streamJson.send(request);
|
||||
} catch (error) {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(error);
|
||||
}
|
||||
@@ -217,5 +174,7 @@ export abstract class BaseController {
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
cleanup(): void {}
|
||||
cleanup(): void {
|
||||
// Subclasses can override to add cleanup logic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP Controller
|
||||
*
|
||||
* Handles MCP-related control requests:
|
||||
* - mcp_message: Route MCP messages
|
||||
* - mcp_server_status: Return MCP server status
|
||||
*/
|
||||
|
||||
import { BaseController } from './baseController.js';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlMcpMessageRequest,
|
||||
} from '../../types.js';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
WorkspaceContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
connectToMcpServer,
|
||||
MCP_DEFAULT_TIMEOUT_MSEC,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class MCPController extends BaseController {
|
||||
/**
|
||||
* Handle MCP control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'mcp_message':
|
||||
return this.handleMcpMessage(payload as CLIControlMcpMessageRequest);
|
||||
|
||||
case 'mcp_server_status':
|
||||
return this.handleMcpStatus();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in MCPController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_message request
|
||||
*
|
||||
* Routes JSON-RPC messages to MCP servers
|
||||
*/
|
||||
private async handleMcpMessage(
|
||||
payload: CLIControlMcpMessageRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const serverNameRaw = payload.server_name;
|
||||
if (
|
||||
typeof serverNameRaw !== 'string' ||
|
||||
serverNameRaw.trim().length === 0
|
||||
) {
|
||||
throw new Error('Missing server_name in mcp_message request');
|
||||
}
|
||||
|
||||
const message = payload.message;
|
||||
if (!message || typeof message !== 'object') {
|
||||
throw new Error(
|
||||
'Missing or invalid message payload for mcp_message request',
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create MCP client
|
||||
let clientEntry: { client: Client; config: MCPServerConfig };
|
||||
try {
|
||||
clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim());
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to connect to MCP server',
|
||||
);
|
||||
}
|
||||
|
||||
const method = message.method;
|
||||
if (typeof method !== 'string' || method.trim().length === 0) {
|
||||
throw new Error('Invalid MCP message: missing method');
|
||||
}
|
||||
|
||||
const jsonrpcVersion =
|
||||
typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0';
|
||||
const messageId = message.id;
|
||||
const params = message.params;
|
||||
const timeout =
|
||||
typeof clientEntry.config.timeout === 'number'
|
||||
? clientEntry.config.timeout
|
||||
: MCP_DEFAULT_TIMEOUT_MSEC;
|
||||
|
||||
try {
|
||||
// Handle notification (no id)
|
||||
if (messageId === undefined) {
|
||||
await clientEntry.client.notification({
|
||||
method,
|
||||
params,
|
||||
});
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: null,
|
||||
result: { success: true, acknowledged: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle request (with id)
|
||||
const result = await clientEntry.client.request(
|
||||
{
|
||||
method,
|
||||
params,
|
||||
},
|
||||
ResultSchema,
|
||||
{ timeout },
|
||||
);
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId,
|
||||
result,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If connection closed, remove from cache
|
||||
if (error instanceof Error && /closed/i.test(error.message)) {
|
||||
this.context.mcpClients.delete(serverNameRaw.trim());
|
||||
}
|
||||
|
||||
const errorCode =
|
||||
typeof (error as { code?: unknown })?.code === 'number'
|
||||
? ((error as { code: number }).code as number)
|
||||
: -32603;
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to execute MCP request';
|
||||
const errorData = (error as { data?: unknown })?.data;
|
||||
|
||||
const errorBody: Record<string, unknown> = {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
};
|
||||
if (errorData !== undefined) {
|
||||
errorBody['data'] = errorData;
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId ?? null,
|
||||
error: errorBody,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_server_status request
|
||||
*
|
||||
* Returns status of registered MCP servers
|
||||
*/
|
||||
private async handleMcpStatus(): Promise<Record<string, unknown>> {
|
||||
const status: Record<string, string> = {};
|
||||
|
||||
// Include SDK MCP servers
|
||||
for (const serverName of this.context.sdkMcpServers) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
// Include CLI-managed MCP clients
|
||||
for (const serverName of this.context.mcpClients.keys()) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] MCP status: ${Object.keys(status).length} servers`,
|
||||
);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create MCP client for a server
|
||||
*
|
||||
* Implements lazy connection and caching
|
||||
*/
|
||||
private async getOrCreateMcpClient(
|
||||
serverName: string,
|
||||
): Promise<{ client: Client; config: MCPServerConfig }> {
|
||||
// Check cache first
|
||||
const cached = this.context.mcpClients.get(serverName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get server configuration
|
||||
const provider = this.context.config as unknown as {
|
||||
getMcpServers?: () => Record<string, MCPServerConfig> | undefined;
|
||||
getDebugMode?: () => boolean;
|
||||
getWorkspaceContext?: () => unknown;
|
||||
};
|
||||
|
||||
if (typeof provider.getMcpServers !== 'function') {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const servers = provider.getMcpServers() ?? {};
|
||||
const serverConfig = servers[serverName];
|
||||
if (!serverConfig) {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const debugMode =
|
||||
typeof provider.getDebugMode === 'function'
|
||||
? provider.getDebugMode()
|
||||
: false;
|
||||
|
||||
const workspaceContext =
|
||||
typeof provider.getWorkspaceContext === 'function'
|
||||
? provider.getWorkspaceContext()
|
||||
: undefined;
|
||||
|
||||
if (!workspaceContext) {
|
||||
throw new Error('Workspace context is not available for MCP connection');
|
||||
}
|
||||
|
||||
// Connect to MCP server
|
||||
const client = await connectToMcpServer(
|
||||
serverName,
|
||||
serverConfig,
|
||||
debugMode,
|
||||
workspaceContext as WorkspaceContext,
|
||||
);
|
||||
|
||||
// Cache the client
|
||||
const entry = { client, config: serverConfig };
|
||||
this.context.mcpClients.set(serverName, entry);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[MCPController] Connected to MCP server: ${serverName}`);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup MCP clients
|
||||
*/
|
||||
override cleanup(): void {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`,
|
||||
);
|
||||
}
|
||||
|
||||
// Close all MCP clients
|
||||
for (const [serverName, { client }] of this.context.mcpClients.entries()) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Failed to close MCP client ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.context.mcpClients.clear();
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,8 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
WaitingToolCall,
|
||||
ToolExecuteConfirmationDetails,
|
||||
ToolMcpConfirmationDetails,
|
||||
ApprovalMode,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
InputFormat,
|
||||
@@ -44,23 +42,15 @@ export class PermissionController extends BaseController {
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
signal: AbortSignal,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'can_use_tool':
|
||||
return this.handleCanUseTool(
|
||||
payload as CLIControlPermissionRequest,
|
||||
signal,
|
||||
);
|
||||
return this.handleCanUseTool(payload as CLIControlPermissionRequest);
|
||||
|
||||
case 'set_permission_mode':
|
||||
return this.handleSetPermissionMode(
|
||||
payload as CLIControlSetPermissionModeRequest,
|
||||
signal,
|
||||
);
|
||||
|
||||
default:
|
||||
@@ -78,12 +68,7 @@ export class PermissionController extends BaseController {
|
||||
*/
|
||||
private async handleCanUseTool(
|
||||
payload: CLIControlPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const toolName = payload.tool_name;
|
||||
if (
|
||||
!toolName ||
|
||||
@@ -205,12 +190,7 @@ export class PermissionController extends BaseController {
|
||||
*/
|
||||
private async handleSetPermissionMode(
|
||||
payload: CLIControlSetPermissionModeRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const mode = payload.mode;
|
||||
const validModes: PermissionMode[] = [
|
||||
'default',
|
||||
@@ -226,7 +206,6 @@ export class PermissionController extends BaseController {
|
||||
}
|
||||
|
||||
this.context.permissionMode = mode;
|
||||
this.context.config.setApprovalMode(mode as ApprovalMode);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
@@ -355,6 +334,47 @@ export class PermissionController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool should be executed based on current permission settings
|
||||
*
|
||||
* This is a convenience method for direct tool execution checks without
|
||||
* going through the control request flow.
|
||||
*/
|
||||
async shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}> {
|
||||
// Check permission mode
|
||||
const modeResult = this.checkPermissionMode();
|
||||
if (!modeResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: modeResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Check tool registry
|
||||
const registryResult = this.checkToolRegistry(toolRequest.name);
|
||||
if (!registryResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: registryResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// If we have confirmation details, we could potentially modify args
|
||||
// This is a hook for future enhancement
|
||||
if (confirmationDetails) {
|
||||
// Future: handle argument modifications based on confirmation details
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool calls and handling outgoing permission requests
|
||||
* This is passed to executeToolCall to hook into CoreToolScheduler updates
|
||||
@@ -391,14 +411,6 @@ export class PermissionController extends BaseController {
|
||||
toolCall: WaitingToolCall,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if already aborted
|
||||
if (this.context.abortSignal?.aborted) {
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const inputFormat = this.context.config.getInputFormat?.();
|
||||
const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON;
|
||||
|
||||
@@ -427,8 +439,7 @@ export class PermissionController extends BaseController {
|
||||
permission_suggestions: permissionSuggestions,
|
||||
blocked_path: null,
|
||||
} as CLIControlPermissionRequest,
|
||||
undefined, // use default timeout
|
||||
this.context.abortSignal,
|
||||
30000,
|
||||
);
|
||||
|
||||
if (response.subtype !== 'success') {
|
||||
@@ -451,15 +462,8 @@ export class PermissionController extends BaseController {
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
} else {
|
||||
// Extract cancel message from response if available
|
||||
const cancelMessage =
|
||||
typeof payload['message'] === 'string'
|
||||
? payload['message']
|
||||
: undefined;
|
||||
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
cancelMessage ? { cancelMessage } : undefined,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -469,23 +473,9 @@ export class PermissionController extends BaseController {
|
||||
error,
|
||||
);
|
||||
}
|
||||
// On error, use default cancel message
|
||||
// Only pass payload for exec and mcp types that support it
|
||||
const confirmationType = toolCall.confirmationDetails.type;
|
||||
if (['edit', 'exec', 'mcp'].includes(confirmationType)) {
|
||||
const execOrMcpDetails = toolCall.confirmationDetails as
|
||||
| ToolExecuteConfirmationDetails
|
||||
| ToolMcpConfirmationDetails;
|
||||
await execOrMcpDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
undefined,
|
||||
);
|
||||
} else {
|
||||
// For other types, don't pass payload (backward compatible)
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
}
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
} finally {
|
||||
this.pendingOutgoingRequests.delete(toolCall.request.callId);
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* SDK MCP Controller
|
||||
*
|
||||
* Handles MCP communication between CLI MCP clients and SDK MCP servers:
|
||||
* - Provides sendSdkMcpMessage callback for CLI → SDK MCP message routing
|
||||
* - mcp_server_status: Returns status of SDK MCP servers
|
||||
*
|
||||
* Message Flow (CLI MCP Client → SDK MCP Server):
|
||||
* CLI MCP Client → SdkControlClientTransport.send() →
|
||||
* sendSdkMcpMessage callback → control_request (mcp_message) → SDK →
|
||||
* SDK MCP Server processes → control_response → CLI MCP Client
|
||||
*/
|
||||
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { BaseController } from './baseController.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlMcpMessageRequest,
|
||||
} from '../../types.js';
|
||||
|
||||
const MCP_REQUEST_TIMEOUT = 30_000; // 30 seconds
|
||||
|
||||
export class SdkMcpController extends BaseController {
|
||||
/**
|
||||
* Handle SDK MCP control requests from ControlDispatcher
|
||||
*
|
||||
* Note: mcp_message requests are NOT handled here. CLI MCP clients
|
||||
* send messages via the sendSdkMcpMessage callback directly, not
|
||||
* through the control dispatcher.
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'mcp_server_status':
|
||||
return this.handleMcpStatus();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in SdkMcpController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_server_status request
|
||||
*
|
||||
* Returns status of all registered SDK MCP servers.
|
||||
* SDK servers are considered "connected" if they are registered.
|
||||
*/
|
||||
private async handleMcpStatus(): Promise<Record<string, unknown>> {
|
||||
const status: Record<string, string> = {};
|
||||
|
||||
for (const serverName of this.context.sdkMcpServers) {
|
||||
// SDK MCP servers are "connected" once registered since they run in SDK process
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'mcp_server_status',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send MCP message to SDK server via control plane
|
||||
*
|
||||
* @param serverName - Name of the SDK MCP server
|
||||
* @param message - MCP JSON-RPC message to send
|
||||
* @returns MCP JSON-RPC response from SDK server
|
||||
*/
|
||||
private async sendMcpMessageToSdk(
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
): Promise<JSONRPCMessage> {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SdkMcpController] Sending MCP message to SDK server '${serverName}':`,
|
||||
JSON.stringify(message),
|
||||
);
|
||||
}
|
||||
|
||||
// Send control request to SDK with the MCP message
|
||||
const response = await this.sendControlRequest(
|
||||
{
|
||||
subtype: 'mcp_message',
|
||||
server_name: serverName,
|
||||
message: message as CLIControlMcpMessageRequest['message'],
|
||||
},
|
||||
MCP_REQUEST_TIMEOUT,
|
||||
this.context.abortSignal,
|
||||
);
|
||||
|
||||
// Extract MCP response from control response
|
||||
const responsePayload = response.response as Record<string, unknown>;
|
||||
const mcpResponse = responsePayload?.['mcp_response'] as JSONRPCMessage;
|
||||
|
||||
if (!mcpResponse) {
|
||||
throw new Error(
|
||||
`Invalid MCP response from SDK for server '${serverName}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SdkMcpController] Received MCP response from SDK server '${serverName}':`,
|
||||
JSON.stringify(mcpResponse),
|
||||
);
|
||||
}
|
||||
|
||||
return mcpResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a callback function for sending MCP messages to SDK servers.
|
||||
*
|
||||
* This callback is used by McpClientManager/SdkControlClientTransport to send
|
||||
* MCP messages from CLI MCP clients to SDK MCP servers via the control plane.
|
||||
*
|
||||
* @returns A function that sends MCP messages to SDK and returns the response
|
||||
*/
|
||||
createSendSdkMcpMessage(): (
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
) => Promise<JSONRPCMessage> {
|
||||
return (serverName: string, message: JSONRPCMessage) =>
|
||||
this.sendMcpMessageToSdk(serverName, message);
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,7 @@ import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlInitializeRequest,
|
||||
CLIControlSetModelRequest,
|
||||
CLIMcpServerConfig,
|
||||
} from '../../types.js';
|
||||
import { CommandService } from '../../../services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js';
|
||||
import {
|
||||
MCPServerConfig,
|
||||
AuthProviderType,
|
||||
type MCPOAuthConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class SystemController extends BaseController {
|
||||
/**
|
||||
@@ -34,30 +26,20 @@ export class SystemController extends BaseController {
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
signal: AbortSignal,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'initialize':
|
||||
return this.handleInitialize(
|
||||
payload as CLIControlInitializeRequest,
|
||||
signal,
|
||||
);
|
||||
return this.handleInitialize(payload as CLIControlInitializeRequest);
|
||||
|
||||
case 'interrupt':
|
||||
return this.handleInterrupt();
|
||||
|
||||
case 'set_model':
|
||||
return this.handleSetModel(
|
||||
payload as CLIControlSetModelRequest,
|
||||
signal,
|
||||
);
|
||||
return this.handleSetModel(payload as CLIControlSetModelRequest);
|
||||
|
||||
case 'supported_commands':
|
||||
return this.handleSupportedCommands(signal);
|
||||
return this.handleSupportedCommands();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in SystemController`);
|
||||
@@ -67,130 +49,15 @@ export class SystemController extends BaseController {
|
||||
/**
|
||||
* Handle initialize request
|
||||
*
|
||||
* Processes SDK MCP servers config.
|
||||
* SDK servers are registered in context.sdkMcpServers
|
||||
* and added to config.mcpServers with the sdk type flag.
|
||||
* External MCP servers are configured separately in settings.
|
||||
* Registers SDK MCP servers and returns capabilities
|
||||
*/
|
||||
private async handleInitialize(
|
||||
payload: CLIControlInitializeRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
this.context.config.setSdkMode(true);
|
||||
|
||||
// Process SDK MCP servers
|
||||
if (
|
||||
payload.sdkMcpServers &&
|
||||
typeof payload.sdkMcpServers === 'object' &&
|
||||
payload.sdkMcpServers !== null
|
||||
) {
|
||||
const sdkServers: Record<string, MCPServerConfig> = {};
|
||||
for (const [key, wireConfig] of Object.entries(payload.sdkMcpServers)) {
|
||||
const name =
|
||||
typeof wireConfig?.name === 'string' && wireConfig.name.trim().length
|
||||
? wireConfig.name
|
||||
: key;
|
||||
|
||||
this.context.sdkMcpServers.add(name);
|
||||
sdkServers[name] = new MCPServerConfig(
|
||||
undefined, // command
|
||||
undefined, // args
|
||||
undefined, // env
|
||||
undefined, // cwd
|
||||
undefined, // url
|
||||
undefined, // httpUrl
|
||||
undefined, // headers
|
||||
undefined, // tcp
|
||||
undefined, // timeout
|
||||
true, // trust - SDK servers are trusted
|
||||
undefined, // description
|
||||
undefined, // includeTools
|
||||
undefined, // excludeTools
|
||||
undefined, // extensionName
|
||||
undefined, // oauth
|
||||
undefined, // authProviderType
|
||||
undefined, // targetAudience
|
||||
undefined, // targetServiceAccount
|
||||
'sdk', // type
|
||||
);
|
||||
}
|
||||
|
||||
const sdkServerCount = Object.keys(sdkServers).length;
|
||||
if (sdkServerCount > 0) {
|
||||
try {
|
||||
this.context.config.addMcpServers(sdkServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${sdkServerCount} SDK MCP servers to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add SDK MCP servers:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
payload.mcpServers &&
|
||||
typeof payload.mcpServers === 'object' &&
|
||||
payload.mcpServers !== null
|
||||
) {
|
||||
const externalServers: Record<string, MCPServerConfig> = {};
|
||||
for (const [name, serverConfig] of Object.entries(payload.mcpServers)) {
|
||||
const normalized = this.normalizeMcpServerConfig(
|
||||
name,
|
||||
serverConfig as CLIMcpServerConfig | undefined,
|
||||
);
|
||||
if (normalized) {
|
||||
externalServers[name] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
const externalCount = Object.keys(externalServers).length;
|
||||
if (externalCount > 0) {
|
||||
try {
|
||||
this.context.config.addMcpServers(externalServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${externalCount} external MCP servers to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add external MCP servers:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.agents && Array.isArray(payload.agents)) {
|
||||
try {
|
||||
this.context.config.setSessionSubagents(payload.agents);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${payload.agents.length} session subagents to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add session subagents:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
// Register SDK MCP servers if provided
|
||||
if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) {
|
||||
for (const serverName of payload.sdkMcpServers) {
|
||||
this.context.sdkMcpServers.add(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,98 +86,36 @@ export class SystemController extends BaseController {
|
||||
buildControlCapabilities(): Record<string, unknown> {
|
||||
const capabilities: Record<string, unknown> = {
|
||||
can_handle_can_use_tool: true,
|
||||
can_handle_hook_callback: false,
|
||||
can_handle_hook_callback: true,
|
||||
can_set_permission_mode:
|
||||
typeof this.context.config.setApprovalMode === 'function',
|
||||
can_set_model: typeof this.context.config.setModel === 'function',
|
||||
// SDK MCP servers are supported - messages routed through control plane
|
||||
can_handle_mcp_message: true,
|
||||
};
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
private normalizeMcpServerConfig(
|
||||
serverName: string,
|
||||
config?: CLIMcpServerConfig,
|
||||
): MCPServerConfig | null {
|
||||
if (!config || typeof config !== 'object') {
|
||||
// Check if MCP message handling is available
|
||||
try {
|
||||
const mcpProvider = this.context.config as unknown as {
|
||||
getMcpServers?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
if (typeof mcpProvider.getMcpServers === 'function') {
|
||||
const servers = mcpProvider.getMcpServers();
|
||||
capabilities['can_handle_mcp_message'] = Boolean(
|
||||
servers && Object.keys(servers).length > 0,
|
||||
);
|
||||
} else {
|
||||
capabilities['can_handle_mcp_message'] = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Ignoring invalid MCP server config for '${serverName}'`,
|
||||
'[SystemController] Failed to determine MCP capability:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
capabilities['can_handle_mcp_message'] = false;
|
||||
}
|
||||
|
||||
const authProvider = this.normalizeAuthProviderType(
|
||||
config.authProviderType,
|
||||
);
|
||||
const oauthConfig = this.normalizeOAuthConfig(config.oauth);
|
||||
|
||||
return new MCPServerConfig(
|
||||
config.command,
|
||||
config.args,
|
||||
config.env,
|
||||
config.cwd,
|
||||
config.url,
|
||||
config.httpUrl,
|
||||
config.headers,
|
||||
config.tcp,
|
||||
config.timeout,
|
||||
config.trust,
|
||||
config.description,
|
||||
config.includeTools,
|
||||
config.excludeTools,
|
||||
config.extensionName,
|
||||
oauthConfig,
|
||||
authProvider,
|
||||
config.targetAudience,
|
||||
config.targetServiceAccount,
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeAuthProviderType(
|
||||
value?: string,
|
||||
): AuthProviderType | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case AuthProviderType.DYNAMIC_DISCOVERY:
|
||||
case AuthProviderType.GOOGLE_CREDENTIALS:
|
||||
case AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION:
|
||||
return value;
|
||||
default:
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Unsupported authProviderType '${value}', skipping`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeOAuthConfig(
|
||||
oauth?: CLIMcpServerConfig['oauth'],
|
||||
): MCPOAuthConfig | undefined {
|
||||
if (!oauth) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: oauth.enabled,
|
||||
clientId: oauth.clientId,
|
||||
clientSecret: oauth.clientSecret,
|
||||
authorizationUrl: oauth.authorizationUrl,
|
||||
tokenUrl: oauth.tokenUrl,
|
||||
scopes: oauth.scopes,
|
||||
audiences: oauth.audiences,
|
||||
redirectUri: oauth.redirectUri,
|
||||
tokenParamName: oauth.tokenParamName,
|
||||
registrationUrl: oauth.registrationUrl,
|
||||
};
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,12 +151,7 @@ export class SystemController extends BaseController {
|
||||
*/
|
||||
private async handleSetModel(
|
||||
payload: CLIControlSetModelRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const model = payload.model;
|
||||
|
||||
// Validate model parameter
|
||||
@@ -389,63 +189,27 @@ export class SystemController extends BaseController {
|
||||
/**
|
||||
* Handle supported_commands request
|
||||
*
|
||||
* Returns list of supported slash commands loaded dynamically
|
||||
* Returns list of supported control commands
|
||||
*
|
||||
* Note: This list should match the ControlRequestType enum in
|
||||
* packages/sdk/typescript/src/types/controlRequests.ts
|
||||
*/
|
||||
private async handleSupportedCommands(
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const slashCommands = await this.loadSlashCommandNames(signal);
|
||||
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
|
||||
const commands = [
|
||||
'initialize',
|
||||
'interrupt',
|
||||
'set_model',
|
||||
'supported_commands',
|
||||
'can_use_tool',
|
||||
'set_permission_mode',
|
||||
'mcp_message',
|
||||
'mcp_server_status',
|
||||
'hook_callback',
|
||||
];
|
||||
|
||||
return {
|
||||
subtype: 'supported_commands',
|
||||
commands: slashCommands,
|
||||
commands,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load slash command names using CommandService
|
||||
*
|
||||
* @param signal - AbortSignal to respect for cancellation
|
||||
* @returns Promise resolving to array of slash command names
|
||||
*/
|
||||
private async loadSlashCommandNames(signal: AbortSignal): Promise<string[]> {
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const service = await CommandService.create(
|
||||
[new BuiltinCommandLoader(this.context.config)],
|
||||
signal,
|
||||
);
|
||||
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
const commands = service.getCommands();
|
||||
for (const command of commands) {
|
||||
names.add(command.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
} catch (error) {
|
||||
// Check if the error is due to abort
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to load slash commands:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
*/
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
MCPServerConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { PermissionSuggestion } from '../../types.js';
|
||||
|
||||
/**
|
||||
@@ -23,6 +26,25 @@ import type { PermissionSuggestion } from '../../types.js';
|
||||
* permission suggestions, and tool call monitoring callbacks.
|
||||
*/
|
||||
export interface PermissionServiceAPI {
|
||||
/**
|
||||
* Check if a tool should be allowed based on current permission settings
|
||||
*
|
||||
* Evaluates permission mode and tool registry to determine if execution
|
||||
* should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
*
|
||||
* @param toolRequest - Tool call request information containing name, args, and call ID
|
||||
* @param confirmationDetails - Optional confirmation details for UI-driven approvals
|
||||
* @returns Promise resolving to permission decision with optional updated arguments
|
||||
*/
|
||||
shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Build UI suggestions for tool confirmation dialogs
|
||||
*
|
||||
|
||||
@@ -13,11 +13,7 @@ import type {
|
||||
ServerGeminiStreamEvent,
|
||||
TaskResultDisplay,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
GeminiEventType,
|
||||
ToolErrorType,
|
||||
parseAndFormatApiError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType, ToolErrorType } from '@qwen-code/qwen-code-core';
|
||||
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type {
|
||||
CLIAssistantMessage,
|
||||
@@ -604,18 +600,6 @@ export abstract class BaseJsonOutputAdapter {
|
||||
}
|
||||
this.finalizePendingBlocks(state, null);
|
||||
break;
|
||||
case GeminiEventType.Error: {
|
||||
// Format the error message using parseAndFormatApiError for consistency
|
||||
// with interactive mode error display
|
||||
const errorText = parseAndFormatApiError(
|
||||
event.value.error,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
this.config.getModel(),
|
||||
);
|
||||
this.appendText(state, errorText, null);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -955,25 +939,9 @@ export abstract class BaseJsonOutputAdapter {
|
||||
this.emitMessageImpl(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if responseParts contain any functionResponse with an error.
|
||||
* This handles cancelled responses and other error cases where the error
|
||||
* is embedded in responseParts rather than the top-level error field.
|
||||
* @param responseParts - Array of Part objects
|
||||
* @returns Error message if found, undefined otherwise
|
||||
*/
|
||||
private checkResponsePartsForError(
|
||||
responseParts: Part[] | undefined,
|
||||
): string | undefined {
|
||||
// Use the shared helper function defined at file level
|
||||
return checkResponsePartsForError(responseParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a tool result message.
|
||||
* Collects execution denied tool calls for inclusion in result messages.
|
||||
* Handles both explicit errors (response.error) and errors embedded in
|
||||
* responseParts (e.g., cancelled responses).
|
||||
* @param request - Tool call request info
|
||||
* @param response - Tool call response info
|
||||
* @param parentToolUseId - Parent tool use ID (null for main agent)
|
||||
@@ -983,14 +951,6 @@ export abstract class BaseJsonOutputAdapter {
|
||||
response: ToolCallResponseInfo,
|
||||
parentToolUseId: string | null = null,
|
||||
): void {
|
||||
// Check for errors in responseParts (e.g., cancelled responses)
|
||||
const responsePartsError = this.checkResponsePartsForError(
|
||||
response.responseParts,
|
||||
);
|
||||
|
||||
// Determine if this is an error response
|
||||
const hasError = Boolean(response.error) || Boolean(responsePartsError);
|
||||
|
||||
// Track permission denials (execution denied errors)
|
||||
if (
|
||||
response.error &&
|
||||
@@ -1007,7 +967,7 @@ export abstract class BaseJsonOutputAdapter {
|
||||
const block: ToolResultBlock = {
|
||||
type: 'tool_result',
|
||||
tool_use_id: request.callId,
|
||||
is_error: hasError,
|
||||
is_error: Boolean(response.error),
|
||||
};
|
||||
const content = toolResultContent(response);
|
||||
if (content !== undefined) {
|
||||
@@ -1213,41 +1173,11 @@ export function partsToString(parts: Part[]): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if responseParts contain any functionResponse with an error.
|
||||
* Helper function for extracting error messages from responseParts.
|
||||
* @param responseParts - Array of Part objects
|
||||
* @returns Error message if found, undefined otherwise
|
||||
*/
|
||||
function checkResponsePartsForError(
|
||||
responseParts: Part[] | undefined,
|
||||
): string | undefined {
|
||||
if (!responseParts || responseParts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of responseParts) {
|
||||
if (
|
||||
'functionResponse' in part &&
|
||||
part.functionResponse?.response &&
|
||||
typeof part.functionResponse.response === 'object' &&
|
||||
'error' in part.functionResponse.response &&
|
||||
part.functionResponse.response['error']
|
||||
) {
|
||||
const error = part.functionResponse.response['error'];
|
||||
return typeof error === 'string' ? error : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts content from tool response.
|
||||
* Uses functionResponsePartsToString to properly handle functionResponse parts,
|
||||
* which correctly extracts output content from functionResponse objects rather
|
||||
* than simply concatenating text or JSON.stringify.
|
||||
* Also handles errors embedded in responseParts (e.g., cancelled responses).
|
||||
*
|
||||
* @param response - Tool call response
|
||||
* @returns String content or undefined
|
||||
@@ -1258,11 +1188,6 @@ export function toolResultContent(
|
||||
if (response.error) {
|
||||
return response.error.message;
|
||||
}
|
||||
// Check for errors in responseParts (e.g., cancelled responses)
|
||||
const responsePartsError = checkResponsePartsForError(response.responseParts);
|
||||
if (responsePartsError) {
|
||||
return responsePartsError;
|
||||
}
|
||||
if (
|
||||
typeof response.resultDisplay === 'string' &&
|
||||
response.resultDisplay.trim().length > 0
|
||||
|
||||
@@ -69,7 +69,6 @@ function createConfig(overrides: ConfigOverrides = {}): Config {
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => 'auto',
|
||||
getOutputFormat: () => 'stream-json',
|
||||
initialize: vi.fn(),
|
||||
};
|
||||
return { ...base, ...overrides } as unknown as Config;
|
||||
}
|
||||
@@ -153,11 +152,6 @@ describe('runNonInteractiveStreamJson', () => {
|
||||
handleControlResponse: ReturnType<typeof vi.fn>;
|
||||
handleCancel: ReturnType<typeof vi.fn>;
|
||||
shutdown: ReturnType<typeof vi.fn>;
|
||||
getPendingIncomingRequestCount: ReturnType<typeof vi.fn>;
|
||||
waitForPendingIncomingRequests: ReturnType<typeof vi.fn>;
|
||||
sdkMcpController: {
|
||||
createSendSdkMcpMessage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
let mockConsolePatcher: {
|
||||
patch: ReturnType<typeof vi.fn>;
|
||||
@@ -192,11 +186,6 @@ describe('runNonInteractiveStreamJson', () => {
|
||||
handleControlResponse: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
getPendingIncomingRequestCount: vi.fn().mockReturnValue(0),
|
||||
waitForPendingIncomingRequests: vi.fn().mockResolvedValue(undefined),
|
||||
sdkMcpController: {
|
||||
createSendSdkMcpMessage: vi.fn().mockReturnValue(vi.fn()),
|
||||
},
|
||||
};
|
||||
(
|
||||
ControlDispatcher as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -4,10 +4,18 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ConfigInitializeOptions,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
/**
|
||||
* Stream JSON Runner with Session State Machine
|
||||
*
|
||||
* Handles stream-json input/output format with:
|
||||
* - Initialize handshake
|
||||
* - Message routing (control vs user messages)
|
||||
* - FIFO user message queue
|
||||
* - Sequential message processing
|
||||
* - Graceful shutdown
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
|
||||
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
|
||||
import { ControlContext } from './control/ControlContext.js';
|
||||
@@ -34,7 +42,48 @@ import { createMinimalSettings } from '../config/settings.js';
|
||||
import { runNonInteractive } from '../nonInteractiveCli.js';
|
||||
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||
|
||||
class Session {
|
||||
const SESSION_STATE = {
|
||||
INITIALIZING: 'initializing',
|
||||
IDLE: 'idle',
|
||||
PROCESSING_QUERY: 'processing_query',
|
||||
SHUTTING_DOWN: 'shutting_down',
|
||||
} as const;
|
||||
|
||||
type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE];
|
||||
|
||||
/**
|
||||
* Message type classification for routing
|
||||
*/
|
||||
type MessageType =
|
||||
| 'control_request'
|
||||
| 'control_response'
|
||||
| 'control_cancel'
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'system'
|
||||
| 'result'
|
||||
| 'stream_event'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Routed message with classification
|
||||
*/
|
||||
interface RoutedMessage {
|
||||
type: MessageType;
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session Manager
|
||||
*
|
||||
* Manages the session lifecycle and message processing state machine.
|
||||
*/
|
||||
class SessionManager {
|
||||
private state: SessionState = SESSION_STATE.INITIALIZING;
|
||||
private userMessageQueue: CLIUserMessage[] = [];
|
||||
private abortController: AbortController;
|
||||
private config: Config;
|
||||
@@ -49,15 +98,6 @@ class Session {
|
||||
private debugMode: boolean;
|
||||
private shutdownHandler: (() => void) | null = null;
|
||||
private initialPrompt: CLIUserMessage | null = null;
|
||||
private processingPromise: Promise<void> | null = null;
|
||||
private isShuttingDown: boolean = false;
|
||||
private configInitialized: boolean = false;
|
||||
|
||||
// Single initialization promise that resolves when session is ready for user messages.
|
||||
// Created lazily once initialization actually starts.
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private initializationResolve: (() => void) | null = null;
|
||||
private initializationReject: ((error: Error) => void) | null = null;
|
||||
|
||||
constructor(config: Config, initialPrompt?: CLIUserMessage) {
|
||||
this.config = config;
|
||||
@@ -72,96 +112,161 @@ class Session {
|
||||
config.getIncludePartialMessages(),
|
||||
);
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
this.setupSignalHandlers();
|
||||
}
|
||||
|
||||
private ensureInitializationPromise(): void {
|
||||
if (this.initializationPromise) {
|
||||
return;
|
||||
}
|
||||
this.initializationPromise = new Promise<void>((resolve, reject) => {
|
||||
this.initializationResolve = () => {
|
||||
resolve();
|
||||
this.initializationResolve = null;
|
||||
this.initializationReject = null;
|
||||
};
|
||||
this.initializationReject = (error: Error) => {
|
||||
reject(error);
|
||||
this.initializationResolve = null;
|
||||
this.initializationReject = null;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next prompt ID
|
||||
*/
|
||||
private getNextPromptId(): string {
|
||||
this.promptIdCounter++;
|
||||
return `${this.sessionId}########${this.promptIdCounter}`;
|
||||
}
|
||||
|
||||
private async ensureConfigInitialized(
|
||||
options?: ConfigInitializeOptions,
|
||||
): Promise<void> {
|
||||
if (this.configInitialized) {
|
||||
return;
|
||||
/**
|
||||
* Route a message to the appropriate handler based on its type
|
||||
*
|
||||
* Classifies incoming messages and routes them to appropriate handlers.
|
||||
*/
|
||||
private route(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): RoutedMessage {
|
||||
// Check control messages first
|
||||
if (isControlRequest(message)) {
|
||||
return { type: 'control_request', message };
|
||||
}
|
||||
if (isControlResponse(message)) {
|
||||
return { type: 'control_response', message };
|
||||
}
|
||||
if (isControlCancel(message)) {
|
||||
return { type: 'control_cancel', message };
|
||||
}
|
||||
|
||||
// Check data messages
|
||||
if (isCLIUserMessage(message)) {
|
||||
return { type: 'user', message };
|
||||
}
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
return { type: 'assistant', message };
|
||||
}
|
||||
if (isCLISystemMessage(message)) {
|
||||
return { type: 'system', message };
|
||||
}
|
||||
if (isCLIResultMessage(message)) {
|
||||
return { type: 'result', message };
|
||||
}
|
||||
if (isCLIPartialAssistantMessage(message)) {
|
||||
return { type: 'stream_event', message };
|
||||
}
|
||||
|
||||
// Unknown message type
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Initializing config');
|
||||
console.error(
|
||||
'[SessionManager] Unknown message type:',
|
||||
JSON.stringify(message, null, 2),
|
||||
);
|
||||
}
|
||||
return { type: 'unknown', message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message with unified logic for both initial prompt and stream messages.
|
||||
*
|
||||
* Handles:
|
||||
* - Abort check
|
||||
* - First message detection and handling
|
||||
* - Normal message processing
|
||||
* - Shutdown state checks
|
||||
*
|
||||
* @param message - Message to process
|
||||
* @returns true if the calling code should exit (break/return), false to continue
|
||||
*/
|
||||
private async processSingleMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<boolean> {
|
||||
// Check for abort
|
||||
if (this.abortController.signal.aborted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle first message if control system not yet initialized
|
||||
if (this.controlSystemEnabled === null) {
|
||||
const handled = await this.handleFirstMessage(message);
|
||||
if (handled) {
|
||||
// If handled, check if we should shutdown
|
||||
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||
}
|
||||
// If not handled, fall through to normal processing
|
||||
}
|
||||
|
||||
// Process message normally
|
||||
await this.processMessage(message);
|
||||
|
||||
// Check for shutdown after processing
|
||||
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point - run the session
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
await this.config.initialize(options);
|
||||
this.configInitialized = true;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Starting session', this.sessionId);
|
||||
}
|
||||
|
||||
// Process initial prompt if provided
|
||||
if (this.initialPrompt !== null) {
|
||||
const shouldExit = await this.processSingleMessage(this.initialPrompt);
|
||||
if (shouldExit) {
|
||||
await this.shutdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Process messages from stream
|
||||
for await (const message of this.inputReader.read()) {
|
||||
const shouldExit = await this.processSingleMessage(message);
|
||||
if (shouldExit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream closed, shutdown
|
||||
await this.shutdown();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Failed to initialize config:', error);
|
||||
console.error('[SessionManager] Error:', error);
|
||||
}
|
||||
await this.shutdown();
|
||||
throw error;
|
||||
} finally {
|
||||
// Ensure signal handlers are always cleaned up even if shutdown wasn't called
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark initialization as complete
|
||||
*/
|
||||
private completeInitialization(): void {
|
||||
if (this.initializationResolve) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Initialization complete');
|
||||
}
|
||||
this.initializationResolve();
|
||||
this.initializationResolve = null;
|
||||
this.initializationReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark initialization as failed
|
||||
*/
|
||||
private failInitialization(error: Error): void {
|
||||
if (this.initializationReject) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Initialization failed:', error);
|
||||
}
|
||||
this.initializationReject(error);
|
||||
this.initializationResolve = null;
|
||||
this.initializationReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for session to be ready for user messages
|
||||
*/
|
||||
private async waitForInitialization(): Promise<void> {
|
||||
if (!this.initializationPromise) {
|
||||
return;
|
||||
}
|
||||
await this.initializationPromise;
|
||||
}
|
||||
|
||||
private ensureControlSystem(): void {
|
||||
if (this.controlContext && this.dispatcher && this.controlService) {
|
||||
return;
|
||||
}
|
||||
// The control system follows a strict three-layer architecture:
|
||||
// 1. ControlContext (shared session state)
|
||||
// 2. ControlDispatcher (protocol routing SDK ↔ CLI)
|
||||
// 3. ControlService (programmatic API for CLI runtime)
|
||||
//
|
||||
// Application code MUST interact with the control plane exclusively through
|
||||
// ControlService. ControlDispatcher is reserved for protocol-level message
|
||||
// routing and should never be used directly outside of this file.
|
||||
this.controlContext = new ControlContext({
|
||||
config: this.config,
|
||||
streamJson: this.outputAdapter,
|
||||
@@ -187,166 +292,274 @@ class Session {
|
||||
return this.dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the first message to determine session mode (SDK vs direct).
|
||||
* This is synchronous from the message loop's perspective - it starts
|
||||
* async work but does not return a promise that the loop awaits.
|
||||
*
|
||||
* The initialization completes asynchronously and resolves initializationPromise
|
||||
* when ready for user messages.
|
||||
*/
|
||||
private handleFirstMessage(
|
||||
private async handleFirstMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): void {
|
||||
if (isControlRequest(message)) {
|
||||
const request = message as CLIControlRequest;
|
||||
): Promise<boolean> {
|
||||
const routed = this.route(message);
|
||||
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
this.controlSystemEnabled = true;
|
||||
this.ensureControlSystem();
|
||||
|
||||
if (request.request.subtype === 'initialize') {
|
||||
// Start SDK mode initialization (fire-and-forget from loop perspective)
|
||||
void this.initializeSdkMode(request);
|
||||
return;
|
||||
await this.dispatcher?.dispatch(request);
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[Session] Ignoring non-initialize control request during initialization',
|
||||
);
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isCLIUserMessage(message)) {
|
||||
if (routed.type === 'user') {
|
||||
this.controlSystemEnabled = false;
|
||||
// Start direct mode initialization (fire-and-forget from loop perspective)
|
||||
void this.initializeDirectMode(message as CLIUserMessage);
|
||||
return;
|
||||
this.state = SESSION_STATE.PROCESSING_QUERY;
|
||||
this.userMessageQueue.push(routed.message as CLIUserMessage);
|
||||
await this.processUserMessageQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
this.controlSystemEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK mode initialization flow
|
||||
* Dispatches initialize request and initializes config with MCP support
|
||||
* Process a single message from the stream
|
||||
*/
|
||||
private async initializeSdkMode(request: CLIControlRequest): Promise<void> {
|
||||
this.ensureInitializationPromise();
|
||||
try {
|
||||
// Dispatch the initialize request first
|
||||
// This registers SDK MCP servers in the control context
|
||||
await this.dispatcher?.dispatch(request);
|
||||
|
||||
// Get sendSdkMcpMessage callback from SdkMcpController
|
||||
// This callback is used by McpClientManager to send MCP messages
|
||||
// from CLI MCP clients to SDK MCP servers via the control plane
|
||||
const sendSdkMcpMessage =
|
||||
this.dispatcher?.sdkMcpController.createSendSdkMcpMessage();
|
||||
|
||||
// Initialize config with SDK MCP message support
|
||||
await this.ensureConfigInitialized({ sendSdkMcpMessage });
|
||||
|
||||
// Initialization complete!
|
||||
this.completeInitialization();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] SDK mode initialization failed:', error);
|
||||
}
|
||||
this.failInitialization(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct mode initialization flow
|
||||
* Initializes config and enqueues the first user message
|
||||
*/
|
||||
private async initializeDirectMode(
|
||||
userMessage: CLIUserMessage,
|
||||
private async processMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<void> {
|
||||
this.ensureInitializationPromise();
|
||||
try {
|
||||
// Initialize config
|
||||
await this.ensureConfigInitialized();
|
||||
const routed = this.route(message);
|
||||
|
||||
// Initialization complete!
|
||||
this.completeInitialization();
|
||||
|
||||
// Enqueue the first user message for processing
|
||||
this.enqueueUserMessage(userMessage);
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Direct mode initialization failed:', error);
|
||||
}
|
||||
this.failInitialization(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SessionManager] State: ${this.state}, Message type: ${routed.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (this.state) {
|
||||
case SESSION_STATE.INITIALIZING:
|
||||
await this.handleInitializingState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.IDLE:
|
||||
await this.handleIdleState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.PROCESSING_QUERY:
|
||||
await this.handleProcessingState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.SHUTTING_DOWN:
|
||||
// Ignore all messages during shutdown
|
||||
break;
|
||||
|
||||
default: {
|
||||
// Exhaustive check
|
||||
const _exhaustiveCheck: never = this.state;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Unknown state:', _exhaustiveCheck);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control request asynchronously (fire-and-forget from main loop).
|
||||
* Errors are handled internally and responses sent by dispatcher.
|
||||
* Handle messages in initializing state
|
||||
*/
|
||||
private handleControlRequestAsync(request: CLIControlRequest): void {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Control system not enabled');
|
||||
private async handleInitializingState(routed: RoutedMessage): Promise<void> {
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Control request received before control system initialization',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.request.subtype === 'initialize') {
|
||||
await dispatcher.dispatch(request);
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Initialized, transitioning to idle');
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring non-initialize control request during initialization',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring non-control message during initialization',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire-and-forget: dispatch runs concurrently
|
||||
// The dispatcher's pendingIncomingRequests tracks completion
|
||||
void dispatcher.dispatch(request).catch((error) => {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Control request dispatch error:', error);
|
||||
}
|
||||
// Error response is already sent by dispatcher.dispatch()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control response - MUST be synchronous
|
||||
* This resolves pending outgoing requests, breaking the deadlock cycle.
|
||||
* Handle messages in idle state
|
||||
*/
|
||||
private handleControlResponse(response: CLIControlResponse): void {
|
||||
private async handleIdleState(routed: RoutedMessage): Promise<void> {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
if (routed.type === 'control_request') {
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Ignoring control request (disabled)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await dispatcher.dispatch(request);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_response') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const response = routed.message as CLIControlResponse;
|
||||
dispatcher.handleControlResponse(response);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_cancel') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const cancelRequest = routed.message as ControlCancelRequest;
|
||||
dispatcher.handleCancel(cancelRequest.request_id);
|
||||
} else if (routed.type === 'user') {
|
||||
const userMessage = routed.message as CLIUserMessage;
|
||||
this.userMessageQueue.push(userMessage);
|
||||
// Start processing queue
|
||||
await this.processUserMessageQueue();
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring message type in idle state:',
|
||||
routed.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in processing state
|
||||
*/
|
||||
private async handleProcessingState(routed: RoutedMessage): Promise<void> {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (routed.type === 'control_request') {
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Control request ignored during processing (disabled)',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await dispatcher.dispatch(request);
|
||||
// Continue processing
|
||||
} else if (routed.type === 'control_response') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const response = routed.message as CLIControlResponse;
|
||||
dispatcher.handleControlResponse(response);
|
||||
// Continue processing
|
||||
} else if (routed.type === 'user') {
|
||||
// Enqueue for later
|
||||
const userMessage = routed.message as CLIUserMessage;
|
||||
this.userMessageQueue.push(userMessage);
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Enqueued user message during processing',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring message type during processing:',
|
||||
routed.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process user message queue (FIFO)
|
||||
*/
|
||||
private async processUserMessageQueue(): Promise<void> {
|
||||
while (
|
||||
this.userMessageQueue.length > 0 &&
|
||||
!this.abortController.signal.aborted
|
||||
) {
|
||||
this.state = SESSION_STATE.PROCESSING_QUERY;
|
||||
const userMessage = this.userMessageQueue.shift()!;
|
||||
|
||||
try {
|
||||
await this.processUserMessage(userMessage);
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Error processing user message:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
// Send error result
|
||||
this.emitErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
// If control system is disabled (single-query mode) and queue is empty,
|
||||
// automatically shutdown instead of returning to idle
|
||||
if (
|
||||
!this.abortController.signal.aborted &&
|
||||
this.state === SESSION_STATE.PROCESSING_QUERY &&
|
||||
this.controlSystemEnabled === false &&
|
||||
this.userMessageQueue.length === 0
|
||||
) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Single-query mode: queue processed, shutting down',
|
||||
);
|
||||
}
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
}
|
||||
|
||||
private handleControlCancel(cancelRequest: ControlCancelRequest): void {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
// Return to idle after processing queue (for multi-query mode with control system)
|
||||
if (
|
||||
!this.abortController.signal.aborted &&
|
||||
this.state === SESSION_STATE.PROCESSING_QUERY
|
||||
) {
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Queue processed, returning to idle');
|
||||
}
|
||||
}
|
||||
|
||||
dispatcher.handleCancel(cancelRequest.request_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single user message
|
||||
*/
|
||||
private async processUserMessage(userMessage: CLIUserMessage): Promise<void> {
|
||||
const input = extractUserMessageText(userMessage);
|
||||
if (!input) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] No text content in user message');
|
||||
console.error('[SessionManager] No text content in user message');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for initialization to complete before processing user messages
|
||||
await this.waitForInitialization();
|
||||
|
||||
const promptId = this.getNextPromptId();
|
||||
|
||||
try {
|
||||
@@ -362,56 +575,16 @@ class Session {
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Error already handled by runNonInteractive via adapter.emitResult
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Query execution error:', error);
|
||||
console.error('[SessionManager] Query execution error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processUserMessageQueue(): Promise<void> {
|
||||
if (this.isShuttingDown || this.abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (
|
||||
this.userMessageQueue.length > 0 &&
|
||||
!this.isShuttingDown &&
|
||||
!this.abortController.signal.aborted
|
||||
) {
|
||||
const userMessage = this.userMessageQueue.shift()!;
|
||||
try {
|
||||
await this.processUserMessage(userMessage);
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Error processing user message:', error);
|
||||
}
|
||||
this.emitErrorResult(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueUserMessage(userMessage: CLIUserMessage): void {
|
||||
this.userMessageQueue.push(userMessage);
|
||||
this.ensureProcessingStarted();
|
||||
}
|
||||
|
||||
private ensureProcessingStarted(): void {
|
||||
if (this.processingPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingPromise = this.processUserMessageQueue().finally(() => {
|
||||
this.processingPromise = null;
|
||||
if (
|
||||
this.userMessageQueue.length > 0 &&
|
||||
!this.isShuttingDown &&
|
||||
!this.abortController.signal.aborted
|
||||
) {
|
||||
this.ensureProcessingStarted();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tool results as user message
|
||||
*/
|
||||
private emitErrorResult(
|
||||
error: unknown,
|
||||
numTurns: number = 0,
|
||||
@@ -429,21 +602,30 @@ class Session {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle interrupt control request
|
||||
*/
|
||||
private handleInterrupt(): void {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Interrupt requested');
|
||||
console.error('[SessionManager] Interrupt requested');
|
||||
}
|
||||
// Abort current query if processing
|
||||
if (this.state === SESSION_STATE.PROCESSING_QUERY) {
|
||||
this.abortController.abort();
|
||||
this.abortController = new AbortController(); // Create new controller for next query
|
||||
}
|
||||
this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup signal handlers for graceful shutdown
|
||||
*/
|
||||
private setupSignalHandlers(): void {
|
||||
this.shutdownHandler = () => {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Shutdown signal received');
|
||||
console.error('[SessionManager] Shutdown signal received');
|
||||
}
|
||||
this.isShuttingDown = true;
|
||||
this.abortController.abort();
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
};
|
||||
|
||||
process.on('SIGINT', this.shutdownHandler);
|
||||
@@ -451,58 +633,21 @@ class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending work to complete before shutdown
|
||||
* Shutdown session and cleanup resources
|
||||
*/
|
||||
private async waitForAllPendingWork(): Promise<void> {
|
||||
// 1. Wait for initialization to complete (or fail)
|
||||
try {
|
||||
await this.waitForInitialization();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Initialization error during shutdown:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Wait for all control request handlers using dispatcher's tracking
|
||||
if (this.dispatcher) {
|
||||
const pendingCount = this.dispatcher.getPendingIncomingRequestCount();
|
||||
if (pendingCount > 0 && this.debugMode) {
|
||||
console.error(
|
||||
`[Session] Waiting for ${pendingCount} pending control request handlers`,
|
||||
);
|
||||
}
|
||||
await this.dispatcher.waitForPendingIncomingRequests();
|
||||
}
|
||||
|
||||
// 3. Wait for user message processing queue
|
||||
while (this.processingPromise) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Waiting for user message processing');
|
||||
}
|
||||
try {
|
||||
await this.processingPromise;
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Error in user message processing:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async shutdown(): Promise<void> {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Shutting down');
|
||||
console.error('[SessionManager] Shutting down');
|
||||
}
|
||||
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// Wait for all pending work
|
||||
await this.waitForAllPendingWork();
|
||||
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
this.dispatcher?.shutdown();
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove signal handlers to prevent memory leaks
|
||||
*/
|
||||
private cleanupSignalHandlers(): void {
|
||||
if (this.shutdownHandler) {
|
||||
process.removeListener('SIGINT', this.shutdownHandler);
|
||||
@@ -510,105 +655,6 @@ class Session {
|
||||
this.shutdownHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main message processing loop
|
||||
*
|
||||
* CRITICAL: This loop must NEVER await handlers that might need to
|
||||
* send control requests and wait for responses. Such handlers must
|
||||
* be started in fire-and-forget mode, allowing the loop to continue
|
||||
* reading responses that resolve pending requests.
|
||||
*
|
||||
* Message handling order:
|
||||
* 1. control_response - FIRST, synchronously resolves pending requests
|
||||
* 2. First message - determines mode, starts async initialization
|
||||
* 3. control_request - fire-and-forget, tracked by dispatcher
|
||||
* 4. control_cancel - synchronous
|
||||
* 5. user_message - enqueued for processing
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Starting session', this.sessionId);
|
||||
}
|
||||
|
||||
// Handle initial prompt if provided (fire-and-forget)
|
||||
if (this.initialPrompt !== null) {
|
||||
this.handleFirstMessage(this.initialPrompt);
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const message of this.inputReader.read()) {
|
||||
if (this.abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CRITICAL: Handle control_response FIRST and SYNCHRONOUSLY
|
||||
// This resolves pending outgoing requests, breaking deadlock.
|
||||
// ============================================================
|
||||
if (isControlResponse(message)) {
|
||||
this.handleControlResponse(message as CLIControlResponse);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle first message to determine session mode
|
||||
if (this.controlSystemEnabled === null) {
|
||||
this.handleFirstMessage(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CRITICAL: Handle control_request in FIRE-AND-FORGET mode
|
||||
// DON'T await - let handler run concurrently while loop continues
|
||||
// Dispatcher's pendingIncomingRequests tracks completion
|
||||
// ============================================================
|
||||
if (isControlRequest(message)) {
|
||||
this.handleControlRequestAsync(message as CLIControlRequest);
|
||||
} else if (isControlCancel(message)) {
|
||||
// Cancel is synchronous - OK to handle inline
|
||||
this.handleControlCancel(message as ControlCancelRequest);
|
||||
} else if (isCLIUserMessage(message)) {
|
||||
// User messages are enqueued, processing runs separately
|
||||
this.enqueueUserMessage(message as CLIUserMessage);
|
||||
} else if (this.debugMode) {
|
||||
if (
|
||||
!isCLIAssistantMessage(message) &&
|
||||
!isCLISystemMessage(message) &&
|
||||
!isCLIResultMessage(message) &&
|
||||
!isCLIPartialAssistantMessage(message)
|
||||
) {
|
||||
console.error(
|
||||
'[Session] Unknown message type:',
|
||||
JSON.stringify(message, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isShuttingDown) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Stream reading error:', streamError);
|
||||
}
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
// Stream ended - wait for all pending work before shutdown
|
||||
await this.waitForAllPendingWork();
|
||||
await this.shutdown();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Error:', error);
|
||||
}
|
||||
await this.shutdown();
|
||||
throw error;
|
||||
} finally {
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserMessageText(message: CLIUserMessage): string | null {
|
||||
@@ -636,6 +682,12 @@ function extractUserMessageText(message: CLIUserMessage): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for stream-json mode
|
||||
*
|
||||
* @param config - Configuration object
|
||||
* @param input - Optional initial prompt input to process before reading from stream
|
||||
*/
|
||||
export async function runNonInteractiveStreamJson(
|
||||
config: Config,
|
||||
input: string,
|
||||
@@ -646,6 +698,7 @@ export async function runNonInteractiveStreamJson(
|
||||
consolePatcher.patch();
|
||||
|
||||
try {
|
||||
// Create initial user message from prompt input if provided
|
||||
let initialPrompt: CLIUserMessage | undefined = undefined;
|
||||
if (input && input.trim().length > 0) {
|
||||
const sessionId = config.getSessionId();
|
||||
@@ -660,7 +713,7 @@ export async function runNonInteractiveStreamJson(
|
||||
};
|
||||
}
|
||||
|
||||
const manager = new Session(config, initialPrompt);
|
||||
const manager = new SessionManager(config, initialPrompt);
|
||||
await manager.run();
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { SubagentConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Annotation for attaching metadata to content blocks
|
||||
@@ -138,8 +137,9 @@ export interface CLISystemMessage {
|
||||
status: string;
|
||||
}>;
|
||||
model?: string;
|
||||
permission_mode?: string;
|
||||
permissionMode?: string;
|
||||
slash_commands?: string[];
|
||||
apiKeySource?: string;
|
||||
qwen_code_version?: string;
|
||||
output_style?: string;
|
||||
agents?: string[];
|
||||
@@ -295,69 +295,10 @@ export interface CLIControlPermissionRequest {
|
||||
blocked_path: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire format for SDK MCP server config in initialization request.
|
||||
* The actual Server instance stays in the SDK process.
|
||||
*/
|
||||
export interface SDKMcpServerConfig {
|
||||
type: 'sdk';
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire format for external MCP server config in initialization request.
|
||||
* Represents stdio/SSE/HTTP/TCP transports that must run in the CLI process.
|
||||
*/
|
||||
export interface CLIMcpServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
httpUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
tcp?: string;
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
extensionName?: string;
|
||||
oauth?: {
|
||||
enabled?: boolean;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
scopes?: string[];
|
||||
audiences?: string[];
|
||||
redirectUri?: string;
|
||||
tokenParamName?: string;
|
||||
registrationUrl?: string;
|
||||
};
|
||||
authProviderType?:
|
||||
| 'dynamic_discovery'
|
||||
| 'google_credentials'
|
||||
| 'service_account_impersonation';
|
||||
targetAudience?: string;
|
||||
targetServiceAccount?: string;
|
||||
}
|
||||
|
||||
export interface CLIControlInitializeRequest {
|
||||
subtype: 'initialize';
|
||||
hooks?: HookRegistration[] | null;
|
||||
/**
|
||||
* SDK MCP servers config
|
||||
* These are MCP servers running in the SDK process, connected via control plane.
|
||||
* External MCP servers are configured separately in settings, not via initialization.
|
||||
*/
|
||||
sdkMcpServers?: Record<string, Omit<SDKMcpServerConfig, 'instance'>>;
|
||||
/**
|
||||
* External MCP servers that the SDK wants the CLI to manage.
|
||||
* These run outside the SDK process and require CLI-side transport setup.
|
||||
*/
|
||||
mcpServers?: Record<string, CLIMcpServerConfig>;
|
||||
agents?: SubagentConfig[];
|
||||
sdkMcpServers?: string[];
|
||||
}
|
||||
|
||||
export interface CLIControlSetPermissionModeRequest {
|
||||
|
||||
@@ -245,7 +245,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
|
||||
@@ -294,21 +293,11 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
);
|
||||
// Verify first call has isContinuation: false
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[{ text: 'Use a tool' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
// Verify second call (after tool execution) has isContinuation: true
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: true },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
|
||||
@@ -383,7 +372,6 @@ describe('runNonInteractive', () => {
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
{ isContinuation: true },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
|
||||
});
|
||||
@@ -509,7 +497,6 @@ describe('runNonInteractive', () => {
|
||||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
@@ -541,7 +528,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
@@ -694,7 +680,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
@@ -846,7 +831,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||
@@ -903,7 +887,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||
@@ -1234,7 +1217,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Message from stream-json input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-envelope',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1710,7 +1692,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Simple string content' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-string-content',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
// UserMessage with array of text blocks
|
||||
@@ -1743,7 +1724,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'First part' }, { text: 'Second part' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-blocks-content',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,7 @@ import {
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
OutputFormat,
|
||||
InputFormat,
|
||||
uiTelemetryService,
|
||||
parseAndFormatApiError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||
@@ -172,7 +170,6 @@ export async function runNonInteractive(
|
||||
adapter.emitMessage(systemMessage);
|
||||
}
|
||||
|
||||
let isFirstTurn = true;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
@@ -188,9 +185,7 @@ export async function runNonInteractive(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
{ isContinuation: !isFirstTurn },
|
||||
);
|
||||
isFirstTurn = false;
|
||||
|
||||
// Start assistant message for this turn
|
||||
if (adapter) {
|
||||
@@ -210,21 +205,10 @@ export async function runNonInteractive(
|
||||
}
|
||||
} else {
|
||||
// Text output mode - direct stdout
|
||||
if (event.type === GeminiEventType.Thought) {
|
||||
process.stdout.write(event.value.description);
|
||||
} else if (event.type === GeminiEventType.Content) {
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
process.stdout.write(event.value);
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
} else if (event.type === GeminiEventType.Error) {
|
||||
// Format and output the error message for text mode
|
||||
const errorText = parseAndFormatApiError(
|
||||
event.value.error,
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
config.getModel(),
|
||||
);
|
||||
process.stderr.write(`${errorText}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,14 +225,40 @@ export async function runNonInteractive(
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const finalRequestInfo = requestInfo;
|
||||
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
const toolCallUpdateCallback =
|
||||
inputFormat === InputFormat.STREAM_JSON && options.controlService
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
/*
|
||||
if (options.controlService) {
|
||||
const permissionResult =
|
||||
await options.controlService.permission.shouldAllowTool(
|
||||
requestInfo,
|
||||
);
|
||||
if (!permissionResult.allowed) {
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
`[runNonInteractive] Tool execution denied: ${requestInfo.name}`,
|
||||
permissionResult.message ?? '',
|
||||
);
|
||||
}
|
||||
if (adapter && permissionResult.message) {
|
||||
adapter.emitSystemMessage('tool_denied', {
|
||||
tool: requestInfo.name,
|
||||
message: permissionResult.message,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (permissionResult.updatedArgs) {
|
||||
finalRequestInfo = {
|
||||
...requestInfo,
|
||||
args: permissionResult.updatedArgs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallUpdateCallback = options.controlService
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
*/
|
||||
|
||||
// Only pass outputUpdateHandler for Task tool
|
||||
const isTaskTool = finalRequestInfo.name === 'task';
|
||||
@@ -267,13 +277,13 @@ export async function runNonInteractive(
|
||||
isTaskTool && taskToolProgressHandler
|
||||
? {
|
||||
outputUpdateHandler: taskToolProgressHandler,
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
/*
|
||||
toolCallUpdateCallback
|
||||
? { onToolCallsUpdate: toolCallUpdateCallback }
|
||||
: undefined,
|
||||
*/
|
||||
}
|
||||
: toolCallUpdateCallback
|
||||
? {
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}
|
||||
: undefined,
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||
@@ -293,6 +303,9 @@ export async function runNonInteractive(
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
);
|
||||
// Note: We no longer emit a separate system message for tool errors
|
||||
// in JSON/STREAM_JSON mode, as the error is already captured in the
|
||||
// tool_result block with is_error=true.
|
||||
}
|
||||
|
||||
if (adapter) {
|
||||
|
||||
@@ -71,6 +71,7 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
|
||||
}));
|
||||
vi.mock('../ui/commands/quitCommand.js', () => ({
|
||||
quitCommand: {},
|
||||
quitConfirmCommand: {},
|
||||
}));
|
||||
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
|
||||
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
|
||||
|
||||
@@ -28,7 +28,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
@@ -77,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
modelCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
quitCommand,
|
||||
quitConfirmCommand,
|
||||
restoreCommand(this.config),
|
||||
statsCommand,
|
||||
summaryCommand,
|
||||
|
||||
@@ -89,6 +89,7 @@ import { useSessionStats } from './contexts/SessionContext.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||
@@ -445,6 +446,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const { showQuitConfirmation } = useQuitConfirmation();
|
||||
|
||||
const {
|
||||
isSubagentCreateDialogOpen,
|
||||
openSubagentCreateDialog,
|
||||
@@ -490,6 +493,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
_showQuitConfirmation: showQuitConfirmation,
|
||||
}),
|
||||
[
|
||||
openAuthDialog,
|
||||
@@ -503,6 +507,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
showQuitConfirmation,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
],
|
||||
@@ -515,6 +520,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
quitConfirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
@@ -963,6 +969,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isFolderTrustDialogOpen,
|
||||
showWelcomeBackDialog,
|
||||
handleWelcomeBackClose,
|
||||
quitConfirmationRequest,
|
||||
});
|
||||
|
||||
const handleExit = useCallback(
|
||||
@@ -976,18 +983,25 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
// Exit directly
|
||||
// Exit directly without showing confirmation dialog
|
||||
handleSlashCommand('/quit');
|
||||
return;
|
||||
}
|
||||
|
||||
// First press: Prioritize cleanup tasks
|
||||
|
||||
// Special case: If quit-confirm dialog is open, Ctrl+C means "quit immediately"
|
||||
if (quitConfirmationRequest) {
|
||||
handleSlashCommand('/quit');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Close other dialogs (highest priority)
|
||||
/**
|
||||
* For AuthDialog it is required to complete the authentication process,
|
||||
* otherwise user cannot proceed to the next step.
|
||||
* So a quit on AuthDialog should go with normal two press quit.
|
||||
* So a quit on AuthDialog should go with normal two press quit
|
||||
* and without quit-confirm dialog.
|
||||
*/
|
||||
if (isAuthDialogOpen) {
|
||||
setPressedOnce(true);
|
||||
@@ -1008,17 +1022,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
return; // Request cancelled, end processing
|
||||
}
|
||||
|
||||
// 4. Clear input buffer (if has content)
|
||||
// 3. Clear input buffer (if has content)
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
return; // Input cleared, end processing
|
||||
}
|
||||
|
||||
// All cleanup tasks completed, set flag for double-press to quit
|
||||
setPressedOnce(true);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setPressedOnce(false);
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
// All cleanup tasks completed, show quit confirmation dialog
|
||||
handleSlashCommand('/quit-confirm');
|
||||
},
|
||||
[
|
||||
isAuthDialogOpen,
|
||||
@@ -1026,6 +1037,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
closeAnyOpenDialog,
|
||||
streamingState,
|
||||
cancelOngoingRequest,
|
||||
quitConfirmationRequest,
|
||||
buffer,
|
||||
],
|
||||
);
|
||||
@@ -1042,8 +1054,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// On first press: set flag, start timer, and call handleExit for cleanup
|
||||
// On second press (within timeout): handleExit sees flag and does fast quit
|
||||
// On first press: set flag, start timer, and call handleExit for cleanup/quit-confirm
|
||||
// On second press (within 500ms): handleExit sees flag and does fast quit
|
||||
if (!ctrlCPressedOnce) {
|
||||
setCtrlCPressedOnce(true);
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
@@ -1184,6 +1196,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
!!confirmationRequest ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
!!quitConfirmationRequest ||
|
||||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
@@ -1232,6 +1245,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
quitConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
initError,
|
||||
@@ -1323,6 +1337,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
quitConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
initError,
|
||||
|
||||
@@ -8,6 +8,35 @@ import { formatDuration } from '../utils/formatters.js';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const quitConfirmCommand: SlashCommand = {
|
||||
name: 'quit-confirm',
|
||||
get description() {
|
||||
return t('Show quit confirmation dialog');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context) => {
|
||||
const now = Date.now();
|
||||
const { sessionStartTime } = context.session.stats;
|
||||
const wallDuration = now - sessionStartTime.getTime();
|
||||
|
||||
return {
|
||||
type: 'quit_confirmation',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
text: `/quit-confirm`,
|
||||
id: now - 1,
|
||||
},
|
||||
{
|
||||
type: 'quit_confirmation',
|
||||
duration: formatDuration(wallDuration),
|
||||
id: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const quitCommand: SlashCommand = {
|
||||
name: 'quit',
|
||||
altNames: ['exit'],
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('setupGithubCommand', async () => {
|
||||
|
||||
const expectedSubstrings = [
|
||||
`set -eEuo pipefail`,
|
||||
`fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`,
|
||||
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
|
||||
];
|
||||
|
||||
for (const substring of expectedSubstrings) {
|
||||
@@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => {
|
||||
|
||||
if (gitignoreExists) {
|
||||
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
||||
expect(gitignoreContent).toContain('.qwen/');
|
||||
expect(gitignoreContent).toContain('.gemini/');
|
||||
expect(gitignoreContent).toContain('gha-creds-*.json');
|
||||
}
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe('updateGitignore', () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
expect(content).toBe('.qwen/\ngha-creds-*.json\n');
|
||||
expect(content).toBe('.gemini/\ngha-creds-*.json\n');
|
||||
});
|
||||
|
||||
it('appends entries to existing .gitignore file', async () => {
|
||||
@@ -148,13 +148,13 @@ describe('updateGitignore', () => {
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
expect(content).toBe(
|
||||
'# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n',
|
||||
'# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add duplicate entries', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = '.qwen/\nsome-other-file\ngha-creds-*.json\n';
|
||||
const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n';
|
||||
await fs.writeFile(gitignorePath, existingContent);
|
||||
|
||||
await updateGitignore(scratchDir);
|
||||
@@ -166,7 +166,7 @@ describe('updateGitignore', () => {
|
||||
|
||||
it('adds only missing entries when some already exist', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = '.qwen/\nsome-other-file\n';
|
||||
const existingContent = '.gemini/\nsome-other-file\n';
|
||||
await fs.writeFile(gitignorePath, existingContent);
|
||||
|
||||
await updateGitignore(scratchDir);
|
||||
@@ -174,17 +174,17 @@ describe('updateGitignore', () => {
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
// Should add only the missing gha-creds-*.json entry
|
||||
expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n');
|
||||
expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n');
|
||||
expect(content).toContain('gha-creds-*.json');
|
||||
// Should not duplicate .qwen/ entry
|
||||
expect((content.match(/\.qwen\//g) || []).length).toBe(1);
|
||||
// Should not duplicate .gemini/ entry
|
||||
expect((content.match(/\.gemini\//g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not get confused by entries in comments or as substrings', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = [
|
||||
'# This is a comment mentioning .qwen/ folder',
|
||||
'my-app.qwen/config',
|
||||
'# This is a comment mentioning .gemini/ folder',
|
||||
'my-app.gemini/config',
|
||||
'# Another comment with gha-creds-*.json pattern',
|
||||
'some-other-gha-creds-file.json',
|
||||
'',
|
||||
@@ -196,7 +196,7 @@ describe('updateGitignore', () => {
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
// Should add both entries since they don't actually exist as gitignore rules
|
||||
expect(content).toContain('.qwen/');
|
||||
expect(content).toContain('.gemini/');
|
||||
expect(content).toContain('gha-creds-*.json');
|
||||
|
||||
// Verify the entries were added (not just mentioned in comments)
|
||||
@@ -204,9 +204,9 @@ describe('updateGitignore', () => {
|
||||
.split('\n')
|
||||
.map((line) => line.split('#')[0].trim())
|
||||
.filter((line) => line);
|
||||
expect(lines).toContain('.qwen/');
|
||||
expect(lines).toContain('.gemini/');
|
||||
expect(lines).toContain('gha-creds-*.json');
|
||||
expect(lines).toContain('my-app.qwen/config');
|
||||
expect(lines).toContain('my-app.gemini/config');
|
||||
expect(lines).toContain('some-other-gha-creds-file.json');
|
||||
});
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const GITHUB_WORKFLOW_PATHS = [
|
||||
'qwen-dispatch/qwen-dispatch.yml',
|
||||
'qwen-assistant/qwen-invoke.yml',
|
||||
'issue-triage/qwen-triage.yml',
|
||||
'issue-triage/qwen-scheduled-triage.yml',
|
||||
'pr-review/qwen-review.yml',
|
||||
'gemini-dispatch/gemini-dispatch.yml',
|
||||
'gemini-assistant/gemini-invoke.yml',
|
||||
'issue-triage/gemini-triage.yml',
|
||||
'issue-triage/gemini-scheduled-triage.yml',
|
||||
'pr-review/gemini-review.yml',
|
||||
];
|
||||
|
||||
// Generate OS-specific commands to open the GitHub pages needed for setup.
|
||||
@@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] {
|
||||
return commands;
|
||||
}
|
||||
|
||||
// Add Qwen Code specific entries to .gitignore file
|
||||
// Add Gemini CLI specific entries to .gitignore file
|
||||
export async function updateGitignore(gitRepoRoot: string): Promise<void> {
|
||||
const gitignoreEntries = ['.qwen/', 'gha-creds-*.json'];
|
||||
const gitignoreEntries = ['.gemini/', 'gha-creds-*.json'];
|
||||
|
||||
const gitignorePath = path.join(gitRepoRoot, '.gitignore');
|
||||
try {
|
||||
@@ -121,7 +121,7 @@ export const setupGithubCommand: SlashCommand = {
|
||||
// Get the latest release tag from GitHub
|
||||
const proxy = context?.services?.config?.getProxy();
|
||||
const releaseTag = await getLatestGitHubRelease(proxy);
|
||||
const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`;
|
||||
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
|
||||
|
||||
// Create the .github/workflows directory to download the files into
|
||||
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
|
||||
@@ -143,7 +143,7 @@ export const setupGithubCommand: SlashCommand = {
|
||||
for (const workflow of GITHUB_WORKFLOW_PATHS) {
|
||||
downloads.push(
|
||||
(async () => {
|
||||
const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
@@ -204,9 +204,8 @@ export const setupGithubCommand: SlashCommand = {
|
||||
toolName: 'run_shell_command',
|
||||
toolArgs: {
|
||||
description:
|
||||
'Setting up GitHub Actions to triage issues and review PRs with Qwen.',
|
||||
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
|
||||
command,
|
||||
is_background: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -100,6 +100,12 @@ export interface QuitActionReturn {
|
||||
messages: HistoryItem[];
|
||||
}
|
||||
|
||||
/** The return type for a command action that requests quit confirmation. */
|
||||
export interface QuitConfirmationActionReturn {
|
||||
type: 'quit_confirmation';
|
||||
messages: HistoryItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in a simple message
|
||||
* being displayed to the user.
|
||||
@@ -176,6 +182,7 @@ export type SlashCommandActionReturn =
|
||||
| ToolActionReturn
|
||||
| MessageActionReturn
|
||||
| QuitActionReturn
|
||||
| QuitConfirmationActionReturn
|
||||
| OpenDialogActionReturn
|
||||
| LoadHistoryActionReturn
|
||||
| SubmitPromptActionReturn
|
||||
|
||||
@@ -36,6 +36,10 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
||||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import {
|
||||
QuitConfirmationDialog,
|
||||
QuitChoice,
|
||||
} from './QuitConfirmationDialog.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -123,6 +127,24 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quitConfirmationRequest) {
|
||||
return (
|
||||
<QuitConfirmationDialog
|
||||
onSelect={(choice: QuitChoice) => {
|
||||
if (choice === QuitChoice.CANCEL) {
|
||||
uiState.quitConfirmationRequest?.onConfirm(false, 'cancel');
|
||||
} else if (choice === QuitChoice.QUIT) {
|
||||
uiState.quitConfirmationRequest?.onConfirm(true, 'quit');
|
||||
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
|
||||
uiState.quitConfirmationRequest?.onConfirm(
|
||||
true,
|
||||
'summary_and_quit',
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.confirmationRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
|
||||
@@ -15,8 +15,6 @@ import { InfoMessage } from './messages/InfoMessage.js';
|
||||
import { ErrorMessage } from './messages/ErrorMessage.js';
|
||||
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
|
||||
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
|
||||
import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js';
|
||||
import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js';
|
||||
import { CompressionMessage } from './messages/CompressionMessage.js';
|
||||
import { SummaryMessage } from './messages/SummaryMessage.js';
|
||||
import { WarningMessage } from './messages/WarningMessage.js';
|
||||
@@ -87,26 +85,6 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought' && (
|
||||
<GeminiThoughtMessage
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought_content' && (
|
||||
<GeminiThoughtMessageContent
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'info' && (
|
||||
<InfoMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
@@ -130,6 +108,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'quit' && (
|
||||
<SessionSummaryDisplay duration={itemForDisplay.duration} />
|
||||
)}
|
||||
{itemForDisplay.type === 'quit_confirmation' && (
|
||||
<SessionSummaryDisplay duration={itemForDisplay.duration} />
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
toolCalls={itemForDisplay.tools}
|
||||
|
||||
@@ -1307,7 +1307,7 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7)
|
||||
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
|
||||
@@ -707,20 +707,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
statusText = t('Accepting edits');
|
||||
}
|
||||
|
||||
const borderColor =
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={true}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default
|
||||
}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text
|
||||
@@ -834,10 +829,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
73
packages/cli/src/ui/components/QuitConfirmationDialog.tsx
Normal file
73
packages/cli/src/ui/components/QuitConfirmationDialog.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export enum QuitChoice {
|
||||
CANCEL = 'cancel',
|
||||
QUIT = 'quit',
|
||||
SUMMARY_AND_QUIT = 'summary_and_quit',
|
||||
}
|
||||
|
||||
interface QuitConfirmationDialogProps {
|
||||
onSelect: (choice: QuitChoice) => void;
|
||||
}
|
||||
|
||||
export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onSelect(QuitChoice.CANCEL);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const options: Array<RadioSelectItem<QuitChoice>> = [
|
||||
{
|
||||
key: 'quit',
|
||||
label: t('Quit immediately (/quit)'),
|
||||
value: QuitChoice.QUIT,
|
||||
},
|
||||
{
|
||||
key: 'summary-and-quit',
|
||||
label: t('Generate summary and quit (/summary)'),
|
||||
value: QuitChoice.SUMMARY_AND_QUIT,
|
||||
},
|
||||
{
|
||||
key: 'cancel',
|
||||
label: t('Cancel (stay in application)'),
|
||||
value: QuitChoice.CANCEL,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
padding={1}
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>{t('What would you like to do before exiting?')}</Text>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
! Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ! Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
* Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ * Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface GeminiThoughtMessageProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays model thinking/reasoning text with a softer, dimmed style
|
||||
* to visually distinguish it from regular content output.
|
||||
*/
|
||||
export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.secondary}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface GeminiThoughtMessageContentProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuation component for thought messages, similar to GeminiMessageContent.
|
||||
* Used when a thought response gets too long and needs to be split for performance.
|
||||
*/
|
||||
export const GeminiThoughtMessageContent: React.FC<
|
||||
GeminiThoughtMessageContentProps
|
||||
> = ({ text, isPending, availableTerminalHeight, terminalWidth }) => {
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth} marginBottom={1}>
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -69,10 +69,7 @@ export function EditOptionsStep({
|
||||
if (selectedValue === 'editor') {
|
||||
// Launch editor directly
|
||||
try {
|
||||
if (!selectedAgent.filePath) {
|
||||
throw new Error('Agent has no file path');
|
||||
}
|
||||
await launchEditor(selectedAgent.filePath);
|
||||
await launchEditor(selectedAgent?.filePath);
|
||||
} catch (err) {
|
||||
setError(
|
||||
t('Failed to launch editor: {{error}}', {
|
||||
|
||||
@@ -218,7 +218,7 @@ export const AgentSelectionStep = ({
|
||||
const renderAgentItem = (
|
||||
agent: {
|
||||
name: string;
|
||||
level: 'project' | 'user' | 'builtin' | 'session';
|
||||
level: 'project' | 'user' | 'builtin';
|
||||
isBuiltin?: boolean;
|
||||
},
|
||||
index: number,
|
||||
@@ -267,7 +267,7 @@ export const AgentSelectionStep = ({
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('Project Level ({{path}})', {
|
||||
path: projectAgents[0].filePath?.replace(/\/[^/]+$/, '') || '',
|
||||
path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''),
|
||||
})}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
@@ -289,7 +289,7 @@ export const AgentSelectionStep = ({
|
||||
>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('User Level ({{path}})', {
|
||||
path: userAgents[0].filePath?.replace(/\/[^/]+$/, '') || '',
|
||||
path: userAgents[0].filePath.replace(/\/[^/]+$/, ''),
|
||||
})}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ShellConfirmationRequest,
|
||||
ConfirmationRequest,
|
||||
LoopDetectionConfirmationRequest,
|
||||
QuitConfirmationRequest,
|
||||
HistoryItemWithoutId,
|
||||
StreamingState,
|
||||
} from '../types.js';
|
||||
@@ -68,6 +69,7 @@ export interface UIState {
|
||||
confirmationRequest: ConfirmationRequest | null;
|
||||
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||
quitConfirmationRequest: QuitConfirmationRequest | null;
|
||||
geminiMdFileCount: number;
|
||||
streamingState: StreamingState;
|
||||
initError: string | null;
|
||||
|
||||
@@ -918,6 +918,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
vi.fn(), // toggleVimEnabled
|
||||
vi.fn(), // setIsProcessing
|
||||
vi.fn(), // setGeminiMdFileCount
|
||||
vi.fn(), // _showQuitConfirmation
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IdeClient,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import type {
|
||||
Message,
|
||||
HistoryItemWithoutId,
|
||||
@@ -52,6 +53,7 @@ function serializeHistoryItemForRecording(
|
||||
|
||||
const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
||||
'quit',
|
||||
'quit-confirm',
|
||||
'exit',
|
||||
'clear',
|
||||
'reset',
|
||||
@@ -73,6 +75,7 @@ interface SlashCommandProcessorActions {
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
openSubagentCreateDialog: () => void;
|
||||
openAgentsManagerDialog: () => void;
|
||||
_showQuitConfirmation: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +115,10 @@ export const useSlashCommandProcessor = (
|
||||
prompt: React.ReactNode;
|
||||
onConfirm: (confirmed: boolean) => void;
|
||||
}>(null);
|
||||
const [quitConfirmationRequest, setQuitConfirmationRequest] =
|
||||
useState<null | {
|
||||
onConfirm: (shouldQuit: boolean, action?: string) => void;
|
||||
}>(null);
|
||||
|
||||
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
|
||||
new Set<string>(),
|
||||
@@ -167,6 +174,11 @@ export const useSlashCommandProcessor = (
|
||||
type: 'quit',
|
||||
duration: message.duration,
|
||||
};
|
||||
} else if (message.type === MessageType.QUIT_CONFIRMATION) {
|
||||
historyItemContent = {
|
||||
type: 'quit_confirmation',
|
||||
duration: message.duration,
|
||||
};
|
||||
} else if (message.type === MessageType.COMPRESSION) {
|
||||
historyItemContent = {
|
||||
type: 'compression',
|
||||
@@ -437,6 +449,66 @@ export const useSlashCommandProcessor = (
|
||||
});
|
||||
return { type: 'handled' };
|
||||
}
|
||||
case 'quit_confirmation':
|
||||
// Show quit confirmation dialog instead of immediately quitting
|
||||
setQuitConfirmationRequest({
|
||||
onConfirm: (shouldQuit: boolean, action?: string) => {
|
||||
setQuitConfirmationRequest(null);
|
||||
if (!shouldQuit) {
|
||||
// User cancelled the quit operation - do nothing
|
||||
return;
|
||||
}
|
||||
if (shouldQuit) {
|
||||
if (action === 'summary_and_quit') {
|
||||
// Generate summary and then quit
|
||||
handleSlashCommand('/summary')
|
||||
.then(() => {
|
||||
// Wait for user to see the summary result
|
||||
setTimeout(() => {
|
||||
handleSlashCommand('/quit');
|
||||
}, 1200);
|
||||
})
|
||||
.catch((error) => {
|
||||
// If summary fails, still quit but show error
|
||||
addItemWithRecording(
|
||||
{
|
||||
type: 'error',
|
||||
text: `Failed to generate summary before quit: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
// Give user time to see the error message
|
||||
setTimeout(() => {
|
||||
handleSlashCommand('/quit');
|
||||
}, 1000);
|
||||
});
|
||||
} else {
|
||||
// Just quit immediately - trigger the actual quit action
|
||||
const now = Date.now();
|
||||
const { sessionStartTime } = sessionStats;
|
||||
const wallDuration = now - sessionStartTime.getTime();
|
||||
|
||||
actions.quit([
|
||||
{
|
||||
type: 'user',
|
||||
text: `/quit`,
|
||||
id: now - 1,
|
||||
},
|
||||
{
|
||||
type: 'quit',
|
||||
duration: formatDuration(wallDuration),
|
||||
id: now,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
return { type: 'handled' };
|
||||
|
||||
case 'quit':
|
||||
actions.quit(result.messages);
|
||||
@@ -620,6 +692,7 @@ export const useSlashCommandProcessor = (
|
||||
setSessionShellAllowlist,
|
||||
setIsProcessing,
|
||||
setConfirmationRequest,
|
||||
sessionStats,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -630,5 +703,6 @@ export const useSlashCommandProcessor = (
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
quitConfirmationRequest,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface DialogCloseOptions {
|
||||
// Welcome back dialog
|
||||
showWelcomeBackDialog: boolean;
|
||||
handleWelcomeBackClose: () => void;
|
||||
|
||||
// Quit confirmation dialog
|
||||
quitConfirmationRequest: {
|
||||
onConfirm: (shouldQuit: boolean, action?: string) => void;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,6 +96,9 @@ export function useDialogClose(options: DialogCloseOptions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Note: quitConfirmationRequest is NOT handled here anymore
|
||||
// It's handled specially in handleExit - ctrl+c in quit-confirm should exit immediately
|
||||
|
||||
// No dialog was open
|
||||
return false;
|
||||
}, [options]);
|
||||
|
||||
@@ -2261,57 +2261,6 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should accumulate streamed thought descriptions', async () => {
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Thought,
|
||||
value: { subject: '', description: 'thinking ' },
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Thought,
|
||||
value: { subject: '', description: 'more' },
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Finished,
|
||||
value: { reason: 'STOP', usageMetadata: undefined },
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
mockLoadedSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
false, // visionModelPreviewEnabled
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Streamed thought');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.thought?.description).toBe('thinking more');
|
||||
});
|
||||
});
|
||||
|
||||
it('should memoize pendingHistoryItems', () => {
|
||||
mockUseReactToolScheduler.mockReturnValue([
|
||||
[],
|
||||
|
||||
@@ -497,61 +497,6 @@ export const useGeminiStream = (
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
);
|
||||
|
||||
const mergeThought = useCallback(
|
||||
(incoming: ThoughtSummary) => {
|
||||
setThought((prev) => {
|
||||
if (!prev) {
|
||||
return incoming;
|
||||
}
|
||||
const subject = incoming.subject || prev.subject;
|
||||
const description = `${prev.description ?? ''}${incoming.description ?? ''}`;
|
||||
return { subject, description };
|
||||
});
|
||||
},
|
||||
[setThought],
|
||||
);
|
||||
|
||||
const handleThoughtEvent = useCallback(
|
||||
(
|
||||
eventValue: ThoughtSummary,
|
||||
currentThoughtBuffer: string,
|
||||
userMessageTimestamp: number,
|
||||
): string => {
|
||||
if (turnCancelledRef.current) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Extract the description text from the thought summary
|
||||
const thoughtText = eventValue.description ?? '';
|
||||
if (!thoughtText) {
|
||||
return currentThoughtBuffer;
|
||||
}
|
||||
|
||||
const newThoughtBuffer = currentThoughtBuffer + thoughtText;
|
||||
|
||||
// If we're not already showing a thought, start a new one
|
||||
if (pendingHistoryItemRef.current?.type !== 'gemini_thought') {
|
||||
// If there's a pending non-thought item, finalize it first
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
}
|
||||
setPendingHistoryItem({ type: 'gemini_thought', text: '' });
|
||||
}
|
||||
|
||||
// Update the existing thought message with accumulated content
|
||||
setPendingHistoryItem({
|
||||
type: 'gemini_thought',
|
||||
text: newThoughtBuffer,
|
||||
});
|
||||
|
||||
// Also update the thought state for the loading indicator
|
||||
mergeThought(eventValue);
|
||||
|
||||
return newThoughtBuffer;
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, mergeThought],
|
||||
);
|
||||
|
||||
const handleUserCancelledEvent = useCallback(
|
||||
(userMessageTimestamp: number) => {
|
||||
if (turnCancelledRef.current) {
|
||||
@@ -765,16 +710,11 @@ export const useGeminiStream = (
|
||||
signal: AbortSignal,
|
||||
): Promise<StreamProcessingStatus> => {
|
||||
let geminiMessageBuffer = '';
|
||||
let thoughtBuffer = '';
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
for await (const event of stream) {
|
||||
switch (event.type) {
|
||||
case ServerGeminiEventType.Thought:
|
||||
thoughtBuffer = handleThoughtEvent(
|
||||
event.value,
|
||||
thoughtBuffer,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
setThought(event.value);
|
||||
break;
|
||||
case ServerGeminiEventType.Content:
|
||||
geminiMessageBuffer = handleContentEvent(
|
||||
@@ -836,7 +776,6 @@ export const useGeminiStream = (
|
||||
},
|
||||
[
|
||||
handleContentEvent,
|
||||
handleThoughtEvent,
|
||||
handleUserCancelledEvent,
|
||||
handleErrorEvent,
|
||||
scheduleToolCalls,
|
||||
|
||||
37
packages/cli/src/ui/hooks/useQuitConfirmation.ts
Normal file
37
packages/cli/src/ui/hooks/useQuitConfirmation.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { QuitChoice } from '../components/QuitConfirmationDialog.js';
|
||||
|
||||
export const useQuitConfirmation = () => {
|
||||
const [isQuitConfirmationOpen, setIsQuitConfirmationOpen] = useState(false);
|
||||
|
||||
const showQuitConfirmation = useCallback(() => {
|
||||
setIsQuitConfirmationOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleQuitConfirmationSelect = useCallback((choice: QuitChoice) => {
|
||||
setIsQuitConfirmationOpen(false);
|
||||
|
||||
if (choice === QuitChoice.CANCEL) {
|
||||
return { shouldQuit: false, action: 'cancel' };
|
||||
} else if (choice === QuitChoice.QUIT) {
|
||||
return { shouldQuit: true, action: 'quit' };
|
||||
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
|
||||
return { shouldQuit: true, action: 'summary_and_quit' };
|
||||
}
|
||||
|
||||
// Default to cancel if unknown choice
|
||||
return { shouldQuit: false, action: 'cancel' };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isQuitConfirmationOpen,
|
||||
showQuitConfirmation,
|
||||
handleQuitConfirmationSelect,
|
||||
};
|
||||
};
|
||||
@@ -103,16 +103,6 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemGeminiThought = HistoryItemBase & {
|
||||
type: 'gemini_thought';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemGeminiThoughtContent = HistoryItemBase & {
|
||||
type: 'gemini_thought_content';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemInfo = HistoryItemBase & {
|
||||
type: 'info';
|
||||
text: string;
|
||||
@@ -171,6 +161,11 @@ export type HistoryItemQuit = HistoryItemBase & {
|
||||
duration: string;
|
||||
};
|
||||
|
||||
export type HistoryItemQuitConfirmation = HistoryItemBase & {
|
||||
type: 'quit_confirmation';
|
||||
duration: string;
|
||||
};
|
||||
|
||||
export type HistoryItemToolGroup = HistoryItemBase & {
|
||||
type: 'tool_group';
|
||||
tools: IndividualToolCallDisplay[];
|
||||
@@ -251,8 +246,6 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemUserShell
|
||||
| HistoryItemGemini
|
||||
| HistoryItemGeminiContent
|
||||
| HistoryItemGeminiThought
|
||||
| HistoryItemGeminiThoughtContent
|
||||
| HistoryItemInfo
|
||||
| HistoryItemError
|
||||
| HistoryItemWarning
|
||||
@@ -263,6 +256,7 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemModelStats
|
||||
| HistoryItemToolStats
|
||||
| HistoryItemQuit
|
||||
| HistoryItemQuitConfirmation
|
||||
| HistoryItemCompression
|
||||
| HistoryItemSummary
|
||||
| HistoryItemCompression
|
||||
@@ -284,6 +278,7 @@ export enum MessageType {
|
||||
MODEL_STATS = 'model_stats',
|
||||
TOOL_STATS = 'tool_stats',
|
||||
QUIT = 'quit',
|
||||
QUIT_CONFIRMATION = 'quit_confirmation',
|
||||
GEMINI = 'gemini',
|
||||
COMPRESSION = 'compression',
|
||||
SUMMARY = 'summary',
|
||||
@@ -347,6 +342,12 @@ export type Message =
|
||||
duration: string;
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
type: MessageType.QUIT_CONFIRMATION;
|
||||
timestamp: Date;
|
||||
duration: string;
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
type: MessageType.COMPRESSION;
|
||||
compression: CompressionProps;
|
||||
@@ -403,3 +404,7 @@ export interface ConfirmationRequest {
|
||||
export interface LoopDetectionConfirmationRequest {
|
||||
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
||||
}
|
||||
|
||||
export interface QuitConfirmationRequest {
|
||||
onConfirm: (shouldQuit: boolean, action?: string) => void;
|
||||
}
|
||||
|
||||
@@ -19,16 +19,12 @@ const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
|
||||
|
||||
interface RenderInlineProps {
|
||||
text: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
|
||||
text,
|
||||
textColor = theme.text.primary,
|
||||
}) => {
|
||||
const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
|
||||
// Early return for plain text without markdown or URLs
|
||||
if (!/[*_~`<[https?:]/.test(text)) {
|
||||
return <Text color={textColor}>{text}</Text>;
|
||||
return <Text color={theme.text.primary}>{text}</Text>;
|
||||
}
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
@@ -17,7 +17,6 @@ interface MarkdownDisplayProps {
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
// Constants for Markdown parsing and rendering
|
||||
@@ -32,7 +31,6 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
textColor = theme.text.primary,
|
||||
}) => {
|
||||
if (!text) return <></>;
|
||||
|
||||
@@ -118,7 +116,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
addContentBlock(
|
||||
<Box key={key}>
|
||||
<Text wrap="wrap">
|
||||
<RenderInline text={line} textColor={textColor} />
|
||||
<RenderInline text={line} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
@@ -157,7 +155,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
addContentBlock(
|
||||
<Box key={key}>
|
||||
<Text wrap="wrap">
|
||||
<RenderInline text={line} textColor={textColor} />
|
||||
<RenderInline text={line} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
@@ -175,36 +173,36 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
switch (level) {
|
||||
case 1:
|
||||
headerNode = (
|
||||
<Text bold color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
<Text bold color={theme.text.link}>
|
||||
<RenderInline text={headerText} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
headerNode = (
|
||||
<Text bold color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
<Text bold color={theme.text.link}>
|
||||
<RenderInline text={headerText} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
case 3:
|
||||
headerNode = (
|
||||
<Text bold color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
<Text bold color={theme.text.primary}>
|
||||
<RenderInline text={headerText} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
case 4:
|
||||
headerNode = (
|
||||
<Text italic color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
<Text italic color={theme.text.secondary}>
|
||||
<RenderInline text={headerText} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
headerNode = (
|
||||
<Text color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
<Text color={theme.text.primary}>
|
||||
<RenderInline text={headerText} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
@@ -221,7 +219,6 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
type="ul"
|
||||
marker={marker}
|
||||
leadingWhitespace={leadingWhitespace}
|
||||
textColor={textColor}
|
||||
/>,
|
||||
);
|
||||
} else if (olMatch) {
|
||||
@@ -235,7 +232,6 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
type="ol"
|
||||
marker={marker}
|
||||
leadingWhitespace={leadingWhitespace}
|
||||
textColor={textColor}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
@@ -249,8 +245,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
} else {
|
||||
addContentBlock(
|
||||
<Box key={key}>
|
||||
<Text wrap="wrap" color={textColor}>
|
||||
<RenderInline text={line} textColor={textColor} />
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
<RenderInline text={line} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
@@ -371,7 +367,6 @@ interface RenderListItemProps {
|
||||
type: 'ul' | 'ol';
|
||||
marker: string;
|
||||
leadingWhitespace?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
const RenderListItemInternal: React.FC<RenderListItemProps> = ({
|
||||
@@ -379,7 +374,6 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
|
||||
type,
|
||||
marker,
|
||||
leadingWhitespace = '',
|
||||
textColor = theme.text.primary,
|
||||
}) => {
|
||||
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
|
||||
const prefixWidth = prefix.length;
|
||||
@@ -391,11 +385,11 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
|
||||
flexDirection="row"
|
||||
>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={textColor}>{prefix}</Text>
|
||||
<Text color={theme.text.primary}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
|
||||
<Text wrap="wrap" color={textColor}>
|
||||
<RenderInline text={itemText} textColor={textColor} />
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
<RenderInline text={itemText} />
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('resumeHistoryUtils', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks tool results as error, captures thought text, and falls back when tool is missing', () => {
|
||||
it('marks tool results as error, skips thought text, and falls back when tool is missing', () => {
|
||||
const conversation = {
|
||||
messages: [
|
||||
{
|
||||
@@ -142,11 +142,6 @@ describe('resumeHistoryUtils', () => {
|
||||
const items = buildResumedHistoryItems(session, makeConfig({}));
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
type: 'gemini_thought',
|
||||
text: 'should be skipped',
|
||||
},
|
||||
{ id: expect.any(Number), type: 'gemini', text: 'visible text' },
|
||||
{
|
||||
id: expect.any(Number),
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* Extracts text content from a Content object's parts (excluding thought parts).
|
||||
* Extracts text content from a Content object's parts.
|
||||
*/
|
||||
function extractTextFromParts(parts: Part[] | undefined): string {
|
||||
if (!parts) return '';
|
||||
@@ -34,22 +34,6 @@ function extractTextFromParts(parts: Part[] | undefined): string {
|
||||
return textParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts thought text content from a Content object's parts.
|
||||
* Thought parts are identified by having `thought: true`.
|
||||
*/
|
||||
function extractThoughtTextFromParts(parts: Part[] | undefined): string {
|
||||
if (!parts) return '';
|
||||
|
||||
const thoughtParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
if ('text' in part && part.text && 'thought' in part && part.thought) {
|
||||
thoughtParts.push(part.text);
|
||||
}
|
||||
}
|
||||
return thoughtParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts function calls from a Content object's parts.
|
||||
*/
|
||||
@@ -203,28 +187,12 @@ function convertToHistoryItems(
|
||||
case 'assistant': {
|
||||
const parts = record.message?.parts as Part[] | undefined;
|
||||
|
||||
// Extract thought content
|
||||
const thoughtText = extractThoughtTextFromParts(parts);
|
||||
|
||||
// Extract text content (non-function-call, non-thought)
|
||||
const text = extractTextFromParts(parts);
|
||||
|
||||
// Extract function calls
|
||||
const functionCalls = extractFunctionCalls(parts);
|
||||
|
||||
// If there's thought content, add it as a gemini_thought message
|
||||
if (thoughtText) {
|
||||
// Flush any pending tool group before thought
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: [...currentToolGroup],
|
||||
});
|
||||
currentToolGroup = [];
|
||||
}
|
||||
items.push({ type: 'gemini_thought', text: thoughtText });
|
||||
}
|
||||
|
||||
// If there's text content, add it as a gemini message
|
||||
if (text) {
|
||||
// Flush any pending tool group before text
|
||||
|
||||
@@ -58,7 +58,7 @@ export const getLatestGitHubRelease = async (
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
|
||||
const endpoint = `https://api.github.com/repos/QwenLM/qwen-code-action/releases/latest`;
|
||||
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
@@ -83,12 +83,9 @@ export const getLatestGitHubRelease = async (
|
||||
}
|
||||
return releaseTag;
|
||||
} catch (_error) {
|
||||
console.debug(
|
||||
`Failed to determine latest qwen-code-action release:`,
|
||||
_error,
|
||||
);
|
||||
console.debug(`Failed to determine latest run-gemini-cli release:`, _error);
|
||||
throw new Error(
|
||||
`Unable to determine the latest qwen-code-action release on GitHub.`,
|
||||
`Unable to determine the latest run-gemini-cli release on GitHub.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -529,7 +529,7 @@ describe('buildSystemMessage', () => {
|
||||
{ name: 'mcp-server-2', status: 'connected' },
|
||||
],
|
||||
model: 'test-model',
|
||||
permission_mode: 'auto',
|
||||
permissionMode: 'auto',
|
||||
slash_commands: ['commit', 'help', 'memory'],
|
||||
qwen_code_version: '1.0.0',
|
||||
agents: [],
|
||||
|
||||
@@ -275,7 +275,7 @@ export async function buildSystemMessage(
|
||||
tools,
|
||||
mcp_servers: mcpServerList,
|
||||
model: config.getModel(),
|
||||
permission_mode: permissionMode,
|
||||
permissionMode,
|
||||
slash_commands: slashCommands,
|
||||
qwen_code_version: config.getCliVersion() || 'unknown',
|
||||
agents: agentNames,
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function validateNonInteractiveAuth(
|
||||
}
|
||||
|
||||
const effectiveAuthType =
|
||||
enforcedType || configuredAuthType || getAuthTypeFromEnv();
|
||||
enforcedType || getAuthTypeFromEnv() || configuredAuthType;
|
||||
|
||||
if (!effectiveAuthType) {
|
||||
const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.0-preview.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -47,7 +47,7 @@
|
||||
"fast-uri": "^3.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.5.0",
|
||||
"glob": "^10.4.5",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
|
||||
@@ -63,7 +63,6 @@ vi.mock('../tools/tool-registry', () => {
|
||||
ToolRegistryMock.prototype.registerTool = vi.fn();
|
||||
ToolRegistryMock.prototype.discoverAllTools = vi.fn();
|
||||
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
|
||||
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
|
||||
ToolRegistryMock.prototype.getTool = vi.fn();
|
||||
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
|
||||
return { ToolRegistry: ToolRegistryMock };
|
||||
|
||||
@@ -46,7 +46,6 @@ import { ExitPlanModeTool } from '../tools/exitPlanMode.js';
|
||||
import { GlobTool } from '../tools/glob.js';
|
||||
import { GrepTool } from '../tools/grep.js';
|
||||
import { LSTool } from '../tools/ls.js';
|
||||
import type { SendSdkMcpMessage } from '../tools/mcp-client.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { ReadManyFilesTool } from '../tools/read-many-files.js';
|
||||
@@ -66,7 +65,6 @@ import { ideContextStore } from '../ide/ideContext.js';
|
||||
import { InputFormat, OutputFormat } from '../output/types.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { SubagentManager } from '../subagents/subagent-manager.js';
|
||||
import type { SubagentConfig } from '../subagents/types.js';
|
||||
import {
|
||||
DEFAULT_OTLP_ENDPOINT,
|
||||
DEFAULT_TELEMETRY_TARGET,
|
||||
@@ -240,18 +238,9 @@ export class MCPServerConfig {
|
||||
readonly targetAudience?: string,
|
||||
/* targetServiceAccount format: <service-account-name>@<project-num>.iam.gserviceaccount.com */
|
||||
readonly targetServiceAccount?: string,
|
||||
// SDK MCP server type - 'sdk' indicates server runs in SDK process
|
||||
readonly type?: 'sdk',
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an MCP server config represents an SDK server
|
||||
*/
|
||||
export function isSdkMcpServerConfig(config: MCPServerConfig): boolean {
|
||||
return config.type === 'sdk';
|
||||
}
|
||||
|
||||
export enum AuthProviderType {
|
||||
DYNAMIC_DISCOVERY = 'dynamic_discovery',
|
||||
GOOGLE_CREDENTIALS = 'google_credentials',
|
||||
@@ -344,12 +333,9 @@ export interface ConfigParameters {
|
||||
eventEmitter?: EventEmitter;
|
||||
useSmartEdit?: boolean;
|
||||
output?: OutputSettings;
|
||||
skipStartupContext?: boolean;
|
||||
inputFormat?: InputFormat;
|
||||
outputFormat?: OutputFormat;
|
||||
skipStartupContext?: boolean;
|
||||
sdkMode?: boolean;
|
||||
sessionSubagents?: SubagentConfig[];
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
function normalizeConfigOutputFormat(
|
||||
@@ -371,17 +357,6 @@ function normalizeConfigOutputFormat(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for Config.initialize()
|
||||
*/
|
||||
export interface ConfigInitializeOptions {
|
||||
/**
|
||||
* Callback for sending MCP messages to SDK servers via control plane.
|
||||
* Required for SDK MCP server support in SDK mode.
|
||||
*/
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
private sessionId: string;
|
||||
private sessionData?: ResumedSessionData;
|
||||
@@ -408,10 +383,8 @@ export class Config {
|
||||
private readonly toolDiscoveryCommand: string | undefined;
|
||||
private readonly toolCallCommand: string | undefined;
|
||||
private readonly mcpServerCommand: string | undefined;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
private sessionSubagents: SubagentConfig[];
|
||||
private readonly mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
private userMemory: string;
|
||||
private sdkMode: boolean;
|
||||
private geminiMdFileCount: number;
|
||||
private approvalMode: ApprovalMode;
|
||||
private readonly showMemoryUsage: boolean;
|
||||
@@ -486,7 +459,6 @@ export class Config {
|
||||
private readonly enableToolOutputTruncation: boolean;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
private readonly useSmartEdit: boolean;
|
||||
private readonly channel: string | undefined;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId ?? randomUUID();
|
||||
@@ -515,8 +487,6 @@ export class Config {
|
||||
this.toolCallCommand = params.toolCallCommand;
|
||||
this.mcpServerCommand = params.mcpServerCommand;
|
||||
this.mcpServers = params.mcpServers;
|
||||
this.sessionSubagents = params.sessionSubagents ?? [];
|
||||
this.sdkMode = params.sdkMode ?? false;
|
||||
this.userMemory = params.userMemory ?? '';
|
||||
this.geminiMdFileCount = params.geminiMdFileCount ?? 0;
|
||||
this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT;
|
||||
@@ -600,7 +570,6 @@ export class Config {
|
||||
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
|
||||
this.useSmartEdit = params.useSmartEdit ?? false;
|
||||
this.extensionManagement = params.extensionManagement ?? true;
|
||||
this.channel = params.channel;
|
||||
this.storage = new Storage(this.targetDir);
|
||||
this.vlmSwitchMode = params.vlmSwitchMode;
|
||||
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
|
||||
@@ -623,9 +592,8 @@ export class Config {
|
||||
|
||||
/**
|
||||
* Must only be called once, throws if called again.
|
||||
* @param options Optional initialization options including sendSdkMcpMessage callback
|
||||
*/
|
||||
async initialize(options?: ConfigInitializeOptions): Promise<void> {
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
throw Error('Config was already initialized');
|
||||
}
|
||||
@@ -638,15 +606,7 @@ export class Config {
|
||||
}
|
||||
this.promptRegistry = new PromptRegistry();
|
||||
this.subagentManager = new SubagentManager(this);
|
||||
|
||||
// Load session subagents if they were provided before initialization
|
||||
if (this.sessionSubagents.length > 0) {
|
||||
this.subagentManager.loadSessionSubagents(this.sessionSubagents);
|
||||
}
|
||||
|
||||
this.toolRegistry = await this.createToolRegistry(
|
||||
options?.sendSdkMcpMessage,
|
||||
);
|
||||
this.toolRegistry = await this.createToolRegistry();
|
||||
|
||||
await this.geminiClient.initialize();
|
||||
|
||||
@@ -882,32 +842,6 @@ export class Config {
|
||||
return this.mcpServers;
|
||||
}
|
||||
|
||||
addMcpServers(servers: Record<string, MCPServerConfig>): void {
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot modify mcpServers after initialization');
|
||||
}
|
||||
this.mcpServers = { ...this.mcpServers, ...servers };
|
||||
}
|
||||
|
||||
getSessionSubagents(): SubagentConfig[] {
|
||||
return this.sessionSubagents;
|
||||
}
|
||||
|
||||
setSessionSubagents(subagents: SubagentConfig[]): void {
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot modify sessionSubagents after initialization');
|
||||
}
|
||||
this.sessionSubagents = subagents;
|
||||
}
|
||||
|
||||
getSdkMode(): boolean {
|
||||
return this.sdkMode;
|
||||
}
|
||||
|
||||
setSdkMode(value: boolean): void {
|
||||
this.sdkMode = value;
|
||||
}
|
||||
|
||||
getUserMemory(): string {
|
||||
return this.userMemory;
|
||||
}
|
||||
@@ -1147,10 +1081,6 @@ export class Config {
|
||||
return this.cliVersion;
|
||||
}
|
||||
|
||||
getChannel(): string | undefined {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current FileSystemService
|
||||
*/
|
||||
@@ -1292,14 +1222,8 @@ export class Config {
|
||||
return this.subagentManager;
|
||||
}
|
||||
|
||||
async createToolRegistry(
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
): Promise<ToolRegistry> {
|
||||
const registry = new ToolRegistry(
|
||||
this,
|
||||
this.eventEmitter,
|
||||
sendSdkMcpMessage,
|
||||
);
|
||||
async createToolRegistry(): Promise<ToolRegistry> {
|
||||
const registry = new ToolRegistry(this, this.eventEmitter);
|
||||
|
||||
const coreToolsConfig = this.getCoreTools();
|
||||
const excludeToolsConfig = this.getExcludeTools();
|
||||
@@ -1374,7 +1298,7 @@ export class Config {
|
||||
registerCoreTool(ShellTool, this);
|
||||
registerCoreTool(MemoryTool);
|
||||
registerCoreTool(TodoWriteTool, this);
|
||||
!this.sdkMode && registerCoreTool(ExitPlanModeTool, this);
|
||||
registerCoreTool(ExitPlanModeTool, this);
|
||||
registerCoreTool(WebFetchTool, this);
|
||||
// Conditionally register web search tool if web search provider is configured
|
||||
// buildWebSearchConfig ensures qwen-oauth users get dashscope provider, so
|
||||
@@ -1384,7 +1308,6 @@ export class Config {
|
||||
}
|
||||
|
||||
await registry.discoverAllTools();
|
||||
console.debug('ToolRegistry created', registry.getAllToolNames());
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +448,6 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getHistory: mockGetHistory,
|
||||
addHistory: vi.fn(),
|
||||
setHistory: vi.fn(),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
});
|
||||
|
||||
@@ -463,7 +462,6 @@ describe('Gemini Client (client.ts)', () => {
|
||||
const mockOriginalChat: Partial<GeminiChat> = {
|
||||
getHistory: vi.fn((_curated?: boolean) => chatHistory),
|
||||
setHistory: vi.fn(),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockOriginalChat as GeminiChat;
|
||||
|
||||
@@ -1082,7 +1080,6 @@ describe('Gemini Client (client.ts)', () => {
|
||||
const mockChat = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
client['chat'] = mockChat;
|
||||
|
||||
@@ -1145,7 +1142,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -1201,7 +1197,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -1278,7 +1273,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -1325,7 +1319,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -1370,7 +1363,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -1458,7 +1450,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -1515,7 +1506,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -1596,7 +1586,6 @@ ${JSON.stringify(
|
||||
.mockReturnValue([
|
||||
{ role: 'user', parts: [{ text: 'previous message' }] },
|
||||
]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
});
|
||||
@@ -1851,7 +1840,6 @@ ${JSON.stringify(
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]), // Default empty history
|
||||
setHistory: vi.fn(),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -2192,7 +2180,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -2229,7 +2216,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
@@ -2270,7 +2256,6 @@ ${JSON.stringify(
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
|
||||
@@ -419,9 +419,6 @@ export class GeminiClient {
|
||||
|
||||
// record user message for session management
|
||||
this.config.getChatRecordingService()?.recordUserMessage(request);
|
||||
|
||||
// strip thoughts from history before sending the message
|
||||
this.stripThoughtsFromHistory();
|
||||
}
|
||||
this.sessionTurnCount++;
|
||||
if (
|
||||
@@ -545,9 +542,7 @@ export class GeminiClient {
|
||||
|
||||
// add plan mode system reminder if approval mode is plan
|
||||
if (this.config.getApprovalMode() === ApprovalMode.PLAN) {
|
||||
systemReminders.push(
|
||||
getPlanModeSystemReminder(this.config.getSdkMode()),
|
||||
);
|
||||
systemReminders.push(getPlanModeSystemReminder());
|
||||
}
|
||||
|
||||
requestToSent = [...systemReminders, ...requestToSent];
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
ShellTool,
|
||||
logToolOutputTruncated,
|
||||
ToolOutputTruncatedEvent,
|
||||
InputFormat,
|
||||
} from '../index.js';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
|
||||
@@ -825,10 +824,10 @@ export class CoreToolScheduler {
|
||||
const shouldAutoDeny =
|
||||
!this.config.isInteractive() &&
|
||||
!this.config.getIdeMode() &&
|
||||
!this.config.getExperimentalZedIntegration() &&
|
||||
this.config.getInputFormat() !== InputFormat.STREAM_JSON;
|
||||
!this.config.getExperimentalZedIntegration();
|
||||
|
||||
if (shouldAutoDeny) {
|
||||
// Treat as execution denied error, similar to excluded tools
|
||||
const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`;
|
||||
this.setStatusInternal(
|
||||
reqInfo.callId,
|
||||
@@ -917,10 +916,7 @@ export class CoreToolScheduler {
|
||||
|
||||
async handleConfirmationResponse(
|
||||
callId: string,
|
||||
originalOnConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>,
|
||||
originalOnConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>,
|
||||
outcome: ToolConfirmationOutcome,
|
||||
signal: AbortSignal,
|
||||
payload?: ToolConfirmationPayload,
|
||||
@@ -929,7 +925,9 @@ export class CoreToolScheduler {
|
||||
(c) => c.request.callId === callId && c.status === 'awaiting_approval',
|
||||
);
|
||||
|
||||
await originalOnConfirm(outcome, payload);
|
||||
if (toolCall && toolCall.status === 'awaiting_approval') {
|
||||
await originalOnConfirm(outcome);
|
||||
}
|
||||
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
await this.autoApproveCompatiblePendingTools(signal, callId);
|
||||
@@ -938,10 +936,11 @@ export class CoreToolScheduler {
|
||||
this.setToolCallOutcome(callId, outcome);
|
||||
|
||||
if (outcome === ToolConfirmationOutcome.Cancel || signal.aborted) {
|
||||
// Use custom cancel message from payload if provided, otherwise use default
|
||||
const cancelMessage =
|
||||
payload?.cancelMessage || 'User did not allow tool call';
|
||||
this.setStatusInternal(callId, 'cancelled', cancelMessage);
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User did not allow tool call',
|
||||
);
|
||||
} else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
|
||||
const waitingToolCall = toolCall as WaitingToolCall;
|
||||
if (isModifiableDeclarativeTool(waitingToolCall.tool)) {
|
||||
@@ -999,8 +998,7 @@ export class CoreToolScheduler {
|
||||
): Promise<void> {
|
||||
if (
|
||||
toolCall.confirmationDetails.type !== 'edit' ||
|
||||
!isModifiableDeclarativeTool(toolCall.tool) ||
|
||||
!payload.newContent
|
||||
!isModifiableDeclarativeTool(toolCall.tool)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1541,10 +1541,10 @@ describe('GeminiChat', () => {
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'thinking...', thought: true },
|
||||
{ text: 'hi' },
|
||||
{ text: 'thinking...', thoughtSignature: 'thought-123' },
|
||||
{
|
||||
functionCall: { name: 'test', args: {} },
|
||||
thoughtSignature: 'thought-456',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1559,7 +1559,10 @@ describe('GeminiChat', () => {
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'hi' }, { functionCall: { name: 'test', args: {} } }],
|
||||
parts: [
|
||||
{ text: 'thinking...' },
|
||||
{ functionCall: { name: 'test', args: {} } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -443,28 +443,20 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
stripThoughtsFromHistory(): void {
|
||||
this.history = this.history
|
||||
.map((content) => {
|
||||
if (!content.parts) return content;
|
||||
|
||||
// Filter out thought parts entirely
|
||||
const filteredParts = content.parts.filter(
|
||||
(part) =>
|
||||
!(
|
||||
part &&
|
||||
typeof part === 'object' &&
|
||||
'thought' in part &&
|
||||
part.thought
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...content,
|
||||
parts: filteredParts,
|
||||
};
|
||||
})
|
||||
// Remove Content objects that have no parts left after filtering
|
||||
.filter((content) => content.parts && content.parts.length > 0);
|
||||
this.history = this.history.map((content) => {
|
||||
const newContent = { ...content };
|
||||
if (newContent.parts) {
|
||||
newContent.parts = newContent.parts.map((part) => {
|
||||
if (part && typeof part === 'object' && 'thoughtSignature' in part) {
|
||||
const newPart = { ...part };
|
||||
delete (newPart as { thoughtSignature?: string }).thoughtSignature;
|
||||
return newPart;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
return newContent;
|
||||
});
|
||||
}
|
||||
|
||||
setTools(tools: Tool[]): void {
|
||||
@@ -505,6 +497,8 @@ export class GeminiChat {
|
||||
): AsyncGenerator<GenerateContentResponse> {
|
||||
// Collect ALL parts from the model response (including thoughts for recording)
|
||||
const allModelParts: Part[] = [];
|
||||
// Non-thought parts for history (what we send back to the API)
|
||||
const historyParts: Part[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | undefined;
|
||||
|
||||
let hasToolCall = false;
|
||||
@@ -522,6 +516,8 @@ export class GeminiChat {
|
||||
|
||||
// Collect all parts for recording
|
||||
allModelParts.push(...content.parts);
|
||||
// Collect non-thought parts for history
|
||||
historyParts.push(...content.parts.filter((part) => !part.thought));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,15 +534,9 @@ export class GeminiChat {
|
||||
yield chunk; // Yield every chunk to the UI immediately.
|
||||
}
|
||||
|
||||
const thoughtParts = allModelParts.filter((part) => part.thought);
|
||||
const thoughtText = thoughtParts
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
const contentParts = allModelParts.filter((part) => !part.thought);
|
||||
// Consolidate text parts for history (merges adjacent text parts).
|
||||
const consolidatedHistoryParts: Part[] = [];
|
||||
for (const part of contentParts) {
|
||||
for (const part of historyParts) {
|
||||
const lastPart =
|
||||
consolidatedHistoryParts[consolidatedHistoryParts.length - 1];
|
||||
if (
|
||||
@@ -560,21 +550,20 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
const contentText = consolidatedHistoryParts
|
||||
const responseText = consolidatedHistoryParts
|
||||
.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
// Record assistant turn with raw Content and metadata
|
||||
if (thoughtText || contentText || hasToolCall || usageMetadata) {
|
||||
if (responseText || hasToolCall || usageMetadata) {
|
||||
this.chatRecordingService?.recordAssistantTurn({
|
||||
model,
|
||||
message: [
|
||||
...(thoughtText ? [{ text: thoughtText, thought: true }] : []),
|
||||
...(contentText ? [{ text: contentText }] : []),
|
||||
...(responseText ? [{ text: responseText }] : []),
|
||||
...(hasToolCall
|
||||
? contentParts
|
||||
? historyParts
|
||||
.filter((part) => part.functionCall)
|
||||
.map((part) => ({ functionCall: part.functionCall }))
|
||||
: []),
|
||||
@@ -590,7 +579,7 @@ export class GeminiChat {
|
||||
// We throw an error only when there's no tool call AND:
|
||||
// - No finish reason, OR
|
||||
// - Empty response text (e.g., only thoughts with no actual content)
|
||||
if (!hasToolCall && (!hasFinishReason || !contentText)) {
|
||||
if (!hasToolCall && (!hasFinishReason || !responseText)) {
|
||||
if (!hasFinishReason) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended without a finish reason.',
|
||||
@@ -604,13 +593,8 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
role: 'model',
|
||||
parts: [
|
||||
...(thoughtText ? [{ text: thoughtText, thought: true }] : []),
|
||||
...consolidatedHistoryParts,
|
||||
],
|
||||
});
|
||||
// Add to history (without thoughts, for API calls)
|
||||
this.history.push({ role: 'model', parts: consolidatedHistoryParts });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { OpenAIContentConverter } from './converter.js';
|
||||
import type { StreamingToolCallParser } from './streamingToolCallParser.js';
|
||||
import type { GenerateContentParameters, Content } from '@google/genai';
|
||||
import type OpenAI from 'openai';
|
||||
|
||||
describe('OpenAIContentConverter', () => {
|
||||
let converter: OpenAIContentConverter;
|
||||
@@ -143,63 +142,4 @@ describe('OpenAIContentConverter', () => {
|
||||
expect(toolMessage?.content).toBe('{"data":{"value":42}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI -> Gemini reasoning content', () => {
|
||||
it('should convert reasoning_content to a thought part for non-streaming responses', () => {
|
||||
const response = converter.convertOpenAIResponseToGemini({
|
||||
object: 'chat.completion',
|
||||
id: 'chatcmpl-1',
|
||||
created: 123,
|
||||
model: 'gpt-test',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'final answer',
|
||||
reasoning_content: 'chain-of-thought',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
} as unknown as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
const parts = response.candidates?.[0]?.content?.parts;
|
||||
expect(parts?.[0]).toEqual(
|
||||
expect.objectContaining({ thought: true, text: 'chain-of-thought' }),
|
||||
);
|
||||
expect(parts?.[1]).toEqual(
|
||||
expect.objectContaining({ text: 'final answer' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert streaming reasoning_content delta to a thought part', () => {
|
||||
const chunk = converter.convertOpenAIChunkToGemini({
|
||||
object: 'chat.completion.chunk',
|
||||
id: 'chunk-1',
|
||||
created: 456,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: 'visible text',
|
||||
reasoning_content: 'thinking...',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
model: 'gpt-test',
|
||||
} as unknown as OpenAI.Chat.ChatCompletionChunk);
|
||||
|
||||
const parts = chunk.candidates?.[0]?.content?.parts;
|
||||
expect(parts?.[0]).toEqual(
|
||||
expect.objectContaining({ thought: true, text: 'thinking...' }),
|
||||
);
|
||||
expect(parts?.[1]).toEqual(
|
||||
expect.objectContaining({ text: 'visible text' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user