diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml new file mode 100644 index 00000000..69192520 --- /dev/null +++ b/.github/workflows/release-sdk.yml @@ -0,0 +1,237 @@ +name: 'Release SDK' + +on: + workflow_dispatch: + inputs: + version: + description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.' + required: false + type: 'string' + ref: + description: 'The branch or ref (full git sha) to release from.' + required: true + type: 'string' + default: 'main' + dry_run: + description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' + required: true + type: 'boolean' + default: true + create_nightly_release: + description: 'Auto apply the nightly release tag, input version is ignored.' + required: false + type: 'boolean' + default: false + create_preview_release: + description: 'Auto apply the preview release tag, input version is ignored.' + required: false + type: 'boolean' + default: false + force_skip_tests: + description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' + required: false + type: 'boolean' + default: false + +jobs: + release-sdk: + runs-on: 'ubuntu-latest' + environment: + name: 'production-release' + url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}' + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} + permissions: + contents: 'write' + packages: 'write' + id-token: 'write' + issues: 'write' + outputs: + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.event.inputs.ref || github.sha }}' + fetch-depth: 0 + + - name: 'Set booleans for simplified logic' + env: + CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' + CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' + DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' + id: 'vars' + run: |- + is_nightly="false" + if [[ "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then + is_nightly="true" + fi + echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}" + + is_preview="false" + if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then + is_preview="true" + fi + echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" + + is_dry_run="false" + if [[ "${DRY_RUN_INPUT}" == "true" ]]; then + is_dry_run="true" + fi + echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + run: |- + npm ci + + - name: 'Get the version' + id: 'version' + run: | + VERSION_ARGS=() + if [[ "${IS_NIGHTLY}" == "true" ]]; then + VERSION_ARGS+=(--type=nightly) + elif [[ "${IS_PREVIEW}" == "true" ]]; then + VERSION_ARGS+=(--type=preview) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}") + fi + else + VERSION_ARGS+=(--type=stable) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}") + fi + fi + + VERSION_JSON=$(node packages/sdk-typescript/scripts/get-release-version.js "${VERSION_ARGS[@]}") + echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT" + echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT" + echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT" + + echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' + IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' + MANUAL_VERSION: '${{ inputs.version }}' + + - name: 'Run Tests' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + working-directory: 'packages/sdk-typescript' + run: | + npm run test:ci + env: + OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' + OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' + OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' + + - name: 'Build CLI for Integration Tests' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + run: | + npm run build + npm run bundle + + - name: 'Run SDK Integration Tests' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + run: | + npm run test:integration:sdk:sandbox:none + npm run test:integration:sdk:sandbox:docker + env: + OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' + OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' + OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' + + - name: 'Configure Git User' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: 'Create and switch to a release branch' + id: 'release_branch' + env: + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}" + git switch -c "${BRANCH_NAME}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + + - name: 'Update package version' + working-directory: 'packages/sdk-typescript' + env: + RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}' + run: |- + npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version + + - name: 'Commit and Conditionally Push package version' + env: + BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + git add packages/sdk-typescript/package.json + if git diff --staged --quiet; then + echo "No version changes to commit" + else + git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}" + fi + if [[ "${IS_DRY_RUN}" == "false" ]]; then + echo "Pushing release branch to remote..." + git push --set-upstream origin "${BRANCH_NAME}" --follow-tags + else + echo "Dry run enabled. Skipping push." + fi + + - name: 'Build SDK' + working-directory: 'packages/sdk-typescript' + run: |- + npm run build + + - name: 'Configure npm for publishing' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + registry-url: 'https://registry.npmjs.org' + scope: '@qwen-code' + + - name: 'Publish @qwen-code/sdk' + working-directory: 'packages/sdk-typescript' + run: |- + npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }} + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + + - name: 'Create GitHub Release and Tag' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' + run: |- + gh release create "sdk-typescript-${RELEASE_TAG}" \ + --target "$RELEASE_BRANCH" \ + --title "SDK TypeScript Release ${RELEASE_TAG}" \ + --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ + --generate-notes + + - name: 'Create Issue on Failure' + if: |- + ${{ failure() }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}" + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: |- + gh issue create \ + --title "SDK Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \ + --body "The SDK release workflow failed. See the full run for details: ${DETAILS_URL}" diff --git a/.vscode/launch.json b/.vscode/launch.json index d98757fb..0ae4f1b1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -79,7 +79,6 @@ "--", "-p", "${input:prompt}", - "-y", "--output-format", "stream-json" ], diff --git a/docs/cli/commands.md b/docs/cli/commands.md index d258bc2d..aa056a43 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -145,16 +145,6 @@ 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. diff --git a/docs/cli/configuration-v1.md b/docs/cli/configuration-v1.md index 5127bbe2..2037db8d 100644 --- a/docs/cli/configuration-v1.md +++ b/docs/cli/configuration-v1.md @@ -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 and quit confirmation dialog. 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. See the [Welcome Back documentation](./welcome-back.md) for more details. diff --git a/docs/features/welcome-back.md b/docs/features/welcome-back.md index 7175406b..1ce552ee 100644 --- a/docs/features/welcome-back.md +++ b/docs/features/welcome-back.md @@ -81,14 +81,6 @@ 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: diff --git a/eslint.config.js b/eslint.config.js index 7b4f502f..5b3b7f3d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,7 @@ export default tseslint.config( 'bundle/**', 'package/bundle/**', '.integration-tests/**', + 'packages/**/.integration-test/**', 'dist/**', ], }, @@ -150,7 +151,7 @@ export default tseslint.config( }, }, { - files: ['packages/*/src/**/*.test.{ts,tsx}'], + files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'], plugins: { vitest, }, @@ -158,11 +159,19 @@ export default tseslint.config( ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, // extra settings for scripts that we run directly with node { - files: ['./scripts/**/*.js', 'esbuild.config.js'], + files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], languageOptions: { globals: { ...globals.node, @@ -229,7 +238,7 @@ export default tseslint.config( prettierConfig, // extra settings for scripts that we run directly with node { - files: ['./integration-tests/**/*.js'], + files: ['./integration-tests/**/*.{js,ts,tsx}'], languageOptions: { globals: { ...globals.node, diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 77105af2..a8a9877f 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -30,6 +30,7 @@ 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(), @@ -48,14 +49,36 @@ export async function setup() { // File doesn't exist, which is fine. } + // Setup for CLI integration tests runDir = join(integrationTestsDir, `${Date.now()}`); await mkdir(runDir, { recursive: true }); + // Setup for SDK E2E tests (separate directory with prefix) + sdkE2eRunDir = join(integrationTestsDir, `sdk-e2e-${Date.now()}`); + await mkdir(sdkE2eRunDir, { recursive: true }); + // Clean up old test runs, but keep the latest few for debugging try { const testRuns = await readdir(integrationTestsDir); - if (testRuns.length > 5) { - const oldRuns = testRuns.sort().slice(0, testRuns.length - 5); + + // Clean up old CLI integration test runs (without sdk-e2e- prefix) + const cliTestRuns = testRuns.filter((run) => !run.startsWith('sdk-e2e-')); + if (cliTestRuns.length > 5) { + const oldRuns = cliTestRuns.sort().slice(0, cliTestRuns.length - 5); + await Promise.all( + oldRuns.map((oldRun) => + rm(join(integrationTestsDir, oldRun), { + recursive: true, + force: true, + }), + ), + ); + } + + // Clean up old SDK E2E test runs (with sdk-e2e- prefix) + const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-')); + if (sdkTestRuns.length > 5) { + const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5); await Promise.all( oldRuns.map((oldRun) => rm(join(integrationTestsDir, oldRun), { @@ -69,24 +92,37 @@ export async function setup() { console.error('Error cleaning up old test runs:', e); } + // Environment variables for CLI integration tests process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log'); + // Environment variables for SDK E2E tests + process.env['E2E_TEST_FILE_DIR'] = sdkE2eRunDir; + process.env['TEST_CLI_PATH'] = join(rootDir, 'dist/cli.js'); + if (process.env['KEEP_OUTPUT']) { console.log(`Keeping output for test run in: ${runDir}`); + console.log(`Keeping output for SDK E2E test run in: ${sdkE2eRunDir}`); } process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; console.log(`\nIntegration test output directory: ${runDir}`); + console.log(`SDK E2E test output directory: ${sdkE2eRunDir}`); + console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); } export async function teardown() { - // Cleanup the test run directory unless KEEP_OUTPUT is set + // Cleanup the CLI test run directory unless KEEP_OUTPUT is set if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { await rm(runDir, { recursive: true, force: true }); } + // Cleanup the SDK E2E test run directory unless KEEP_OUTPUT is set + if (process.env['KEEP_OUTPUT'] !== 'true' && sdkE2eRunDir) { + await rm(sdkE2eRunDir, { recursive: true, force: true }); + } + if (originalMemoryContent !== null) { await mkdir(dirname(memoryFilePath), { recursive: true }); await writeFile(memoryFilePath, originalMemoryContent, 'utf-8'); diff --git a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts new file mode 100644 index 00000000..93005d4b --- /dev/null +++ b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts @@ -0,0 +1,486 @@ +/** + * E2E tests based on abort-and-lifecycle.ts example + * Tests AbortController integration and process lifecycle management + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + AbortError, + isAbortError, + isSDKAssistantMessage, + type TextBlock, + type ContentBlock, +} from '@qwen-code/sdk'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('AbortController and Process Lifecycle (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('abort-and-lifecycle'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('Basic AbortController Usage', () => { + it('should support AbortController cancellation', async () => { + const controller = new AbortController(); + + // Abort after 5 seconds + setTimeout(() => { + controller.abort(); + }, 5000); + + const q = query({ + prompt: 'Write a very long story about TypeScript programming', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 100); + + // Should receive some content before abort + expect(text.length).toBeGreaterThan(0); + } + } + + // Should not reach here - query should be aborted + expect(false).toBe(true); + } catch (error) { + expect(isAbortError(error)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle abort during query execution', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + let receivedFirstMessage = false; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + if (!receivedFirstMessage) { + // Abort immediately after receiving first assistant message + receivedFirstMessage = true; + controller.abort(); + } + } + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + // Should have received at least one message before abort + expect(receivedFirstMessage).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle abort immediately after query starts', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a very long essay', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Abort immediately after query initialization + setTimeout(() => { + controller.abort(); + }, 200); + + try { + for await (const _message of q) { + // May or may not receive messages before abort + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Process Lifecycle Monitoring', () => { + it('should handle normal process completion', async () => { + const q = query({ + prompt: 'Why do we choose to go to the moon?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let completedSuccessfully = false; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 100); + expect(text.length).toBeGreaterThan(0); + } + } + + completedSuccessfully = true; + } catch (error) { + // Should not throw for normal completion + expect(false).toBe(true); + } finally { + await q.close(); + expect(completedSuccessfully).toBe(true); + } + }); + + it('should handle process cleanup after error', async () => { + const q = query({ + prompt: 'Hello world', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 50); + expect(text.length).toBeGreaterThan(0); + } + } + } catch (error) { + // Expected to potentially have errors + } finally { + // Should cleanup successfully even after error + await q.close(); + expect(true).toBe(true); // Cleanup completed + } + }); + }); + + describe('Input Stream Control', () => { + it('should support endInput() method', async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let receivedResponse = false; + let endInputCalled = false; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message) && !endInputCalled) { + const textBlocks = message.message.content.filter( + (block: ContentBlock): block is TextBlock => + block.type === 'text', + ); + const text = textBlocks.map((b: TextBlock) => b.text).join(''); + + expect(text.length).toBeGreaterThan(0); + receivedResponse = true; + + // End input after receiving first response + q.endInput(); + endInputCalled = true; + } + } + + expect(receivedResponse).toBe(true); + expect(endInputCalled).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling and Recovery', () => { + it('should handle invalid executable path', async () => { + try { + const q = query({ + prompt: 'Hello world', + options: { + pathToQwenExecutable: '/nonexistent/path/to/cli', + debug: false, + }, + }); + + // Should not reach here - query() should throw immediately + for await (const _message of q) { + // Should not reach here + } + + // Should not reach here + expect(false).toBe(true); + } catch (error) { + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toBeDefined(); + expect((error as Error).message).toContain( + 'Invalid pathToQwenExecutable', + ); + } + }); + + it('should throw AbortError with correct properties', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Explain the concept of async programming', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Abort after allowing query to start + setTimeout(() => controller.abort(), 1000); + + try { + for await (const _message of q) { + // May receive some messages before abort + } + } catch (error) { + // Verify error type and helper functions + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBeDefined(); + } finally { + await q.close(); + } + }); + }); + + describe('Debugging with stderr callback', () => { + it('should capture stderr messages when debug is enabled', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Why do we choose to go to the moon?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 50); + expect(text.length).toBeGreaterThan(0); + } + } + } finally { + await q.close(); + expect(stderrMessages.length).toBeGreaterThan(0); + } + }); + + it('should not capture stderr when debug is disabled', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + } finally { + await q.close(); + // Should have minimal or no stderr output when debug is false + expect(stderrMessages.length).toBeLessThan(10); + } + }); + }); + + describe('Abort with Cleanup', () => { + it('should cleanup properly after abort', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a very long essay about programming', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Abort immediately + setTimeout(() => controller.abort(), 100); + + try { + for await (const _message of q) { + // May receive some messages before abort + } + } catch (error) { + if (error instanceof AbortError) { + expect(true).toBe(true); // Expected abort error + } else { + throw error; // Unexpected error + } + } finally { + await q.close(); + expect(true).toBe(true); // Cleanup completed after abort + } + }); + + it('should handle multiple abort calls gracefully', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Count to 100', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Multiple abort calls + setTimeout(() => controller.abort(), 100); + setTimeout(() => controller.abort(), 200); + setTimeout(() => controller.abort(), 300); + + try { + for await (const _message of q) { + // Should be interrupted + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Resource Management Edge Cases', () => { + it('should handle close() called multiple times', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + // Start the query + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Close multiple times + await q.close(); + await q.close(); + await q.close(); + + // Should not throw + expect(true).toBe(true); + }); + + it('should handle abort after close', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + abortController: controller, + debug: false, + }, + }); + + // Start and close immediately + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + await q.close(); + + // Abort after close + controller.abort(); + + // Should not throw + expect(true).toBe(true); + }); + }); +}); diff --git a/integration-tests/sdk-typescript/configuration-options.test.ts b/integration-tests/sdk-typescript/configuration-options.test.ts new file mode 100644 index 00000000..bc59cd79 --- /dev/null +++ b/integration-tests/sdk-typescript/configuration-options.test.ts @@ -0,0 +1,640 @@ +/** + * @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(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/mcp-server.test.ts b/integration-tests/sdk-typescript/mcp-server.test.ts new file mode 100644 index 00000000..9b3f2193 --- /dev/null +++ b/integration-tests/sdk-typescript/mcp-server.test.ts @@ -0,0 +1,405 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for MCP (Model Context Protocol) server integration via SDK + * Tests that the SDK can properly interact with MCP servers configured in qwen-code + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKSystemMessage, + isSDKUserMessage, + type SDKMessage, + type ToolUseBlock, + type SDKSystemMessage, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + createMCPServer, + extractText, + findToolUseBlocks, + createSharedTestOptions, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = { + ...createSharedTestOptions(), + permissionMode: 'yolo' as const, +}; + +describe('MCP Server Integration (E2E)', () => { + let helper: SDKTestHelper; + let serverScriptPath: string; + let testDir: string; + + beforeEach(async () => { + // Create isolated test environment using SDKTestHelper + helper = new SDKTestHelper(); + testDir = await helper.setup('mcp-server-integration'); + + // Create MCP server using the helper utility + const mcpServer = await createMCPServer(helper, 'math', 'test-math-server'); + serverScriptPath = mcpServer.scriptPath; + }); + + afterEach(async () => { + // Cleanup test directory + await helper.cleanup(); + }); + + describe('Basic MCP Tool Usage', () => { + it('should use MCP add tool to add two numbers', async () => { + const q = query({ + prompt: + 'Use the add tool to calculate 5 + 10. Just give me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'add'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer + expect(assistantText).toMatch(/15/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + if (isSDKResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should use MCP multiply tool to multiply two numbers', async () => { + const q = query({ + prompt: + 'Use the multiply tool to calculate 6 * 7. Just give me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'multiply'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer + expect(assistantText).toMatch(/42/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('MCP Server Discovery', () => { + it('should list MCP servers in system init message', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + let systemMessage: SDKSystemMessage | null = null; + + try { + for await (const message of q) { + if (isSDKSystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + break; + } + } + + // Validate MCP server is listed + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + + // Find our test server + const testServer = systemMessage!.mcp_servers?.find( + (server) => server.name === 'test-math-server', + ); + expect(testServer).toBeDefined(); + + // Note: tools are not exposed in the mcp_servers array in system message + // They are available through the MCP protocol but not in the init message + } finally { + await q.close(); + } + }); + }); + + describe('Complex MCP Operations', () => { + it('should chain multiple MCP tool calls', async () => { + const q = query({ + prompt: + 'First use add to calculate 10 + 5, then multiply the result by 2. Give me the final answer.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + const toolCalls: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); + toolUseBlocks.forEach((block) => { + toolCalls.push(block.name); + }); + assistantText += extractText(message.message.content); + } + } + + // Validate both tools were called + expect(toolCalls).toContain('add'); + expect(toolCalls).toContain('multiply'); + + // Validate result: (10 + 5) * 2 = 30 + expect(assistantText).toMatch(/30/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle multiple calls to the same MCP tool', async () => { + const q = query({ + prompt: + 'Use the add tool twice: first add 1 + 2, then add 3 + 4. Tell me both results.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + const addToolCalls: ToolUseBlock[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'add'); + addToolCalls.push(...toolUseBlocks); + assistantText += extractText(message.message.content); + } + } + + // Validate add tool was called at least twice + expect(addToolCalls.length).toBeGreaterThanOrEqual(2); + + // Validate results contain expected answers: 3 and 7 + expect(assistantText).toMatch(/3/); + expect(assistantText).toMatch(/7/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('MCP Tool Message Flow', () => { + it('should receive proper message sequence for MCP tool usage', async () => { + const q = query({ + prompt: 'Use add to calculate 2 + 3', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messageTypes: string[] = []; + let foundToolUse = false; + let foundToolResult = false; + + try { + for await (const message of q) { + messageTypes.push(message.type); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + expect(toolUseBlocks[0].name).toBe('add'); + expect(toolUseBlocks[0].input).toBeDefined(); + } + } + + if (isSDKUserMessage(message)) { + const content = message.message.content; + const contentArray = Array.isArray(content) + ? content + : [{ type: 'text', text: content }]; + const toolResultBlock = contentArray.find( + (block) => block.type === 'tool_result', + ); + if (toolResultBlock) { + foundToolResult = true; + } + } + } + + // Validate message flow + expect(foundToolUse).toBe(true); + expect(foundToolResult).toBe(true); + expect(messageTypes).toContain('system'); + expect(messageTypes).toContain('assistant'); + expect(messageTypes).toContain('user'); + expect(messageTypes).toContain('result'); + + // Result should be last message + expect(messageTypes[messageTypes.length - 1]).toBe('result'); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling', () => { + it('should handle gracefully when MCP tool is not available', async () => { + const q = query({ + prompt: 'Use the subtract tool to calculate 10 - 5', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Should complete without crashing + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + + // Assistant should indicate tool is not available or provide alternative + expect(assistantText.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/multi-turn.test.ts b/integration-tests/sdk-typescript/multi-turn.test.ts new file mode 100644 index 00000000..c1b96cc7 --- /dev/null +++ b/integration-tests/sdk-typescript/multi-turn.test.ts @@ -0,0 +1,559 @@ +/** + * E2E tests based on multi-turn.ts example + * Tests multi-turn conversation functionality with real CLI + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, + type SDKUserMessage, + type SDKAssistantMessage, + type TextBlock, + type ContentBlock, + type SDKMessage, + type ControlMessage, + type ToolUseBlock, +} from '@qwen-code/sdk'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +/** + * Determine the message type using protocol type guards + */ +function getMessageType(message: SDKMessage | ControlMessage): string { + if (isSDKUserMessage(message)) { + return '🧑 USER'; + } else if (isSDKAssistantMessage(message)) { + return '🤖 ASSISTANT'; + } else if (isSDKSystemMessage(message)) { + return `🖥️ SYSTEM(${message.subtype})`; + } else if (isSDKResultMessage(message)) { + return `✅ RESULT(${message.subtype})`; + } else if (isSDKPartialAssistantMessage(message)) { + return '⏳ STREAM_EVENT'; + } else if (isControlRequest(message)) { + return `🎮 CONTROL_REQUEST(${message.request.subtype})`; + } else if (isControlResponse(message)) { + return `📭 CONTROL_RESPONSE(${message.response.subtype})`; + } else if (isControlCancel(message)) { + return '🛑 CONTROL_CANCEL'; + } else { + return '❓ UNKNOWN'; + } +} + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +describe('Multi-Turn Conversations (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('multi-turn'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('AsyncIterable Prompt Support', () => { + it('should handle multi-turn conversation using AsyncIterable prompt', async () => { + // Create multi-turn conversation generator + async function* createMultiTurnConversation(): AsyncIterable { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts new file mode 100644 index 00000000..e8d201e6 --- /dev/null +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -0,0 +1,1249 @@ +/** + * E2E tests for permission control features: + * - canUseTool callback parameter + * - setPermissionMode API + */ + +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + afterEach, +} from 'vitest'; +import { + query, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKUserMessage, + type SDKMessage, + type SDKUserMessage, + type ToolUseBlock, + type ContentBlock, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + createSharedTestOptions, + hasAnyToolResults, + hasSuccessfulToolResults, + hasErrorToolResults, + findSystemMessage, + findToolCalls, +} from './test-helper.js'; + +const TEST_TIMEOUT = 30000; +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 setPermissionMode. + */ +function createStreamingInputWithControlPoint( + firstMessage: string, + secondMessage: string, +): { + generator: AsyncIterable; + resume: () => void; +} { + let resumeResolve: (() => void) | null = null; + const resumePromise = new Promise((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('Permission Control (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeAll(() => { + //process.env['DEBUG'] = '1'; + }); + + afterAll(() => { + delete process.env['DEBUG']; + }); + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('permission-control'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('canUseTool callback parameter', () => { + it('should invoke canUseTool callback when tool is requested', async () => { + const toolCalls: Array<{ + toolName: string; + input: Record; + }> = []; + + const q = query({ + prompt: 'Write a js hello world to file.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + canUseTool: async (toolName, input) => { + toolCalls.push({ toolName, input }); + return { + behavior: 'deny', + message: 'Tool execution denied by user.', + }; + }, + }, + }); + + try { + let hasToolUse = false; + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock) { + hasToolUse = true; + } + } + } + + expect(hasToolUse).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); + expect(toolCalls[0].toolName).toBeDefined(); + expect(toolCalls[0].input).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should allow tool execution when canUseTool returns allow', async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named hello.txt with content "world"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasToolResult = false; + for await (const message of q) { + if (isSDKUserMessage(message)) { + if ( + Array.isArray(message.message.content) && + message.message.content.some( + (block) => block.type === 'tool_result', + ) + ) { + hasToolResult = true; + } + } + } + + expect(callbackInvoked).toBe(true); + expect(hasToolResult).toBe(true); + } finally { + await q.close(); + } + }); + + it('should deny tool execution when canUseTool returns deny', async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test.txt', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + canUseTool: async () => { + callbackInvoked = true; + return { + behavior: 'deny', + message: 'Tool execution denied by test', + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(callbackInvoked).toBe(true); + // Tool use might still appear, but execution should be denied + // The exact behavior depends on CLI implementation + } finally { + await q.close(); + } + }); + + it('should pass suggestions to canUseTool callback', async () => { + let receivedSuggestions: unknown = null; + + const q = query({ + prompt: 'Create a file named data.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input, options) => { + receivedSuggestions = options?.suggestions; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // Suggestions may be null or an array, depending on CLI implementation + expect(receivedSuggestions !== undefined).toBe(true); + } finally { + await q.close(); + } + }); + + it('should pass abort signal to canUseTool callback', async () => { + let receivedSignal: AbortSignal | undefined = undefined; + + const q = query({ + prompt: 'Create a file named signal.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input, options) => { + receivedSignal = options?.signal; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(receivedSignal).toBeDefined(); + expect(receivedSignal).toBeInstanceOf(AbortSignal); + } finally { + await q.close(); + } + }); + + it('should default to deny when canUseTool is not provided', async () => { + const q = query({ + prompt: 'Create a file named default.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + // canUseTool not provided + }, + }); + + try { + // When canUseTool is not provided, tools should be denied by default + // The exact behavior depends on CLI implementation + for await (const _message of q) { + // Consume all messages + } + // Test passes if no errors occur + expect(true).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('setPermissionMode API', () => { + it('should change permission mode from default to yolo', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 1 + 1?', + 'What is 2 + 2?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + debug: true, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 40000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('yolo'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 40000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should change permission mode from yolo to plan', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 3 + 3?', + 'What is 4 + 4?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('plan'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should change permission mode to auto-edit', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 5 + 5?', + 'What is 6 + 6?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 15000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('auto-edit'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should throw error when setPermissionMode is called on closed query', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + timeout: { + /** + * We use a short control request timeout and + * wait till the time exceeded to test if + * an immediate close() will raise an query close + * error and no other uncaught timeout error + */ + controlRequest: 5000, + }, + }, + }); + + await q.close(); + + await expect(q.setPermissionMode('yolo')).rejects.toThrow( + 'Query is closed', + ); + + await new Promise((resolve) => setTimeout(resolve, 8000)); + }, 10_000); + }); + + describe('canUseTool and setPermissionMode integration', () => { + it('should work together - canUseTool callback with dynamic permission mode change', async () => { + const toolCalls: Array<{ + toolName: string; + input: Record; + }> = []; + + const { generator, resume } = createStreamingInputWithControlPoint( + 'Create a file named first.txt', + 'Create a file named second.txt', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input) => { + toolCalls.push({ toolName, input }); + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isSDKResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + TEST_TIMEOUT, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); + + await q.setPermissionMode('yolo'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + TEST_TIMEOUT, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('ApprovalMode behavior tests', () => { + describe('default mode', () => { + it( + 'should auto-deny tools requiring confirmation without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-default-deny.txt with content "hello"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + // No canUseTool callback provided + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // In default mode without canUseTool, tools should be denied + expect(hasAnyToolResults(messages)).toBe(true); + expect(hasErrorToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow tools when canUseTool returns allow', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: + 'Create a file named test-default-allow.txt with content "world"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(callbackInvoked).toBe(true); + expect(hasSuccessfulToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute read-only tools without confirmation', + async () => { + const q = query({ + prompt: 'List files in the current directory', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: testDir, + // No canUseTool callback - read-only tools should still work + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasAnyToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('yolo mode', () => { + it( + 'should auto-approve all tools without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-yolo.txt with content "yolo mode"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: testDir, + // No canUseTool callback - tools should still execute + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasSuccessfulToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not invoke canUseTool callback in yolo mode', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-yolo-no-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasAnyToolResults(messages)).toBe(true); + // canUseTool should not be invoked in yolo mode + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute dangerous commands without confirmation', + async () => { + const q = query({ + prompt: 'Run command: echo "dangerous operation"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasAnyToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('plan mode', () => { + // Write tools that should never be called in plan mode + const WRITE_TOOLS = [ + 'edit', + 'write_file', + 'run_shell_command', + 'delete_file', + 'move_file', + ]; + + // Read tools that should be allowed in plan mode + const READ_TOOLS = [ + 'read_file', + 'read_many_files', + 'grep_search', + 'glob', + 'list_directory', + 'web_search', + 'web_fetch', + ]; + + it( + 'should have permission_mode set to plan in system message', + async () => { + const q = query({ + prompt: 'List files in the current directory', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Find the init system message + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.permission_mode).toBe('plan'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not call any write tools in plan mode', + async () => { + // Create a test file so the model has something to reference + await helper.createFile( + 'test-plan-file.txt', + 'This is test content for plan mode verification.', + ); + + const q = query({ + prompt: + 'Read the file test-plan-file.txt and suggest how to improve its content.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Find all tool calls and verify none are write tools + const allToolCalls = findToolCalls(messages); + const writeToolCalls = allToolCalls.filter((tc) => + WRITE_TOOLS.includes(tc.toolUse.name), + ); + + // No write tools should be called in plan mode + expect(writeToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow read-only tools without restrictions', + async () => { + // Create test files for the model to read + await helper.createFile('test-read-1.txt', 'Content of file 1'); + await helper.createFile('test-read-2.txt', 'Content of file 2'); + + const q = query({ + prompt: + 'Read the contents of test-read-1.txt and test-read-2.txt files, then list files in the current directory.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Find all tool calls + const allToolCalls = findToolCalls(messages); + + // Verify read tools were called (at least one) + const readToolCalls = allToolCalls.filter((tc) => + READ_TOOLS.includes(tc.toolUse.name), + ); + expect(readToolCalls.length).toBeGreaterThan(0); + + // Verify tool results are successful (not blocked) + expect(hasSuccessfulToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not invoke canUseTool callback in plan mode since no permission approval is expected', + async () => { + let callbackInvoked = false; + + // Create a test file for reading + await helper.createFile( + 'test-plan-callback.txt', + 'Content for callback test', + ); + + const q = query({ + prompt: 'Read the file test-plan-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Read tools should work without invoking canUseTool + // In plan mode, no permission approval is expected from user + expect(hasSuccessfulToolResults(messages)).toBe(true); + + // canUseTool should not be invoked in plan mode + // since plan mode is for research only, no permission interaction needed + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should only output research and plan as text, no actual changes', + async () => { + // Create a test file + const originalContent = 'Original content for plan mode test'; + await helper.createFile('test-no-changes.txt', originalContent); + + const q = query({ + prompt: + 'Read test-no-changes.txt and plan how you would modify it to add a header. Do not actually make any changes.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Verify the file was not modified + const fileContent = await helper.readFile('test-no-changes.txt'); + expect(fileContent).toBe(originalContent); + + // Verify no write tools were called + const allToolCalls = findToolCalls(messages); + const writeToolCalls = allToolCalls.filter((tc) => + WRITE_TOOLS.includes(tc.toolUse.name), + ); + expect(writeToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('auto-edit mode', () => { + it( + 'should auto-approve write/edit tools without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-auto-edit.txt with content "auto-edit test"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + cwd: testDir, + // No canUseTool callback - write/edit tools should still execute + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // auto-edit mode should auto-approve write/edit tools + expect(hasSuccessfulToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not invoke canUseTool callback for write/edit tools', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-auto-edit-no-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + cwd: testDir, + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // auto-edit mode should auto-approve write/edit tools without invoking callback + expect(hasSuccessfulToolResults(messages)).toBe(true); + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute read-only tools without confirmation', + async () => { + // Create a test file in the test directory for the model to read + await helper.createFile( + 'test-read-file.txt', + 'This is a test file for read-only tool verification.', + ); + + const q = query({ + prompt: 'Read the contents of test-read-file.txt file', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'auto-edit', + // No canUseTool callback - read-only tools should still work + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + expect(hasAnyToolResults(messages)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('mode comparison tests', () => { + it.skip( + 'should demonstrate different behaviors across all modes for write operations', + async () => { + const modes: Array<'default' | 'auto-edit' | 'yolo'> = [ + 'default', + 'auto-edit', + 'yolo', + ]; + const results: Record = {}; + + for (const mode of modes) { + const q = query({ + prompt: `Create a file named test-${mode}.txt`, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: mode, + cwd: testDir, + canUseTool: + mode === 'yolo' || mode === 'auto-edit' + ? undefined + : async (toolName, input) => { + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + results[mode] = hasSuccessfulToolResults(messages); + } finally { + await q.close(); + } + } + + // Verify expected behaviors + expect(results['default']).toBe(true); // Allowed via canUseTool + // expect(results['plan']).toBe(false); // Blocked by plan mode + expect(results['auto-edit']).toBe(true); // Auto-approved for write/edit tools + expect(results['yolo']).toBe(true); // Auto-approved for all tools + }, + TEST_TIMEOUT * 4, + ); + }); + }); +}); diff --git a/integration-tests/sdk-typescript/sdk-mcp-server.test.ts b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts new file mode 100644 index 00000000..1ce8658e --- /dev/null +++ b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts @@ -0,0 +1,456 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for SDK-embedded MCP servers + * + * Tests that the SDK can create and manage MCP servers running in the SDK process + * using the tool() and createSdkMcpServer() APIs. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { z } from 'zod'; +import { + query, + tool, + createSdkMcpServer, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKSystemMessage, + type SDKMessage, + type SDKSystemMessage, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + extractText, + findToolUseBlocks, + createSharedTestOptions, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = { + ...createSharedTestOptions(), + permissionMode: 'yolo' as const, +}; + +describe('SDK MCP Server Integration (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('sdk-mcp-server-integration'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('Basic SDK MCP Tool Usage', () => { + it('should use SDK MCP tool to perform a simple calculation', async () => { + // Define a simple calculator tool using the tool() API with Zod schema + const calculatorTool = tool( + 'calculate_sum', + 'Calculate the sum of two numbers', + z.object({ + a: z.number().describe('First number'), + b: z.number().describe('Second number'), + }).shape, + async (args) => ({ + content: [{ type: 'text', text: String(args.a + args.b) }], + }), + ); + + // Create SDK MCP server with the tool + const serverConfig = createSdkMcpServer({ + name: 'sdk-calculator', + version: '1.0.0', + tools: [calculatorTool], + }); + + const q = query({ + prompt: + 'Use the calculate_sum tool to add 25 and 17. Output the result of tool only.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + mcpServers: { + 'sdk-calculator': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'calculate_sum'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer: 25 + 17 = 42 + expect(assistantText).toMatch(/42/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + if (isSDKResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should use SDK MCP tool with string operations', async () => { + // Define a string manipulation tool with Zod schema + const stringTool = tool( + 'reverse_string', + 'Reverse a string', + { + text: z.string().describe('The text to reverse'), + }, + async (args) => ({ + content: [ + { type: 'text', text: args.text.split('').reverse().join('') }, + ], + }), + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-string-utils', + version: '1.0.0', + tools: [stringTool], + }); + + const q = query({ + prompt: `Use the 'reverse_string' tool to process the word "hello world". Output the tool result only.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + mcpServers: { + 'sdk-string-utils': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'reverse_string'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains reversed string: "olleh" + expect(assistantText.toLowerCase()).toMatch(/olleh/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Multiple SDK MCP Tools', () => { + it('should use multiple tools from the same SDK MCP server', async () => { + // Define the Zod schema shape for two numbers + const twoNumbersSchema = { + a: z.number().describe('First number'), + b: z.number().describe('Second number'), + }; + + // Define multiple tools + const addTool = tool( + 'sdk_add', + 'Add two numbers', + twoNumbersSchema, + async (args) => ({ + content: [{ type: 'text', text: String(args.a + args.b) }], + }), + ); + + const multiplyTool = tool( + 'sdk_multiply', + 'Multiply two numbers', + twoNumbersSchema, + async (args) => ({ + content: [{ type: 'text', text: String(args.a * args.b) }], + }), + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-math', + version: '1.0.0', + tools: [addTool, multiplyTool], + }); + + const q = query({ + prompt: + 'First use sdk_add to calculate 10 + 5, then use sdk_multiply to multiply the result by 3. Give me the final answer.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'sdk-math': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + const toolCalls: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); + toolUseBlocks.forEach((block) => { + toolCalls.push(block.name); + }); + assistantText += extractText(message.message.content); + } + } + + // Validate both tools were called + expect(toolCalls).toContain('sdk_add'); + expect(toolCalls).toContain('sdk_multiply'); + + // Validate result: (10 + 5) * 3 = 45 + expect(assistantText).toMatch(/45/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('SDK MCP Server Discovery', () => { + it('should list SDK MCP servers in system init message', async () => { + // Define echo tool with Zod schema + const echoTool = tool( + 'echo', + 'Echo a message', + { + message: z.string().describe('Message to echo'), + }, + async (args) => ({ + content: [{ type: 'text', text: args.message }], + }), + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-echo', + version: '1.0.0', + tools: [echoTool], + }); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'sdk-echo': serverConfig, + }, + }, + }); + + let systemMessage: SDKSystemMessage | null = null; + + try { + for await (const message of q) { + if (isSDKSystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + break; + } + } + + // Validate MCP server is listed + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + + // Find our SDK MCP server + const sdkServer = systemMessage!.mcp_servers?.find( + (server) => server.name === 'sdk-echo', + ); + expect(sdkServer).toBeDefined(); + } finally { + await q.close(); + } + }); + }); + + describe('SDK MCP Tool Error Handling', () => { + it('should handle tool errors gracefully', async () => { + // Define a tool that throws an error with Zod schema + const errorTool = tool( + 'maybe_fail', + 'A tool that may fail based on input', + { + shouldFail: z.boolean().describe('If true, the tool will fail'), + }, + async (args) => { + if (args.shouldFail) { + throw new Error('Tool intentionally failed'); + } + return { content: [{ type: 'text', text: 'Success!' }] }; + }, + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-error-test', + version: '1.0.0', + tools: [errorTool], + }); + + const q = query({ + prompt: + 'Use the maybe_fail tool with shouldFail set to true. Tell me what happens.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'sdk-error-test': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'maybe_fail'); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + } + } + + // Tool should be called + expect(foundToolUse).toBe(true); + + // Query should complete (even with tool error) + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Async Tool Handlers', () => { + it('should handle async tool handlers with delays', async () => { + // Define a tool with async delay using Zod schema + const delayedTool = tool( + 'delayed_response', + 'Returns a value after a delay', + { + delay: z.number().describe('Delay in milliseconds (max 100)'), + value: z.string().describe('Value to return'), + }, + async (args) => { + // Cap delay at 100ms for test performance + const actualDelay = Math.min(args.delay, 100); + await new Promise((resolve) => setTimeout(resolve, actualDelay)); + return { + content: [{ type: 'text', text: `Delayed result: ${args.value}` }], + }; + }, + ); + + const serverConfig = createSdkMcpServer({ + name: 'sdk-async', + version: '1.0.0', + tools: [delayedTool], + }); + + const q = query({ + prompt: + 'Use the delayed_response tool with delay=50 and value="test_async". Tell me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'sdk-async': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks( + message, + 'delayed_response', + ); + if (toolUseBlocks.length > 0) { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains the delayed response + expect(assistantText.toLowerCase()).toMatch(/test_async/i); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isSDKResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/single-turn.test.ts b/integration-tests/sdk-typescript/single-turn.test.ts new file mode 100644 index 00000000..3608e619 --- /dev/null +++ b/integration-tests/sdk-typescript/single-turn.test.ts @@ -0,0 +1,528 @@ +/** + * E2E tests for single-turn query execution + * Tests basic query patterns with simple prompts and clear output expectations + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + type SDKMessage, + type SDKSystemMessage, + type SDKAssistantMessage, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + extractText, + createSharedTestOptions, + assertSuccessfulCompletion, + collectMessagesByType, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('Single-Turn Query (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('single-turn'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('Simple Text Queries', () => { + it('should answer basic arithmetic question', async () => { + const q = query({ + prompt: 'What is 2 + 2? Just give me the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: true, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate we got messages + expect(messages.length).toBeGreaterThan(0); + + // Validate assistant response content + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText).toMatch(/4/); + + // Validate message flow ends with success + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should answer simple factual question', async () => { + const q = query({ + prompt: 'What is the capital of France? One word answer.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate content + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText.toLowerCase()).toContain('paris'); + + // Validate completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should handle greeting and self-description', async () => { + const q = query({ + prompt: 'Say hello and tell me your name in one sentence.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate content contains greeting + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/); + + // Validate message types + const assistantMessages = collectMessagesByType( + messages, + isSDKAssistantMessage, + ); + expect(assistantMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); + + describe('System Initialization', () => { + it('should receive system message with initialization info', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let systemMessage: SDKSystemMessage | null = null; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKSystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate system message exists and has required fields + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.type).toBe('system'); + expect(systemMessage!.subtype).toBe('init'); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.cwd).toBeDefined(); + expect(systemMessage!.tools).toBeDefined(); + expect(Array.isArray(systemMessage!.tools)).toBe(true); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + expect(systemMessage!.model).toBeDefined(); + expect(systemMessage!.permission_mode).toBeDefined(); + expect(systemMessage!.qwen_code_version).toBeDefined(); + + // Validate system message appears early in sequence + const systemMessageIndex = messages.findIndex( + (msg) => isSDKSystemMessage(msg) && msg.subtype === 'init', + ); + expect(systemMessageIndex).toBeGreaterThanOrEqual(0); + expect(systemMessageIndex).toBeLessThan(3); + } finally { + await q.close(); + } + }); + + it('should maintain session ID consistency', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let systemMessage: SDKSystemMessage | null = null; + const sessionId = q.getSessionId(); + + try { + for await (const message of q) { + if (isSDKSystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate session IDs are consistent + expect(sessionId).toBeDefined(); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + } finally { + await q.close(); + } + }); + }); + + describe('Message Flow', () => { + it('should follow expected message sequence', async () => { + const q = query({ + prompt: 'Say hi', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messageTypes: string[] = []; + + try { + for await (const message of q) { + messageTypes.push(message.type); + } + + // Validate message sequence + expect(messageTypes.length).toBeGreaterThan(0); + expect(messageTypes).toContain('assistant'); + expect(messageTypes[messageTypes.length - 1]).toBe('result'); + } finally { + await q.close(); + } + }); + + it('should complete iteration naturally', async () => { + const q = query({ + prompt: 'Say goodbye', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let completedNaturally = false; + let messageCount = 0; + + try { + for await (const message of q) { + messageCount++; + + if (isSDKResultMessage(message)) { + completedNaturally = true; + expect(message.subtype).toBe('success'); + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Configuration Options', () => { + it('should respect debug option and capture stderr', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // Debug mode should produce stderr output + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should respect cwd option', async () => { + const q = query({ + prompt: 'What is 1 + 1?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let hasResponse = false; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + hasResponse = true; + } + } + + expect(hasResponse).toBe(true); + } finally { + await q.close(); + } + }); + + it('should receive partial messages when includePartialMessages is enabled', async () => { + const q = query({ + prompt: 'Count from 1 to 5', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + includePartialMessages: true, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let partialMessageCount = 0; + let assistantMessageCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKPartialAssistantMessage(message)) { + partialMessageCount++; + } + + if (isSDKAssistantMessage(message)) { + assistantMessageCount++; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(partialMessageCount).toBeGreaterThan(0); + expect(assistantMessageCount).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); + + describe('Message Type Recognition', () => { + it('should correctly identify all message types', async () => { + const q = query({ + prompt: 'What is 5 + 5?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate type guards work correctly + const assistantMessages = collectMessagesByType( + messages, + isSDKAssistantMessage, + ); + const resultMessages = collectMessagesByType( + messages, + isSDKResultMessage, + ); + const systemMessages = collectMessagesByType( + messages, + isSDKSystemMessage, + ); + + expect(assistantMessages.length).toBeGreaterThan(0); + expect(resultMessages.length).toBeGreaterThan(0); + expect(systemMessages.length).toBeGreaterThan(0); + + // Validate assistant message structure + const firstAssistant = assistantMessages[0]; + expect(firstAssistant.message.content).toBeDefined(); + expect(Array.isArray(firstAssistant.message.content)).toBe(true); + + // Validate result message structure + const resultMessage = resultMessages[0]; + expect(resultMessage.subtype).toBe('success'); + } finally { + await q.close(); + } + }); + + it('should extract text content from assistant messages', async () => { + const q = query({ + prompt: 'Count from 1 to 3', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let assistantMessage: SDKAssistantMessage | null = null; + + try { + for await (const message of q) { + if (isSDKAssistantMessage(message)) { + assistantMessage = message; + } + } + + expect(assistantMessage).not.toBeNull(); + expect(assistantMessage!.message.content).toBeDefined(); + + // Validate content contains expected numbers + const text = extractText(assistantMessage!.message.content); + expect(text.length).toBeGreaterThan(0); + expect(text).toMatch(/1/); + expect(text).toMatch(/2/); + expect(text).toMatch(/3/); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling', () => { + it('should throw if CLI not found', async () => { + try { + const q = query({ + prompt: 'Hello', + options: { + pathToQwenExecutable: '/nonexistent/path/to/cli', + debug: false, + }, + }); + + for await (const _message of q) { + // Should not reach here + } + + expect(false).toBe(true); // Should have thrown + } catch (error) { + expect(error).toBeDefined(); + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toContain( + 'Invalid pathToQwenExecutable', + ); + } + }); + }); + + describe('Resource Management', () => { + it('should cleanup subprocess on close()', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + // Start and immediately close + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Should close without error + await q.close(); + expect(true).toBe(true); // Cleanup completed + }); + + it('should handle close() called multiple times', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + // Start the query + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Close multiple times + await q.close(); + await q.close(); + await q.close(); + + // Should not throw + expect(true).toBe(true); + }); + }); +}); diff --git a/integration-tests/sdk-typescript/subagents.test.ts b/integration-tests/sdk-typescript/subagents.test.ts new file mode 100644 index 00000000..c327c96e --- /dev/null +++ b/integration-tests/sdk-typescript/subagents.test.ts @@ -0,0 +1,614 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for subagent configuration and execution + * Tests subagent delegation and task completion + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKAssistantMessage, + type SDKMessage, + type SubagentConfig, + type ContentBlock, + type ToolUseBlock, +} from '@qwen-code/sdk'; +import { + SDKTestHelper, + extractText, + createSharedTestOptions, + findToolUseBlocks, + assertSuccessfulCompletion, + findSystemMessage, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('Subagents (E2E)', () => { + let helper: SDKTestHelper; + let testWorkDir: string; + + beforeEach(async () => { + // Create isolated test environment using SDKTestHelper + helper = new SDKTestHelper(); + testWorkDir = await helper.setup('subagent-tests'); + + // Create a simple test file for subagent to work with + await helper.createFile('test.txt', 'Hello from test file\n'); + }); + + afterEach(async () => { + // Cleanup test directory + await helper.cleanup(); + }); + + describe('Subagent Configuration', () => { + it('should accept session-level subagent configuration', async () => { + const simpleSubagent: SubagentConfig = { + name: 'simple-greeter', + description: 'A simple subagent that responds to greetings', + systemPrompt: + 'You are a friendly greeter. When given a task, respond with a cheerful greeting.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello, let simple-greeter to say hi back to me.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleSubagent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate system message includes the subagent + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('simple-greeter'); + + // Validate successful completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should accept multiple subagent configurations', async () => { + const greeterAgent: SubagentConfig = { + name: 'greeter', + description: 'Responds to greetings', + systemPrompt: 'You are a friendly greeter.', + level: 'session', + }; + + const mathAgent: SubagentConfig = { + name: 'math-helper', + description: 'Helps with math problems', + systemPrompt: 'You are a math expert. Solve math problems clearly.', + level: 'session', + }; + + const q = query({ + prompt: 'What is 5 + 5?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [greeterAgent, mathAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate both subagents are registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('greeter'); + expect(systemMessage!.agents).toContain('math-helper'); + expect(systemMessage!.agents!.length).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + + it('should handle subagent with custom model config', async () => { + const customModelAgent: SubagentConfig = { + name: 'custom-model-agent', + description: 'Agent with custom model configuration', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + modelConfig: { + temp: 0.7, + top_p: 0.9, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [customModelAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('custom-model-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with run config', async () => { + const limitedAgent: SubagentConfig = { + name: 'limited-agent', + description: 'Agent with execution limits', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + runConfig: { + max_turns: 5, + max_time_minutes: 1, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [limitedAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('limited-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with specific tools', async () => { + const toolRestrictedAgent: SubagentConfig = { + name: 'read-only-agent', + description: 'Agent that can only read files', + systemPrompt: + 'You are a file reading assistant. Read files when asked.', + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [toolRestrictedAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('read-only-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Execution', () => { + it('should delegate task to subagent when appropriate', async () => { + const fileReaderAgent: SubagentConfig = { + name: 'file-reader', + description: 'Reads and reports file contents', + systemPrompt: `You are a file reading assistant. When given a task to read a file, use the read_file tool to read it and report its contents back. Be concise in your response.`, + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const testFile = helper.getPath('test.txt'); + const q = query({ + prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [fileReaderAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: SDKMessage[] = []; + let foundTaskTool = false; + let taskToolUseId: string | null = null; + let foundSubagentToolCall = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + // Check for task tool use in content blocks (main agent calling subagent) + const taskToolBlocks = findToolUseBlocks(message, 'task'); + if (taskToolBlocks.length > 0) { + foundTaskTool = true; + taskToolUseId = taskToolBlocks[0].id; + } + + // Check if this message is from a subagent (has parent_tool_use_id) + if (message.parent_tool_use_id !== null) { + // This is a subagent message + const subagentToolBlocks = findToolUseBlocks(message); + if (subagentToolBlocks.length > 0) { + foundSubagentToolCall = true; + // Verify parent_tool_use_id matches the task tool use id + expect(message.parent_tool_use_id).toBe(taskToolUseId); + } + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent delegation) + expect(foundTaskTool).toBe(true); + expect(taskToolUseId).not.toBeNull(); + + // Validate subagent actually made tool calls with proper parent_tool_use_id + expect(foundSubagentToolCall).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }, 60000); // Increase timeout for subagent execution + + it('should complete simple task with subagent', async () => { + const simpleTaskAgent: SubagentConfig = { + name: 'simple-calculator', + description: 'Performs simple arithmetic calculations', + systemPrompt: + 'You are a calculator. When given a math problem, solve it and provide just the answer.', + level: 'session', + }; + + const q = query({ + prompt: 'Use the simple-calculator subagent to calculate 15 + 27.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleTaskAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: SDKMessage[] = []; + let foundTaskTool = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + // Check for task tool use (main agent delegating to subagent) + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use' && block.name === 'task', + ); + if (toolUseBlock) { + foundTaskTool = true; + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent was called) + expect(foundTaskTool).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }, 60000); + + it('should verify subagent execution with comprehensive parent_tool_use_id checks', async () => { + const comprehensiveAgent: SubagentConfig = { + name: 'comprehensive-agent', + description: 'Agent for comprehensive testing', + systemPrompt: + 'You are a helpful assistant. When asked to list files, use the list_directory tool.', + level: 'session', + tools: ['list_directory', 'read_file'], + }; + + const q = query({ + prompt: `Use the comprehensive-agent subagent to list the files in ${testWorkDir}.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [comprehensiveAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: SDKMessage[] = []; + let taskToolUseId: string | null = null; + const subagentToolCalls: ToolUseBlock[] = []; + const mainAgentToolCalls: ToolUseBlock[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + // Collect all tool use blocks + const toolUseBlocks = message.message.content.filter( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + + for (const toolUse of toolUseBlocks) { + if (toolUse.name === 'task') { + // This is the main agent calling the subagent + taskToolUseId = toolUse.id; + mainAgentToolCalls.push(toolUse); + } + + // If this message has parent_tool_use_id, it's from a subagent + if (message.parent_tool_use_id !== null) { + subagentToolCalls.push(toolUse); + } + } + } + } + + // Criterion 1: When a subagent is called, there must be a 'task' tool being called + expect(taskToolUseId).not.toBeNull(); + expect(mainAgentToolCalls.length).toBeGreaterThan(0); + expect(mainAgentToolCalls.some((tc) => tc.name === 'task')).toBe(true); + + // Criterion 2: A tool call from a subagent is identified by a non-null parent_tool_use_id + // All subagent tool calls should have parent_tool_use_id set to the task tool's id + expect(subagentToolCalls.length).toBeGreaterThan(0); + + // Verify all subagent messages have the correct parent_tool_use_id + const subagentMessages = messages.filter( + (msg): msg is SDKMessage & { parent_tool_use_id: string } => + isSDKAssistantMessage(msg) && msg.parent_tool_use_id !== null, + ); + + expect(subagentMessages.length).toBeGreaterThan(0); + for (const subagentMsg of subagentMessages) { + expect(subagentMsg.parent_tool_use_id).toBe(taskToolUseId); + } + + // Verify no main agent tool calls (except task) have parent_tool_use_id + const mainAgentMessages = messages.filter( + (msg): msg is SDKMessage => + isSDKAssistantMessage(msg) && msg.parent_tool_use_id === null, + ); + + for (const mainMsg of mainAgentMessages) { + if (isSDKAssistantMessage(mainMsg)) { + // Main agent messages should not have parent_tool_use_id + expect(mainMsg.parent_tool_use_id).toBeNull(); + } + } + + // Validate successful completion + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }, 60000); + }); + + describe('Subagent Error Handling', () => { + it('should handle empty subagent array', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should still work with empty agents array + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should handle subagent with minimal configuration', async () => { + const minimalAgent: SubagentConfig = { + name: 'minimal-agent', + description: 'Minimal configuration agent', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [minimalAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate minimal agent is registered + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('minimal-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Integration', () => { + it('should work with other SDK options', async () => { + const testAgent: SubagentConfig = { + name: 'test-agent', + description: 'Test agent for integration', + systemPrompt: 'You are a test assistant.', + level: 'session', + }; + + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [testAgent], + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + permissionMode: 'default', + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate subagent works with debug mode + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('test-agent'); + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should maintain session consistency with subagents', async () => { + const sessionAgent: SubagentConfig = { + name: 'session-agent', + description: 'Agent for session testing', + systemPrompt: 'You are a session test assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [sessionAgent], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate session consistency + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + expect(systemMessage!.agents).toContain('session-agent'); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/system-control.test.ts b/integration-tests/sdk-typescript/system-control.test.ts new file mode 100644 index 00000000..0b0a74d3 --- /dev/null +++ b/integration-tests/sdk-typescript/system-control.test.ts @@ -0,0 +1,317 @@ +/** + * E2E tests for system controller features: + * - setModel API for dynamic model switching + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKAssistantMessage, + isSDKSystemMessage, + type SDKUserMessage, +} from '@qwen-code/sdk'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +/** + * Factory function that creates a streaming input with a control point. + * After the first message is yielded, the generator waits for a resume signal, + * allowing the test code to call query instance methods like setModel. + * + * @param firstMessage - The first user message to send + * @param secondMessage - The second user message to send after control operations + * @returns Object containing the async generator and a resume function + */ +function createStreamingInputWithControlPoint( + firstMessage: string, + secondMessage: string, +): { + generator: AsyncIterable; + resume: () => void; +} { + let resumeResolve: (() => void) | null = null; + const resumePromise = new Promise((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((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((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((resolve) => { + resumeResolve1 = resolve; + }); + const resumePromise2 = new Promise((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((resolve) => resolvers.push(resolve)), + new Promise((resolve) => resolvers.push(resolve)), + new Promise((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', + ); + }); + }); +}); diff --git a/integration-tests/sdk-typescript/test-helper.ts b/integration-tests/sdk-typescript/test-helper.ts new file mode 100644 index 00000000..f3005655 --- /dev/null +++ b/integration-tests/sdk-typescript/test-helper.ts @@ -0,0 +1,970 @@ +/** + * @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; + /** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( + 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 { + 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; + resume: () => void; + resumeAll: () => void; +} { + const sid = sessionId || crypto.randomUUID(); + const resumeResolvers: Array<() => void> = []; + const resumePromises: Array> = []; + + // Create a resume promise for each message after the first + for (let i = 1; i < messageContents.length; i++) { + const promise = new Promise((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, + options: { + timeout?: number; + interval?: number; + errorMessage?: string; + } = {}, +): Promise { + 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 = {}, +): 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 = {}, +) { + 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, + }; +} diff --git a/integration-tests/sdk-typescript/tool-control.test.ts b/integration-tests/sdk-typescript/tool-control.test.ts new file mode 100644 index 00000000..b2b955a6 --- /dev/null +++ b/integration-tests/sdk-typescript/tool-control.test.ts @@ -0,0 +1,744 @@ +/** + * @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, + ); + }); +}); diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/simple-mcp-server.test.ts index d8b6268d..d58bd982 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/simple-mcp-server.test.ts @@ -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'); + const output = await rig.run('add 5 and 10, use tool if you can.'); const foundToolCall = await rig.waitForToolCall('add'); diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index 295741e1..0cd24f82 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -2,7 +2,11 @@ "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, - "allowJs": true + "allowJs": true, + "baseUrl": ".", + "paths": { + "@qwen-code/sdk": ["../packages/sdk-typescript/dist/index.d.ts"] + } }, "include": ["**/*.ts"], "references": [{ "path": "../packages/core" }] diff --git a/integration-tests/vitest.config.ts b/integration-tests/vitest.config.ts index c8b79ad6..9be72f50 100644 --- a/integration-tests/vitest.config.ts +++ b/integration-tests/vitest.config.ts @@ -1,12 +1,15 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { defineConfig } from 'vitest/config'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const timeoutMinutes = Number(process.env.TB_TIMEOUT_MINUTES || '5'); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const timeoutMinutes = Number(process.env['TB_TIMEOUT_MINUTES'] || '5'); const testTimeoutMs = timeoutMinutes * 60 * 1000; export default defineConfig({ @@ -25,4 +28,13 @@ export default defineConfig({ }, }, }, + resolve: { + alias: { + // Use built SDK bundle for e2e tests + '@qwen-code/sdk': resolve( + __dirname, + '../packages/sdk-typescript/dist/index.mjs', + ), + }, + }, }); diff --git a/package-lock.json b/package-lock.json index b2042225..4fba584c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.4.1", "workspaces": [ "packages/*" ], @@ -1285,6 +1285,22 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1299,6 +1315,14 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", @@ -2769,6 +2793,10 @@ "resolved": "packages/test-utils", "link": true }, + "node_modules/@qwen-code/sdk": { + "resolved": "packages/sdk-typescript", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz", @@ -4158,6 +4186,13 @@ "node": ">=20.0.0" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -4726,6 +4761,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -5086,6 +5134,16 @@ "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -6190,6 +6248,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -6747,6 +6812,39 @@ "node": ">=0.3.1" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6860,6 +6958,23 @@ "url": "https://dotenvx.com" } }, + "node_modules/dts-bundle-generator": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz", + "integrity": "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "typescript": ">=5.0.2", + "yargs": "^17.6.0" + }, + "bin": { + "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7706,6 +7821,72 @@ "node": ">=20.0.0" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -8191,6 +8372,13 @@ "node": ">= 10.0.0" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8303,6 +8491,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -8340,6 +8538,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8904,6 +9115,16 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -9036,6 +9257,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -10589,6 +10822,23 @@ "node": ">=4" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10944,6 +11194,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -11072,6 +11329,19 @@ "license": "MIT", "optional": true }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, "node_modules/mnemonist": { "version": "0.40.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", @@ -11642,6 +11912,35 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -12132,6 +12431,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -12266,6 +12575,18 @@ "node": ">=16.20.0" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -13095,6 +13416,45 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", @@ -14090,6 +14450,19 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -14755,6 +15128,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -14926,6 +15309,13 @@ "dev": true, "license": "MIT" }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -16032,7 +16422,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", @@ -16147,7 +16537,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.4.0", + "version": "0.4.1", "hasInstallScript": true, "dependencies": { "@google/genai": "1.16.0", @@ -16285,9 +16675,2438 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "packages/sdk-typescript": { + "name": "@qwen-code/sdk", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitest/coverage-v8": "^1.6.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.12", + "eslint": "^8.57.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "packages/sdk-typescript/node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "packages/sdk-typescript/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "packages/sdk-typescript/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "packages/sdk-typescript/node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/sdk-typescript/node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/sdk-typescript/node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "packages/sdk-typescript/node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "packages/sdk-typescript/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "packages/sdk-typescript/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "packages/sdk-typescript/node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "packages/sdk-typescript/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "packages/sdk-typescript/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/sdk-typescript/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "packages/sdk-typescript/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "packages/sdk-typescript/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "packages/sdk-typescript/node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sdk-typescript/node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sdk-typescript/node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "packages/sdk-typescript/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.4.0", + "version": "0.4.1", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -16299,7 +19118,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.4.0", + "version": "0.4.1", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index a8b6857f..4049e208 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.4.1", "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.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1" }, "scripts": { "start": "cross-env node scripts/start.js", @@ -37,6 +37,10 @@ "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'", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5ad51028..5571db1f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.0", + "version": "0.4.1", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1" }, "dependencies": { "@google/genai": "1.16.0", diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3162638f..3212996d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -6,6 +6,7 @@ import { ApprovalMode, + AuthType, Config, DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, @@ -133,6 +134,10 @@ 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; } function normalizeOutputFormat( @@ -411,6 +416,36 @@ export async function parseArguments(settings: Settings): Promise { 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.', @@ -745,8 +780,14 @@ 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) { + if ( + !interactive && + !argv.experimentalAcp && + inputFormat !== InputFormat.STREAM_JSON + ) { switch (approvalMode) { case ApprovalMode.PLAN: case ApprovalMode.DEFAULT: @@ -770,6 +811,7 @@ export async function loadCliConfig( settings, activeExtensions, extraExcludes.length > 0 ? extraExcludes : undefined, + argv.excludeTools, ); const blockedMcpServers: Array<{ name: string; extensionName: string }> = []; @@ -850,7 +892,7 @@ export async function loadCliConfig( debugMode, question, fullContext: argv.allFiles || false, - coreTools: settings.tools?.core || undefined, + coreTools: argv.coreTools || settings.tools?.core || undefined, allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, @@ -883,13 +925,16 @@ export async function loadCliConfig( model: resolvedModel, extensionContextFilePaths, sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, - maxSessionTurns: settings.model?.maxSessionTurns ?? -1, + maxSessionTurns: + argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], - authType: settings.security?.auth?.selectedType, + authType: + (argv.authType as AuthType | undefined) || + settings.security?.auth?.selectedType, inputFormat, outputFormat, includePartialMessages, @@ -997,8 +1042,10 @@ function mergeExcludeTools( settings: Settings, extensions: Extension[], extraExcludes?: string[] | undefined, + cliExcludeTools?: string[] | undefined, ): string[] { const allExcludeTools = new Set([ + ...(cliExcludeTools || []), ...(settings.tools?.exclude || []), ...(extraExcludes || []), ]); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index a9bc3d9e..f602d17d 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -481,6 +481,10 @@ describe('gemini.tsx main function kitty protocol', () => { includePartialMessages: undefined, continue: undefined, resume: undefined, + coreTools: undefined, + excludeTools: undefined, + authType: undefined, + maxSessionTurns: undefined, }); await main(); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 310ef6b7..18f191bc 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -276,8 +276,11 @@ 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) { + if (!process.stdin.isTTY && inputFormat !== 'stream-json') { stdinData = await readStdin(); } @@ -383,7 +386,18 @@ export async function main() { setMaxSizedBoxDebugging(isDebugMode); - const initializationResult = await initializeApp(config, settings); + // 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); + } if ( settings.merged.security?.auth?.selectedType === @@ -417,19 +431,15 @@ export async function main() { settings, startupWarnings, process.cwd(), - initializationResult, + initializationResult!, ); return; } - 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; + // For non-stream-json mode, initialize config here + if (inputFormat !== InputFormat.STREAM_JSON) { + await config.initialize(); + } // Only read stdin if NOT in stream-json mode // In stream-json mode, stdin is used for protocol messages (control requests, etc.) @@ -442,7 +452,8 @@ export async function main() { } const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, + (argv.authType as AuthType) || + settings.merged.security?.auth?.selectedType, settings.merged.security?.auth?.useExternal, config, settings, diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 3ab57edb..c2217757 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -110,7 +110,6 @@ 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', @@ -690,18 +689,6 @@ 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 // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 474753ae..adeb85f1 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -108,7 +108,6 @@ export default { '在浏览器中打开完整的 Qwen Code 文档', 'Configuration not available.': '配置不可用', 'change the auth method': '更改认证方法', - 'Show quit confirmation dialog': '显示退出确认对话框', 'Copy the last result or code snippet to clipboard': '将最后的结果或代码片段复制到剪贴板', @@ -655,15 +654,6 @@ 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 // ============================================================================ diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index fa1b0e0f..d6dc79a4 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -16,9 +16,12 @@ * Controllers: * - SystemController: initialize, interrupt, set_model, supported_commands * - PermissionController: can_use_tool, set_permission_mode - * - MCPController: mcp_message, mcp_server_status + * - SdkMcpController: mcp_server_status (mcp_message handled via callback) * - 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 */ @@ -26,8 +29,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 { MCPController } from './controllers/mcpController.js'; +import { PermissionController } from './controllers/permissionController.js'; +import { SdkMcpController } from './controllers/sdkMcpController.js'; // import { HookController } from './controllers/hookController.js'; import type { CLIControlRequest, @@ -64,8 +67,8 @@ export class ControlDispatcher implements IPendingRequestRegistry { // Make controllers publicly accessible readonly systemController: SystemController; - // readonly permissionController: PermissionController; - // readonly mcpController: MCPController; + readonly permissionController: PermissionController; + readonly sdkMcpController: SdkMcpController; // readonly hookController: HookController; // Central pending request registries @@ -83,12 +86,16 @@ export class ControlDispatcher implements IPendingRequestRegistry { this, 'SystemController', ); - // this.permissionController = new PermissionController( - // context, - // this, - // 'PermissionController', - // ); - // this.mcpController = new MCPController(context, this, 'MCPController'); + this.permissionController = new PermissionController( + context, + this, + 'PermissionController', + ); + this.sdkMcpController = new SdkMcpController( + context, + this, + 'SdkMcpController', + ); // this.hookController = new HookController(context, this, 'HookController'); // Listen for main abort signal @@ -228,10 +235,10 @@ export class ControlDispatcher implements IPendingRequestRegistry { } this.pendingOutgoingRequests.clear(); - // Cleanup controllers (MCP controller will close all clients) + // Cleanup controllers this.systemController.cleanup(); - // this.permissionController.cleanup(); - // this.mcpController.cleanup(); + this.permissionController.cleanup(); + this.sdkMcpController.cleanup(); // this.hookController.cleanup(); } @@ -291,6 +298,47 @@ 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 { + 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 */ @@ -302,13 +350,12 @@ 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_message': - // case 'mcp_server_status': - // return this.mcpController; + case 'mcp_server_status': + return this.sdkMcpController; // case 'hook_callback': // return this.hookController; diff --git a/packages/cli/src/nonInteractive/control/ControlService.ts b/packages/cli/src/nonInteractive/control/ControlService.ts index 7193fb63..671a1853 100644 --- a/packages/cli/src/nonInteractive/control/ControlService.ts +++ b/packages/cli/src/nonInteractive/control/ControlService.ts @@ -29,7 +29,7 @@ import type { IControlContext } from './ControlContext.js'; import type { ControlDispatcher } from './ControlDispatcher.js'; import type { - // PermissionServiceAPI, + PermissionServiceAPI, SystemServiceAPI, // McpServiceAPI, // HookServiceAPI, @@ -61,43 +61,31 @@ 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 { - // /** - // * 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), - // }; - // } + 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), + }; + } /** * System Domain API diff --git a/packages/cli/src/nonInteractive/control/controllers/baseController.ts b/packages/cli/src/nonInteractive/control/controllers/baseController.ts index d2e20545..dcb9e7c9 100644 --- a/packages/cli/src/nonInteractive/control/controllers/baseController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/baseController.ts @@ -117,16 +117,41 @@ 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 { + // Check if already aborted + if (signal?.aborted) { + throw new Error('Request aborted'); + } + const requestId = randomUUID(); return new Promise((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) { @@ -136,12 +161,27 @@ 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, - resolve, - reject, + wrappedResolve, + wrappedReject, timeoutId, ); @@ -155,6 +195,9 @@ export abstract class BaseController { try { this.context.streamJson.send(request); } catch (error) { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } this.registry.deregisterOutgoingRequest(requestId); reject(error); } @@ -174,7 +217,5 @@ export abstract class BaseController { /** * Cleanup resources */ - cleanup(): void { - // Subclasses can override to add cleanup logic - } + cleanup(): void {} } diff --git a/packages/cli/src/nonInteractive/control/controllers/mcpController.ts b/packages/cli/src/nonInteractive/control/controllers/mcpController.ts deleted file mode 100644 index fccafb67..00000000 --- a/packages/cli/src/nonInteractive/control/controllers/mcpController.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * @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> { - 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> { - 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 = { - 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> { - const status: Record = {}; - - // 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 | 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(); - } -} diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index f93b4489..4cec3b00 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -15,8 +15,10 @@ */ import type { - ToolCallRequestInfo, WaitingToolCall, + ToolExecuteConfirmationDetails, + ToolMcpConfirmationDetails, + ApprovalMode, } from '@qwen-code/qwen-code-core'; import { InputFormat, @@ -42,15 +44,23 @@ export class PermissionController extends BaseController { */ protected async handleRequestPayload( payload: ControlRequestPayload, - _signal: AbortSignal, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + switch (payload.subtype) { case 'can_use_tool': - return this.handleCanUseTool(payload as CLIControlPermissionRequest); + return this.handleCanUseTool( + payload as CLIControlPermissionRequest, + signal, + ); case 'set_permission_mode': return this.handleSetPermissionMode( payload as CLIControlSetPermissionModeRequest, + signal, ); default: @@ -68,7 +78,12 @@ export class PermissionController extends BaseController { */ private async handleCanUseTool( payload: CLIControlPermissionRequest, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + const toolName = payload.tool_name; if ( !toolName || @@ -190,7 +205,12 @@ export class PermissionController extends BaseController { */ private async handleSetPermissionMode( payload: CLIControlSetPermissionModeRequest, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + const mode = payload.mode; const validModes: PermissionMode[] = [ 'default', @@ -206,6 +226,7 @@ export class PermissionController extends BaseController { } this.context.permissionMode = mode; + this.context.config.setApprovalMode(mode as ApprovalMode); if (this.context.debugMode) { console.error( @@ -334,47 +355,6 @@ 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; - }> { - // 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 @@ -411,6 +391,14 @@ export class PermissionController extends BaseController { toolCall: WaitingToolCall, ): Promise { 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; @@ -439,7 +427,8 @@ export class PermissionController extends BaseController { permission_suggestions: permissionSuggestions, blocked_path: null, } as CLIControlPermissionRequest, - 30000, + undefined, // use default timeout + this.context.abortSignal, ); if (response.subtype !== 'success') { @@ -462,8 +451,15 @@ 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) { @@ -473,9 +469,23 @@ export class PermissionController extends BaseController { error, ); } - await toolCall.confirmationDetails.onConfirm( - ToolConfirmationOutcome.Cancel, - ); + // 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, + ); + } } finally { this.pendingOutgoingRequests.delete(toolCall.request.callId); } diff --git a/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts b/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts new file mode 100644 index 00000000..5d0264fb --- /dev/null +++ b/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts @@ -0,0 +1,138 @@ +/** + * @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> { + 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> { + const status: Record = {}; + + 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 { + 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; + 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 { + return (serverName: string, message: JSONRPCMessage) => + this.sendMcpMessageToSdk(serverName, message); + } +} diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index c3fc651b..e214a881 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -18,7 +18,15 @@ 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 { /** @@ -26,20 +34,30 @@ export class SystemController extends BaseController { */ protected async handleRequestPayload( payload: ControlRequestPayload, - _signal: AbortSignal, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + switch (payload.subtype) { case 'initialize': - return this.handleInitialize(payload as CLIControlInitializeRequest); + return this.handleInitialize( + payload as CLIControlInitializeRequest, + signal, + ); case 'interrupt': return this.handleInterrupt(); case 'set_model': - return this.handleSetModel(payload as CLIControlSetModelRequest); + return this.handleSetModel( + payload as CLIControlSetModelRequest, + signal, + ); case 'supported_commands': - return this.handleSupportedCommands(); + return this.handleSupportedCommands(signal); default: throw new Error(`Unsupported request subtype in SystemController`); @@ -49,15 +67,130 @@ export class SystemController extends BaseController { /** * Handle initialize request * - * Registers SDK MCP servers and returns capabilities + * 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. */ private async handleInitialize( payload: CLIControlInitializeRequest, + signal: AbortSignal, ): Promise> { - // Register SDK MCP servers if provided - if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) { - for (const serverName of payload.sdkMcpServers) { - this.context.sdkMcpServers.add(serverName); + 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 = {}; + 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 = {}; + 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, + ); + } } } @@ -86,36 +219,98 @@ export class SystemController extends BaseController { buildControlCapabilities(): Record { const capabilities: Record = { can_handle_can_use_tool: true, - can_handle_hook_callback: true, + can_handle_hook_callback: false, 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, }; - // Check if MCP message handling is available - try { - const mcpProvider = this.context.config as unknown as { - getMcpServers?: () => Record | 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) { + return capabilities; + } + + private normalizeMcpServerConfig( + serverName: string, + config?: CLIMcpServerConfig, + ): MCPServerConfig | null { + if (!config || typeof config !== 'object') { if (this.context.debugMode) { console.error( - '[SystemController] Failed to determine MCP capability:', - error, + `[SystemController] Ignoring invalid MCP server config for '${serverName}'`, ); } - capabilities['can_handle_mcp_message'] = false; + return null; } - return capabilities; + 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, + }; } /** @@ -151,7 +346,12 @@ export class SystemController extends BaseController { */ private async handleSetModel( payload: CLIControlSetModelRequest, + signal: AbortSignal, ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + const model = payload.model; // Validate model parameter @@ -189,27 +389,63 @@ export class SystemController extends BaseController { /** * Handle supported_commands request * - * Returns list of supported control commands - * - * Note: This list should match the ControlRequestType enum in - * packages/sdk/typescript/src/types/controlRequests.ts + * Returns list of supported slash commands loaded dynamically */ - private async handleSupportedCommands(): Promise> { - const commands = [ - 'initialize', - 'interrupt', - 'set_model', - 'supported_commands', - 'can_use_tool', - 'set_permission_mode', - 'mcp_message', - 'mcp_server_status', - 'hook_callback', - ]; + private async handleSupportedCommands( + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + throw new Error('Request aborted'); + } + + const slashCommands = await this.loadSlashCommandNames(signal); return { subtype: 'supported_commands', - commands, + commands: slashCommands, }; } + + /** + * 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 { + if (signal.aborted) { + return []; + } + + try { + const service = await CommandService.create( + [new BuiltinCommandLoader(this.context.config)], + signal, + ); + + if (signal.aborted) { + return []; + } + + const names = new Set(); + 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 []; + } + } } diff --git a/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts index c83637b7..9137d95a 100644 --- a/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts +++ b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts @@ -13,10 +13,7 @@ */ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { - ToolCallRequestInfo, - MCPServerConfig, -} from '@qwen-code/qwen-code-core'; +import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; import type { PermissionSuggestion } from '../../types.js'; /** @@ -26,25 +23,6 @@ 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; - }>; - /** * Build UI suggestions for tool confirmation dialogs * diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 3968c5cc..915fb721 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -13,7 +13,11 @@ import type { ServerGeminiStreamEvent, TaskResultDisplay, } from '@qwen-code/qwen-code-core'; -import { GeminiEventType, ToolErrorType } from '@qwen-code/qwen-code-core'; +import { + GeminiEventType, + ToolErrorType, + parseAndFormatApiError, +} from '@qwen-code/qwen-code-core'; import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; import type { CLIAssistantMessage, @@ -600,6 +604,18 @@ 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; } @@ -939,9 +955,25 @@ 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) @@ -951,6 +983,14 @@ 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 && @@ -967,7 +1007,7 @@ export abstract class BaseJsonOutputAdapter { const block: ToolResultBlock = { type: 'tool_result', tool_use_id: request.callId, - is_error: Boolean(response.error), + is_error: hasError, }; const content = toolResultContent(response); if (content !== undefined) { @@ -1173,11 +1213,41 @@ 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 @@ -1188,6 +1258,11 @@ 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 diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 61643fb3..84d7dece 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -69,6 +69,7 @@ function createConfig(overrides: ConfigOverrides = {}): Config { getDebugMode: () => false, getApprovalMode: () => 'auto', getOutputFormat: () => 'stream-json', + initialize: vi.fn(), }; return { ...base, ...overrides } as unknown as Config; } @@ -152,6 +153,11 @@ describe('runNonInteractiveStreamJson', () => { handleControlResponse: ReturnType; handleCancel: ReturnType; shutdown: ReturnType; + getPendingIncomingRequestCount: ReturnType; + waitForPendingIncomingRequests: ReturnType; + sdkMcpController: { + createSendSdkMcpMessage: ReturnType; + }; }; let mockConsolePatcher: { patch: ReturnType; @@ -186,6 +192,11 @@ 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 diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index 614208b7..e8e6da12 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -4,18 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** - * 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 type { + Config, + ConfigInitializeOptions, +} from '@qwen-code/qwen-code-core'; import { StreamJsonInputReader } from './io/StreamJsonInputReader.js'; import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js'; import { ControlContext } from './control/ControlContext.js'; @@ -42,48 +34,7 @@ import { createMinimalSettings } from '../config/settings.js'; import { runNonInteractive } from '../nonInteractiveCli.js'; import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; -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; +class Session { private userMessageQueue: CLIUserMessage[] = []; private abortController: AbortController; private config: Config; @@ -98,6 +49,15 @@ class SessionManager { private debugMode: boolean; private shutdownHandler: (() => void) | null = null; private initialPrompt: CLIUserMessage | null = null; + private processingPromise: Promise | 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 | null = null; + private initializationResolve: (() => void) | null = null; + private initializationReject: ((error: Error) => void) | null = null; constructor(config: Config, initialPrompt?: CLIUserMessage) { this.config = config; @@ -112,161 +72,96 @@ class SessionManager { config.getIncludePartialMessages(), ); - // Setup signal handlers for graceful shutdown this.setupSignalHandlers(); } - /** - * Get next prompt ID - */ + private ensureInitializationPromise(): void { + if (this.initializationPromise) { + return; + } + this.initializationPromise = new Promise((resolve, reject) => { + this.initializationResolve = () => { + resolve(); + this.initializationResolve = null; + this.initializationReject = null; + }; + this.initializationReject = (error: Error) => { + reject(error); + this.initializationResolve = null; + this.initializationReject = null; + }; + }); + } + private getNextPromptId(): string { this.promptIdCounter++; return `${this.sessionId}########${this.promptIdCounter}`; } - /** - * 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 }; + private async ensureConfigInitialized( + options?: ConfigInitializeOptions, + ): Promise { + if (this.configInitialized) { + return; } - // 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( - '[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 { - // Check for abort - if (this.abortController.signal.aborted) { - return true; + console.error('[Session] Initializing config'); } - // 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 { try { - 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(); + await this.config.initialize(options); + this.configInitialized = true; } catch (error) { if (this.debugMode) { - console.error('[SessionManager] Error:', error); + console.error('[Session] Failed to initialize config:', 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 { + 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, @@ -292,274 +187,166 @@ class SessionManager { return this.dispatcher; } - private async handleFirstMessage( + /** + * 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( message: | CLIMessage | CLIControlRequest | CLIControlResponse | ControlCancelRequest, - ): Promise { - const routed = this.route(message); - - if (routed.type === 'control_request') { - const request = routed.message as CLIControlRequest; + ): void { + if (isControlRequest(message)) { + const request = message as CLIControlRequest; this.controlSystemEnabled = true; this.ensureControlSystem(); + if (request.request.subtype === 'initialize') { - await this.dispatcher?.dispatch(request); - this.state = SESSION_STATE.IDLE; - return true; - } - return false; - } - - if (routed.type === 'user') { - this.controlSystemEnabled = false; - this.state = SESSION_STATE.PROCESSING_QUERY; - this.userMessageQueue.push(routed.message as CLIUserMessage); - await this.processUserMessageQueue(); - return true; - } - - this.controlSystemEnabled = false; - return false; - } - - /** - * Process a single message from the stream - */ - private async processMessage( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, - ): Promise { - const routed = this.route(message); - - 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 messages in initializing state - */ - private async handleInitializingState(routed: RoutedMessage): Promise { - 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', - ); - } + // Start SDK mode initialization (fire-and-forget from loop perspective) + void this.initializeSdkMode(request); 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', + '[Session] Ignoring non-initialize control request during initialization', ); } - } - } - - /** - * Handle messages in idle state - */ - private async handleIdleState(routed: RoutedMessage): Promise { - const dispatcher = this.getDispatcher(); - 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 { - 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 { - 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; } - // 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 (isCLIUserMessage(message)) { + this.controlSystemEnabled = false; + // Start direct mode initialization (fire-and-forget from loop perspective) + void this.initializeDirectMode(message as CLIUserMessage); + return; + } + + this.controlSystemEnabled = false; + } + + /** + * SDK mode initialization flow + * Dispatches initialize request and initializes config with MCP support + */ + private async initializeSdkMode(request: CLIControlRequest): Promise { + 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('[SessionManager] Queue processed, returning to idle'); + console.error('[Session] SDK mode initialization failed:', error); } + this.failInitialization( + error instanceof Error ? error : new Error(String(error)), + ); } } /** - * Process a single user message + * Direct mode initialization flow + * Initializes config and enqueues the first user message */ + private async initializeDirectMode( + userMessage: CLIUserMessage, + ): Promise { + this.ensureInitializationPromise(); + try { + // Initialize config + await this.ensureConfigInitialized(); + + // 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)), + ); + } + } + + /** + * Handle control request asynchronously (fire-and-forget from main loop). + * Errors are handled internally and responses sent by dispatcher. + */ + private handleControlRequestAsync(request: CLIControlRequest): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + if (this.debugMode) { + console.error('[Session] Control system not enabled'); + } + 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. + */ + private handleControlResponse(response: CLIControlResponse): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + dispatcher.handleControlResponse(response); + } + + private handleControlCancel(cancelRequest: ControlCancelRequest): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + dispatcher.handleCancel(cancelRequest.request_id); + } + private async processUserMessage(userMessage: CLIUserMessage): Promise { const input = extractUserMessageText(userMessage); if (!input) { if (this.debugMode) { - console.error('[SessionManager] No text content in user message'); + console.error('[Session] No text content in user message'); } return; } + // Wait for initialization to complete before processing user messages + await this.waitForInitialization(); + const promptId = this.getNextPromptId(); try { @@ -575,16 +362,56 @@ class SessionManager { }, ); } catch (error) { - // Error already handled by runNonInteractive via adapter.emitResult if (this.debugMode) { - console.error('[SessionManager] Query execution error:', error); + console.error('[Session] Query execution error:', error); } } } - /** - * Send tool results as user message - */ + private async processUserMessageQueue(): Promise { + 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(); + } + }); + } + private emitErrorResult( error: unknown, numTurns: number = 0, @@ -602,30 +429,21 @@ class SessionManager { }); } - /** - * Handle interrupt control request - */ private handleInterrupt(): void { if (this.debugMode) { - 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 + console.error('[Session] Interrupt requested'); } + this.abortController.abort(); + this.abortController = new AbortController(); } - /** - * Setup signal handlers for graceful shutdown - */ private setupSignalHandlers(): void { this.shutdownHandler = () => { if (this.debugMode) { - console.error('[SessionManager] Shutdown signal received'); + console.error('[Session] Shutdown signal received'); } + this.isShuttingDown = true; this.abortController.abort(); - this.state = SESSION_STATE.SHUTTING_DOWN; }; process.on('SIGINT', this.shutdownHandler); @@ -633,21 +451,58 @@ class SessionManager { } /** - * Shutdown session and cleanup resources + * Wait for all pending work to complete before shutdown */ - private async shutdown(): Promise { - if (this.debugMode) { - console.error('[SessionManager] Shutting down'); + private async waitForAllPendingWork(): Promise { + // 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); + } } - this.state = SESSION_STATE.SHUTTING_DOWN; + // 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 { + if (this.debugMode) { + console.error('[Session] Shutting down'); + } + + this.isShuttingDown = true; + + // Wait for all pending work + await this.waitForAllPendingWork(); + this.dispatcher?.shutdown(); this.cleanupSignalHandlers(); } - /** - * Remove signal handlers to prevent memory leaks - */ private cleanupSignalHandlers(): void { if (this.shutdownHandler) { process.removeListener('SIGINT', this.shutdownHandler); @@ -655,6 +510,105 @@ class SessionManager { 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 { + 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 { @@ -682,12 +636,6 @@ 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, @@ -698,7 +646,6 @@ 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(); @@ -713,7 +660,7 @@ export async function runNonInteractiveStreamJson( }; } - const manager = new SessionManager(config, initialPrompt); + const manager = new Session(config, initialPrompt); await manager.run(); } finally { consolePatcher.cleanup(); diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 784ea916..1d5e800d 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { SubagentConfig } from '@qwen-code/qwen-code-core'; /** * Annotation for attaching metadata to content blocks @@ -137,9 +138,8 @@ export interface CLISystemMessage { status: string; }>; model?: string; - permissionMode?: string; + permission_mode?: string; slash_commands?: string[]; - apiKeySource?: string; qwen_code_version?: string; output_style?: string; agents?: string[]; @@ -295,10 +295,69 @@ 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; + cwd?: string; + url?: string; + httpUrl?: string; + headers?: Record; + 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; - sdkMcpServers?: string[]; + /** + * 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>; + /** + * 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; + agents?: SubagentConfig[]; } export interface CLIControlSetPermissionModeRequest { diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 5cc53fc6..30bc6a62 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -245,6 +245,7 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', + { isContinuation: false }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Hello'); expect(processStdoutSpy).toHaveBeenCalledWith(' World'); @@ -293,11 +294,21 @@ 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'); @@ -372,6 +383,7 @@ describe('runNonInteractive', () => { ], expect.any(AbortSignal), 'prompt-id-3', + { isContinuation: true }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.'); }); @@ -497,6 +509,7 @@ describe('runNonInteractive', () => { processedParts, expect.any(AbortSignal), 'prompt-id-7', + { isContinuation: false }, ); // 6. Assert the final output is correct @@ -528,6 +541,7 @@ 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 @@ -680,6 +694,7 @@ 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 @@ -831,6 +846,7 @@ describe('runNonInteractive', () => { [{ text: 'Prompt from command' }], expect.any(AbortSignal), 'prompt-id-slash', + { isContinuation: false }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Response from command'); @@ -887,6 +903,7 @@ describe('runNonInteractive', () => { [{ text: '/unknowncommand' }], expect.any(AbortSignal), 'prompt-id-unknown', + { isContinuation: false }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown'); @@ -1217,6 +1234,7 @@ describe('runNonInteractive', () => { [{ text: 'Message from stream-json input' }], expect.any(AbortSignal), 'prompt-envelope', + { isContinuation: false }, ); }); @@ -1692,6 +1710,7 @@ describe('runNonInteractive', () => { [{ text: 'Simple string content' }], expect.any(AbortSignal), 'prompt-string-content', + { isContinuation: false }, ); // UserMessage with array of text blocks @@ -1724,6 +1743,7 @@ describe('runNonInteractive', () => { [{ text: 'First part' }, { text: 'Second part' }], expect.any(AbortSignal), 'prompt-blocks-content', + { isContinuation: false }, ); }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8e5a9c90..1614c304 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -15,7 +15,9 @@ 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'; @@ -170,6 +172,7 @@ export async function runNonInteractive( adapter.emitMessage(systemMessage); } + let isFirstTurn = true; while (true) { turnCount++; if ( @@ -185,7 +188,9 @@ export async function runNonInteractive( currentMessages[0]?.parts || [], abortController.signal, prompt_id, + { isContinuation: !isFirstTurn }, ); + isFirstTurn = false; // Start assistant message for this turn if (adapter) { @@ -205,10 +210,21 @@ export async function runNonInteractive( } } else { // Text output mode - direct stdout - if (event.type === GeminiEventType.Content) { + if (event.type === GeminiEventType.Thought) { + process.stdout.write(event.value.description); + } else 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`); } } } @@ -225,40 +241,14 @@ export async function runNonInteractive( for (const requestInfo of toolCallRequests) { const finalRequestInfo = requestInfo; - /* - 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; - */ + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + const toolCallUpdateCallback = + inputFormat === InputFormat.STREAM_JSON && options.controlService + ? options.controlService.permission.getToolCallUpdateCallback() + : undefined; // Only pass outputUpdateHandler for Task tool const isTaskTool = finalRequestInfo.name === 'task'; @@ -277,13 +267,13 @@ export async function runNonInteractive( isTaskTool && taskToolProgressHandler ? { outputUpdateHandler: taskToolProgressHandler, - /* - toolCallUpdateCallback - ? { onToolCallsUpdate: toolCallUpdateCallback } - : undefined, - */ + onToolCallsUpdate: toolCallUpdateCallback, } - : undefined, + : toolCallUpdateCallback + ? { + onToolCallsUpdate: toolCallUpdateCallback, + } + : undefined, ); // Note: In JSON mode, subagent messages are automatically added to the main @@ -303,9 +293,6 @@ 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) { diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 67f8ee72..9d649b2f 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -71,7 +71,6 @@ 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: {} })); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 8be63a8e..100fbef9 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, quitConfirmCommand } from '../ui/commands/quitCommand.js'; +import { quitCommand } 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,7 +77,6 @@ export class BuiltinCommandLoader implements ICommandLoader { modelCommand, ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), quitCommand, - quitConfirmCommand, restoreCommand(this.config), statsCommand, summaryCommand, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ebcc14f6..edda3d4d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -89,7 +89,6 @@ 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'; @@ -446,8 +445,6 @@ export const AppContainer = (props: AppContainerProps) => { const { toggleVimEnabled } = useVimMode(); - const { showQuitConfirmation } = useQuitConfirmation(); - const { isSubagentCreateDialogOpen, openSubagentCreateDialog, @@ -493,7 +490,6 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, - _showQuitConfirmation: showQuitConfirmation, }), [ openAuthDialog, @@ -507,7 +503,6 @@ export const AppContainer = (props: AppContainerProps) => { openPermissionsDialog, openApprovalModeDialog, addConfirmUpdateExtensionRequest, - showQuitConfirmation, openSubagentCreateDialog, openAgentsManagerDialog, ], @@ -520,7 +515,6 @@ export const AppContainer = (props: AppContainerProps) => { commandContext, shellConfirmationRequest, confirmationRequest, - quitConfirmationRequest, } = useSlashCommandProcessor( config, settings, @@ -969,7 +963,6 @@ export const AppContainer = (props: AppContainerProps) => { isFolderTrustDialogOpen, showWelcomeBackDialog, handleWelcomeBackClose, - quitConfirmationRequest, }); const handleExit = useCallback( @@ -983,25 +976,18 @@ export const AppContainer = (props: AppContainerProps) => { if (timerRef.current) { clearTimeout(timerRef.current); } - // Exit directly without showing confirmation dialog + // Exit directly 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 - * and without quit-confirm dialog. + * So a quit on AuthDialog should go with normal two press quit. */ if (isAuthDialogOpen) { setPressedOnce(true); @@ -1022,14 +1008,17 @@ export const AppContainer = (props: AppContainerProps) => { return; // Request cancelled, end processing } - // 3. Clear input buffer (if has content) + // 4. Clear input buffer (if has content) if (buffer.text.length > 0) { buffer.setText(''); return; // Input cleared, end processing } - // All cleanup tasks completed, show quit confirmation dialog - handleSlashCommand('/quit-confirm'); + // All cleanup tasks completed, set flag for double-press to quit + setPressedOnce(true); + timerRef.current = setTimeout(() => { + setPressedOnce(false); + }, CTRL_EXIT_PROMPT_DURATION_MS); }, [ isAuthDialogOpen, @@ -1037,7 +1026,6 @@ export const AppContainer = (props: AppContainerProps) => { closeAnyOpenDialog, streamingState, cancelOngoingRequest, - quitConfirmationRequest, buffer, ], ); @@ -1054,8 +1042,8 @@ export const AppContainer = (props: AppContainerProps) => { return; } - // 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 + // On first press: set flag, start timer, and call handleExit for cleanup + // On second press (within timeout): handleExit sees flag and does fast quit if (!ctrlCPressedOnce) { setCtrlCPressedOnce(true); ctrlCTimerRef.current = setTimeout(() => { @@ -1196,7 +1184,6 @@ export const AppContainer = (props: AppContainerProps) => { !!confirmationRequest || confirmUpdateExtensionRequests.length > 0 || !!loopDetectionConfirmationRequest || - !!quitConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || @@ -1245,7 +1232,6 @@ export const AppContainer = (props: AppContainerProps) => { confirmationRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, - quitConfirmationRequest, geminiMdFileCount, streamingState, initError, @@ -1337,7 +1323,6 @@ export const AppContainer = (props: AppContainerProps) => { confirmationRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, - quitConfirmationRequest, geminiMdFileCount, streamingState, initError, diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index fc9683c9..4e9da3a0 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -8,35 +8,6 @@ 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'], diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 69d6e9d4..a2a352cb 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -100,12 +100,6 @@ 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. @@ -182,7 +176,6 @@ export type SlashCommandActionReturn = | ToolActionReturn | MessageActionReturn | QuitActionReturn - | QuitConfirmationActionReturn | OpenDialogActionReturn | LoadHistoryActionReturn | SubmitPromptActionReturn diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 2f6f8636..d696c87a 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -36,10 +36,6 @@ 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']; @@ -127,24 +123,6 @@ export const DialogManager = ({ /> ); } - if (uiState.quitConfirmationRequest) { - return ( - { - 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 ( = ({ terminalWidth={terminalWidth} /> )} + {itemForDisplay.type === 'gemini_thought' && ( + + )} + {itemForDisplay.type === 'gemini_thought_content' && ( + + )} {itemForDisplay.type === 'info' && ( )} @@ -108,9 +130,6 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'quit' && ( )} - {itemForDisplay.type === 'quit_confirmation' && ( - - )} {itemForDisplay.type === 'tool_group' && ( void; -} - -export const QuitConfirmationDialog: React.FC = ({ - onSelect, -}) => { - useKeypress( - (key) => { - if (key.name === 'escape') { - onSelect(QuitChoice.CANCEL); - } - }, - { isActive: true }, - ); - - const options: Array> = [ - { - 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 ( - - - {t('What would you like to do before exiting?')} - - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx new file mode 100644 index 00000000..22571852 --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx @@ -0,0 +1,48 @@ +/** + * @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 = ({ + text, + isPending, + availableTerminalHeight, + terminalWidth, +}) => { + const prefix = '✦ '; + const prefixWidth = prefix.length; + + return ( + + + {prefix} + + + + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx new file mode 100644 index 00000000..f68dd3b5 --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx @@ -0,0 +1,40 @@ +/** + * @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 ( + + + + ); +}; diff --git a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx index ab1cd2a9..ccec2ebf 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx @@ -69,7 +69,10 @@ export function EditOptionsStep({ if (selectedValue === 'editor') { // Launch editor directly try { - await launchEditor(selectedAgent?.filePath); + if (!selectedAgent.filePath) { + throw new Error('Agent has no file path'); + } + await launchEditor(selectedAgent.filePath); } catch (err) { setError( t('Failed to launch editor: {{error}}', { diff --git a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx index 613ac87e..add3dcb5 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx @@ -218,7 +218,7 @@ export const AgentSelectionStep = ({ const renderAgentItem = ( agent: { name: string; - level: 'project' | 'user' | 'builtin'; + level: 'project' | 'user' | 'builtin' | 'session'; isBuiltin?: boolean; }, index: number, @@ -267,7 +267,7 @@ export const AgentSelectionStep = ({ {t('Project Level ({{path}})', { - path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: projectAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} @@ -289,7 +289,7 @@ export const AgentSelectionStep = ({ > {t('User Level ({{path}})', { - path: userAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: userAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 21ff5389..ac2f5f10 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -12,7 +12,6 @@ import type { ShellConfirmationRequest, ConfirmationRequest, LoopDetectionConfirmationRequest, - QuitConfirmationRequest, HistoryItemWithoutId, StreamingState, } from '../types.js'; @@ -69,7 +68,6 @@ export interface UIState { confirmationRequest: ConfirmationRequest | null; confirmUpdateExtensionRequests: ConfirmationRequest[]; loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null; - quitConfirmationRequest: QuitConfirmationRequest | null; geminiMdFileCount: number; streamingState: StreamingState; initError: string | null; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index dc8fcea7..55fec0c3 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -918,7 +918,6 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // toggleVimEnabled vi.fn(), // setIsProcessing vi.fn(), // setGeminiMdFileCount - vi.fn(), // _showQuitConfirmation ), ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 758eb972..553accb7 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -18,7 +18,6 @@ import { IdeClient, } from '@qwen-code/qwen-code-core'; import { useSessionStats } from '../contexts/SessionContext.js'; -import { formatDuration } from '../utils/formatters.js'; import type { Message, HistoryItemWithoutId, @@ -53,7 +52,6 @@ function serializeHistoryItemForRecording( const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'quit', - 'quit-confirm', 'exit', 'clear', 'reset', @@ -75,7 +73,6 @@ interface SlashCommandProcessorActions { addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; openAgentsManagerDialog: () => void; - _showQuitConfirmation: () => void; } /** @@ -115,10 +112,6 @@ export const useSlashCommandProcessor = ( prompt: React.ReactNode; onConfirm: (confirmed: boolean) => void; }>(null); - const [quitConfirmationRequest, setQuitConfirmationRequest] = - useState void; - }>(null); const [sessionShellAllowlist, setSessionShellAllowlist] = useState( new Set(), @@ -174,11 +167,6 @@ 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', @@ -449,66 +437,6 @@ 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); @@ -692,7 +620,6 @@ export const useSlashCommandProcessor = ( setSessionShellAllowlist, setIsProcessing, setConfirmationRequest, - sessionStats, ], ); @@ -703,6 +630,5 @@ export const useSlashCommandProcessor = ( commandContext, shellConfirmationRequest, confirmationRequest, - quitConfirmationRequest, }; }; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 70a06abc..298f4496 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -44,11 +44,6 @@ export interface DialogCloseOptions { // Welcome back dialog showWelcomeBackDialog: boolean; handleWelcomeBackClose: () => void; - - // Quit confirmation dialog - quitConfirmationRequest: { - onConfirm: (shouldQuit: boolean, action?: string) => void; - } | null; } /** @@ -96,9 +91,6 @@ 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]); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 5994cc60..f82caa80 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2261,6 +2261,57 @@ 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([ [], diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 8e7cbc0d..b4df01b0 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -497,6 +497,61 @@ 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) { @@ -710,11 +765,16 @@ export const useGeminiStream = ( signal: AbortSignal, ): Promise => { let geminiMessageBuffer = ''; + let thoughtBuffer = ''; const toolCallRequests: ToolCallRequestInfo[] = []; for await (const event of stream) { switch (event.type) { case ServerGeminiEventType.Thought: - setThought(event.value); + thoughtBuffer = handleThoughtEvent( + event.value, + thoughtBuffer, + userMessageTimestamp, + ); break; case ServerGeminiEventType.Content: geminiMessageBuffer = handleContentEvent( @@ -776,6 +836,7 @@ export const useGeminiStream = ( }, [ handleContentEvent, + handleThoughtEvent, handleUserCancelledEvent, handleErrorEvent, scheduleToolCalls, diff --git a/packages/cli/src/ui/hooks/useQuitConfirmation.ts b/packages/cli/src/ui/hooks/useQuitConfirmation.ts deleted file mode 100644 index fff0d488..00000000 --- a/packages/cli/src/ui/hooks/useQuitConfirmation.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @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, - }; -}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index bc9a6317..96ed4c50 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -103,6 +103,16 @@ 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; @@ -161,11 +171,6 @@ export type HistoryItemQuit = HistoryItemBase & { duration: string; }; -export type HistoryItemQuitConfirmation = HistoryItemBase & { - type: 'quit_confirmation'; - duration: string; -}; - export type HistoryItemToolGroup = HistoryItemBase & { type: 'tool_group'; tools: IndividualToolCallDisplay[]; @@ -246,6 +251,8 @@ export type HistoryItemWithoutId = | HistoryItemUserShell | HistoryItemGemini | HistoryItemGeminiContent + | HistoryItemGeminiThought + | HistoryItemGeminiThoughtContent | HistoryItemInfo | HistoryItemError | HistoryItemWarning @@ -256,7 +263,6 @@ export type HistoryItemWithoutId = | HistoryItemModelStats | HistoryItemToolStats | HistoryItemQuit - | HistoryItemQuitConfirmation | HistoryItemCompression | HistoryItemSummary | HistoryItemCompression @@ -278,7 +284,6 @@ export enum MessageType { MODEL_STATS = 'model_stats', TOOL_STATS = 'tool_stats', QUIT = 'quit', - QUIT_CONFIRMATION = 'quit_confirmation', GEMINI = 'gemini', COMPRESSION = 'compression', SUMMARY = 'summary', @@ -342,12 +347,6 @@ export type Message = duration: string; content?: string; } - | { - type: MessageType.QUIT_CONFIRMATION; - timestamp: Date; - duration: string; - content?: string; - } | { type: MessageType.COMPRESSION; compression: CompressionProps; @@ -404,7 +403,3 @@ export interface ConfirmationRequest { export interface LoopDetectionConfirmationRequest { onComplete: (result: { userSelection: 'disable' | 'keep' }) => void; } - -export interface QuitConfirmationRequest { - onConfirm: (shouldQuit: boolean, action?: string) => void; -} diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 4320c519..48efc6e8 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -19,12 +19,16 @@ const UNDERLINE_TAG_END_LENGTH = 4; // For "" interface RenderInlineProps { text: string; + textColor?: string; } -const RenderInlineInternal: React.FC = ({ text }) => { +const RenderInlineInternal: React.FC = ({ + text, + textColor = theme.text.primary, +}) => { // Early return for plain text without markdown or URLs if (!/[*_~`<[https?:]/.test(text)) { - return {text}; + return {text}; } const nodes: React.ReactNode[] = []; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index da6bf21a..b5e7dd5d 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -17,6 +17,7 @@ interface MarkdownDisplayProps { isPending: boolean; availableTerminalHeight?: number; terminalWidth: number; + textColor?: string; } // Constants for Markdown parsing and rendering @@ -31,6 +32,7 @@ const MarkdownDisplayInternal: React.FC = ({ isPending, availableTerminalHeight, terminalWidth, + textColor = theme.text.primary, }) => { if (!text) return <>; @@ -116,7 +118,7 @@ const MarkdownDisplayInternal: React.FC = ({ addContentBlock( - + , ); @@ -155,7 +157,7 @@ const MarkdownDisplayInternal: React.FC = ({ addContentBlock( - + , ); @@ -173,36 +175,36 @@ const MarkdownDisplayInternal: React.FC = ({ switch (level) { case 1: headerNode = ( - - + + ); break; case 2: headerNode = ( - - + + ); break; case 3: headerNode = ( - - + + ); break; case 4: headerNode = ( - - + + ); break; default: headerNode = ( - - + + ); break; @@ -219,6 +221,7 @@ const MarkdownDisplayInternal: React.FC = ({ type="ul" marker={marker} leadingWhitespace={leadingWhitespace} + textColor={textColor} />, ); } else if (olMatch) { @@ -232,6 +235,7 @@ const MarkdownDisplayInternal: React.FC = ({ type="ol" marker={marker} leadingWhitespace={leadingWhitespace} + textColor={textColor} />, ); } else { @@ -245,8 +249,8 @@ const MarkdownDisplayInternal: React.FC = ({ } else { addContentBlock( - - + + , ); @@ -367,6 +371,7 @@ interface RenderListItemProps { type: 'ul' | 'ol'; marker: string; leadingWhitespace?: string; + textColor?: string; } const RenderListItemInternal: React.FC = ({ @@ -374,6 +379,7 @@ const RenderListItemInternal: React.FC = ({ type, marker, leadingWhitespace = '', + textColor = theme.text.primary, }) => { const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; const prefixWidth = prefix.length; @@ -385,11 +391,11 @@ const RenderListItemInternal: React.FC = ({ flexDirection="row" > - {prefix} + {prefix} - - + + diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index f0c94fab..29d60272 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -102,7 +102,7 @@ describe('resumeHistoryUtils', () => { ]); }); - it('marks tool results as error, skips thought text, and falls back when tool is missing', () => { + it('marks tool results as error, captures thought text, and falls back when tool is missing', () => { const conversation = { messages: [ { @@ -142,6 +142,11 @@ 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), diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 85ae0572..3c69bfd4 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -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. + * Extracts text content from a Content object's parts (excluding thought parts). */ function extractTextFromParts(parts: Part[] | undefined): string { if (!parts) return ''; @@ -34,6 +34,22 @@ 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. */ @@ -187,12 +203,28 @@ 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 diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index 11f302b4..a6dac920 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -529,7 +529,7 @@ describe('buildSystemMessage', () => { { name: 'mcp-server-2', status: 'connected' }, ], model: 'test-model', - permissionMode: 'auto', + permission_mode: 'auto', slash_commands: ['commit', 'help', 'memory'], qwen_code_version: '1.0.0', agents: [], diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts index fe8fc528..1fd7472b 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -275,7 +275,7 @@ export async function buildSystemMessage( tools, mcp_servers: mcpServerList, model: config.getModel(), - permissionMode, + permission_mode: permissionMode, slash_commands: slashCommands, qwen_code_version: config.getCliVersion() || 'unknown', agents: agentNames, diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 78ccc993..1590c074 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -41,7 +41,7 @@ export async function validateNonInteractiveAuth( } const effectiveAuthType = - enforcedType || getAuthTypeFromEnv() || configuredAuthType; + enforcedType || configuredAuthType || getAuthTypeFromEnv(); 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`; diff --git a/packages/core/package.json b/packages/core/package.json index 1a07067b..0cd64ea8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.4.0", + "version": "0.4.1", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1c83432d..6aa49306 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -63,6 +63,7 @@ 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 }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 59baba85..6383cb17 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -46,6 +46,7 @@ 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'; @@ -65,6 +66,7 @@ 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, @@ -238,9 +240,18 @@ export class MCPServerConfig { readonly targetAudience?: string, /* targetServiceAccount format: @.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', @@ -333,9 +344,11 @@ export interface ConfigParameters { eventEmitter?: EventEmitter; useSmartEdit?: boolean; output?: OutputSettings; - skipStartupContext?: boolean; inputFormat?: InputFormat; outputFormat?: OutputFormat; + skipStartupContext?: boolean; + sdkMode?: boolean; + sessionSubagents?: SubagentConfig[]; } function normalizeConfigOutputFormat( @@ -357,6 +370,17 @@ 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; @@ -383,8 +407,10 @@ export class Config { private readonly toolDiscoveryCommand: string | undefined; private readonly toolCallCommand: string | undefined; private readonly mcpServerCommand: string | undefined; - private readonly mcpServers: Record | undefined; + private mcpServers: Record | undefined; + private sessionSubagents: SubagentConfig[]; private userMemory: string; + private sdkMode: boolean; private geminiMdFileCount: number; private approvalMode: ApprovalMode; private readonly showMemoryUsage: boolean; @@ -487,6 +513,8 @@ 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; @@ -592,8 +620,9 @@ export class Config { /** * Must only be called once, throws if called again. + * @param options Optional initialization options including sendSdkMcpMessage callback */ - async initialize(): Promise { + async initialize(options?: ConfigInitializeOptions): Promise { if (this.initialized) { throw Error('Config was already initialized'); } @@ -606,7 +635,15 @@ export class Config { } this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); - this.toolRegistry = await this.createToolRegistry(); + + // 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, + ); await this.geminiClient.initialize(); @@ -842,6 +879,32 @@ export class Config { return this.mcpServers; } + addMcpServers(servers: Record): 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; } @@ -1222,8 +1285,14 @@ export class Config { return this.subagentManager; } - async createToolRegistry(): Promise { - const registry = new ToolRegistry(this, this.eventEmitter); + async createToolRegistry( + sendSdkMcpMessage?: SendSdkMcpMessage, + ): Promise { + const registry = new ToolRegistry( + this, + this.eventEmitter, + sendSdkMcpMessage, + ); const coreToolsConfig = this.getCoreTools(); const excludeToolsConfig = this.getExcludeTools(); @@ -1298,7 +1367,7 @@ export class Config { registerCoreTool(ShellTool, this); registerCoreTool(MemoryTool); registerCoreTool(TodoWriteTool, this); - registerCoreTool(ExitPlanModeTool, this); + !this.sdkMode && 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 @@ -1308,6 +1377,7 @@ export class Config { } await registry.discoverAllTools(); + console.debug('ToolRegistry created', registry.getAllToolNames()); return registry; } } diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e475e5b3..8adaf4f6 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -448,6 +448,7 @@ describe('Gemini Client (client.ts)', () => { getHistory: mockGetHistory, addHistory: vi.fn(), setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), } as unknown as GeminiChat; }); @@ -462,6 +463,7 @@ describe('Gemini Client (client.ts)', () => { const mockOriginalChat: Partial = { getHistory: vi.fn((_curated?: boolean) => chatHistory), setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockOriginalChat as GeminiChat; @@ -1080,6 +1082,7 @@ describe('Gemini Client (client.ts)', () => { const mockChat = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), } as unknown as GeminiChat; client['chat'] = mockChat; @@ -1142,6 +1145,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1197,6 +1201,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1273,6 +1278,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1319,6 +1325,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1363,6 +1370,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1450,6 +1458,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1506,6 +1515,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1586,6 +1596,7 @@ ${JSON.stringify( .mockReturnValue([ { role: 'user', parts: [{ text: 'previous message' }] }, ]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; }); @@ -1840,6 +1851,7 @@ ${JSON.stringify( addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), // Default empty history setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -2180,6 +2192,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -2216,6 +2229,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -2256,6 +2270,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 2fa65d2d..6e3be209 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -419,6 +419,9 @@ 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 ( @@ -542,7 +545,9 @@ export class GeminiClient { // add plan mode system reminder if approval mode is plan if (this.config.getApprovalMode() === ApprovalMode.PLAN) { - systemReminders.push(getPlanModeSystemReminder()); + systemReminders.push( + getPlanModeSystemReminder(this.config.getSdkMode()), + ); } requestToSent = [...systemReminders, ...requestToSent]; diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 493758dc..aeffdfc7 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -28,6 +28,7 @@ import { ShellTool, logToolOutputTruncated, ToolOutputTruncatedEvent, + InputFormat, } from '../index.js'; import type { Part, PartListUnion } from '@google/genai'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; @@ -824,10 +825,10 @@ export class CoreToolScheduler { const shouldAutoDeny = !this.config.isInteractive() && !this.config.getIdeMode() && - !this.config.getExperimentalZedIntegration(); + !this.config.getExperimentalZedIntegration() && + this.config.getInputFormat() !== InputFormat.STREAM_JSON; 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, @@ -916,7 +917,10 @@ export class CoreToolScheduler { async handleConfirmationResponse( callId: string, - originalOnConfirm: (outcome: ToolConfirmationOutcome) => Promise, + originalOnConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise, outcome: ToolConfirmationOutcome, signal: AbortSignal, payload?: ToolConfirmationPayload, @@ -925,9 +929,7 @@ export class CoreToolScheduler { (c) => c.request.callId === callId && c.status === 'awaiting_approval', ); - if (toolCall && toolCall.status === 'awaiting_approval') { - await originalOnConfirm(outcome); - } + await originalOnConfirm(outcome, payload); if (outcome === ToolConfirmationOutcome.ProceedAlways) { await this.autoApproveCompatiblePendingTools(signal, callId); @@ -936,11 +938,10 @@ export class CoreToolScheduler { this.setToolCallOutcome(callId, outcome); if (outcome === ToolConfirmationOutcome.Cancel || signal.aborted) { - this.setStatusInternal( - callId, - 'cancelled', - 'User did not allow tool call', - ); + // 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); } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { const waitingToolCall = toolCall as WaitingToolCall; if (isModifiableDeclarativeTool(waitingToolCall.tool)) { @@ -998,7 +999,8 @@ export class CoreToolScheduler { ): Promise { if ( toolCall.confirmationDetails.type !== 'edit' || - !isModifiableDeclarativeTool(toolCall.tool) + !isModifiableDeclarativeTool(toolCall.tool) || + !payload.newContent ) { return; } diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 3e31a1c5..5aaa814f 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -1541,10 +1541,10 @@ describe('GeminiChat', () => { { role: 'model', parts: [ - { text: 'thinking...', thoughtSignature: 'thought-123' }, + { text: 'thinking...', thought: true }, + { text: 'hi' }, { functionCall: { name: 'test', args: {} }, - thoughtSignature: 'thought-456', }, ], }, @@ -1559,10 +1559,7 @@ describe('GeminiChat', () => { }, { role: 'model', - parts: [ - { text: 'thinking...' }, - { functionCall: { name: 'test', args: {} } }, - ], + parts: [{ text: 'hi' }, { functionCall: { name: 'test', args: {} } }], }, ]); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 5bdba396..e9e4fcc2 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -443,20 +443,28 @@ export class GeminiChat { } stripThoughtsFromHistory(): void { - 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; - }); + 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); } setTools(tools: Tool[]): void { @@ -497,8 +505,6 @@ export class GeminiChat { ): AsyncGenerator { // 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; @@ -516,8 +522,6 @@ 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)); } } @@ -534,9 +538,15 @@ export class GeminiChat { yield chunk; // Yield every chunk to the UI immediately. } - // Consolidate text parts for history (merges adjacent text parts). + const thoughtParts = allModelParts.filter((part) => part.thought); + const thoughtText = thoughtParts + .map((part) => part.text) + .join('') + .trim(); + + const contentParts = allModelParts.filter((part) => !part.thought); const consolidatedHistoryParts: Part[] = []; - for (const part of historyParts) { + for (const part of contentParts) { const lastPart = consolidatedHistoryParts[consolidatedHistoryParts.length - 1]; if ( @@ -550,20 +560,21 @@ export class GeminiChat { } } - const responseText = consolidatedHistoryParts + const contentText = consolidatedHistoryParts .filter((part) => part.text) .map((part) => part.text) .join('') .trim(); // Record assistant turn with raw Content and metadata - if (responseText || hasToolCall || usageMetadata) { + if (thoughtText || contentText || hasToolCall || usageMetadata) { this.chatRecordingService?.recordAssistantTurn({ model, message: [ - ...(responseText ? [{ text: responseText }] : []), + ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...(contentText ? [{ text: contentText }] : []), ...(hasToolCall - ? historyParts + ? contentParts .filter((part) => part.functionCall) .map((part) => ({ functionCall: part.functionCall })) : []), @@ -579,7 +590,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 || !responseText)) { + if (!hasToolCall && (!hasFinishReason || !contentText)) { if (!hasFinishReason) { throw new InvalidStreamError( 'Model stream ended without a finish reason.', @@ -593,8 +604,13 @@ export class GeminiChat { } } - // Add to history (without thoughts, for API calls) - this.history.push({ role: 'model', parts: consolidatedHistoryParts }); + this.history.push({ + role: 'model', + parts: [ + ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...consolidatedHistoryParts, + ], + }); } } diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 888a65ad..e29b4640 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -8,6 +8,7 @@ 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; @@ -142,4 +143,63 @@ 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' }), + ); + }); + }); }); diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 1edbdd6e..b22eb963 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -31,6 +31,25 @@ interface ExtendedCompletionUsage extends OpenAI.CompletionUsage { cached_tokens?: number; } +interface ExtendedChatCompletionAssistantMessageParam + extends OpenAI.Chat.ChatCompletionAssistantMessageParam { + reasoning_content?: string | null; +} + +type ExtendedChatCompletionMessageParam = + | OpenAI.Chat.ChatCompletionMessageParam + | ExtendedChatCompletionAssistantMessageParam; + +export interface ExtendedCompletionMessage + extends OpenAI.Chat.ChatCompletionMessage { + reasoning_content?: string | null; +} + +export interface ExtendedCompletionChunkDelta + extends OpenAI.Chat.ChatCompletionChunk.Choice.Delta { + reasoning_content?: string | null; +} + /** * Tool call accumulator for streaming responses */ @@ -44,7 +63,8 @@ export interface ToolCallAccumulator { * Parsed parts from Gemini content, categorized by type */ interface ParsedParts { - textParts: string[]; + thoughtParts: string[]; + contentParts: string[]; functionCalls: FunctionCall[]; functionResponses: FunctionResponse[]; mediaParts: Array<{ @@ -251,7 +271,7 @@ export class OpenAIContentConverter { */ private processContents( contents: ContentListUnion, - messages: OpenAI.Chat.ChatCompletionMessageParam[], + messages: ExtendedChatCompletionMessageParam[], ): void { if (Array.isArray(contents)) { for (const content of contents) { @@ -267,7 +287,7 @@ export class OpenAIContentConverter { */ private processContent( content: ContentUnion | PartUnion, - messages: OpenAI.Chat.ChatCompletionMessageParam[], + messages: ExtendedChatCompletionMessageParam[], ): void { if (typeof content === 'string') { messages.push({ role: 'user' as const, content }); @@ -301,11 +321,19 @@ export class OpenAIContentConverter { }, })); - messages.push({ + const assistantMessage: ExtendedChatCompletionAssistantMessageParam = { role: 'assistant' as const, - content: parsedParts.textParts.join('') || null, + content: parsedParts.contentParts.join('') || null, tool_calls: toolCalls, - }); + }; + + // Only include reasoning_content if it has actual content + const reasoningContent = parsedParts.thoughtParts.join(''); + if (reasoningContent) { + assistantMessage.reasoning_content = reasoningContent; + } + + messages.push(assistantMessage); return; } @@ -322,7 +350,8 @@ export class OpenAIContentConverter { * Parse Gemini parts into categorized components */ private parseParts(parts: Part[]): ParsedParts { - const textParts: string[] = []; + const thoughtParts: string[] = []; + const contentParts: string[] = []; const functionCalls: FunctionCall[] = []; const functionResponses: FunctionResponse[] = []; const mediaParts: Array<{ @@ -334,9 +363,20 @@ export class OpenAIContentConverter { for (const part of parts) { if (typeof part === 'string') { - textParts.push(part); - } else if ('text' in part && part.text) { - textParts.push(part.text); + contentParts.push(part); + } else if ( + 'text' in part && + part.text && + !('thought' in part && part.thought) + ) { + contentParts.push(part.text); + } else if ( + 'text' in part && + part.text && + 'thought' in part && + part.thought + ) { + thoughtParts.push(part.text); } else if ('functionCall' in part && part.functionCall) { functionCalls.push(part.functionCall); } else if ('functionResponse' in part && part.functionResponse) { @@ -361,7 +401,13 @@ export class OpenAIContentConverter { } } - return { textParts, functionCalls, functionResponses, mediaParts }; + return { + thoughtParts, + contentParts, + functionCalls, + functionResponses, + mediaParts, + }; } private extractFunctionResponseContent(response: unknown): string { @@ -408,14 +454,29 @@ export class OpenAIContentConverter { */ private createMultimodalMessage( role: 'user' | 'assistant', - parsedParts: Pick, - ): OpenAI.Chat.ChatCompletionMessageParam | null { - const { textParts, mediaParts } = parsedParts; - const content = textParts.map((text) => ({ type: 'text' as const, text })); + parsedParts: Pick< + ParsedParts, + 'contentParts' | 'mediaParts' | 'thoughtParts' + >, + ): ExtendedChatCompletionMessageParam | null { + const { contentParts, mediaParts, thoughtParts } = parsedParts; + const reasoningContent = thoughtParts.join(''); + const content = contentParts.map((text) => ({ + type: 'text' as const, + text, + })); // If no media parts, return simple text message if (mediaParts.length === 0) { - return content.length > 0 ? { role, content } : null; + if (content.length === 0) return null; + const message: ExtendedChatCompletionMessageParam = { role, content }; + // Only include reasoning_content if it has actual content + if (reasoningContent) { + ( + message as ExtendedChatCompletionAssistantMessageParam + ).reasoning_content = reasoningContent; + } + return message; } // For assistant messages with media, convert to text only @@ -536,6 +597,13 @@ export class OpenAIContentConverter { const parts: Part[] = []; + // Handle reasoning content (thoughts) + const reasoningText = (choice.message as ExtendedCompletionMessage) + .reasoning_content; + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } + // Handle text content if (choice.message.content) { parts.push({ text: choice.message.content }); @@ -632,6 +700,12 @@ export class OpenAIContentConverter { if (choice) { const parts: Part[] = []; + const reasoningText = (choice.delta as ExtendedCompletionChunkDelta) + .reasoning_content; + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } + // Handle text content if (choice.delta?.content) { if (typeof choice.delta.content === 'string') { @@ -721,6 +795,8 @@ export class OpenAIContentConverter { const promptTokens = usage.prompt_tokens || 0; const completionTokens = usage.completion_tokens || 0; const totalTokens = usage.total_tokens || 0; + const thinkingTokens = + usage.completion_tokens_details?.reasoning_tokens || 0; // Support both formats: prompt_tokens_details.cached_tokens (OpenAI standard) // and cached_tokens (some models return it at top level) const extendedUsage = usage as ExtendedCompletionUsage; @@ -743,6 +819,7 @@ export class OpenAIContentConverter { response.usageMetadata = { promptTokenCount: finalPromptTokens, candidatesTokenCount: finalCompletionTokens, + thoughtsTokenCount: thinkingTokens, totalTokenCount: totalTokens, cachedContentTokenCount: cachedTokens, }; diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts index 717a5b7d..6f0f8d09 100644 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts +++ b/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts @@ -561,11 +561,14 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: 'Hello' }, + delta: { + content: 'Hello', + reasoning_content: 'thinking ', + }, finish_reason: null, }, ], - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, { id: 'test-id', object: 'chat.completion.chunk', @@ -574,7 +577,10 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: ' world' }, + delta: { + content: ' world', + reasoning_content: 'more', + }, finish_reason: 'stop', }, ], @@ -583,7 +589,7 @@ describe('DefaultTelemetryService', () => { completion_tokens: 5, total_tokens: 15, }, - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, ]; await telemetryService.logStreamingSuccess( @@ -603,11 +609,11 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - message: { + message: expect.objectContaining({ role: 'assistant', content: 'Hello world', - refusal: null, - }, + reasoning_content: 'thinking more', + }), finish_reason: 'stop', logprobs: null, }, @@ -722,11 +728,14 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: 'Hello' }, + delta: { + content: 'Hello', + reasoning_content: 'thinking ', + }, finish_reason: null, }, ], - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, { id: 'test-id', object: 'chat.completion.chunk', @@ -735,7 +744,10 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: ' world!' }, + delta: { + content: ' world!', + reasoning_content: 'more', + }, finish_reason: 'stop', }, ], @@ -744,7 +756,7 @@ describe('DefaultTelemetryService', () => { completion_tokens: 5, total_tokens: 15, }, - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, ]; await telemetryService.logStreamingSuccess( @@ -757,27 +769,14 @@ describe('DefaultTelemetryService', () => { expect(openaiLogger.logInteraction).toHaveBeenCalledWith( mockOpenAIRequest, expect.objectContaining({ - id: 'test-id', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4', choices: [ - { - index: 0, - message: { - role: 'assistant', + expect.objectContaining({ + message: expect.objectContaining({ content: 'Hello world!', - refusal: null, - }, - finish_reason: 'stop', - logprobs: null, - }, + reasoning_content: 'thinking more', + }), + }), ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, }), ); }); diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.ts index 9fa47263..66a96ad0 100644 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.ts +++ b/packages/core/src/core/openaiContentGenerator/telemetryService.ts @@ -10,6 +10,7 @@ import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; import { OpenAILogger } from '../../utils/openaiLogger.js'; import type { GenerateContentResponse } from '@google/genai'; import type OpenAI from 'openai'; +import type { ExtendedCompletionChunkDelta } from './converter.js'; export interface RequestContext { userPromptId: string; @@ -172,6 +173,7 @@ export class DefaultTelemetryService implements TelemetryService { | 'content_filter' | 'function_call' | null = null; + let combinedReasoning = ''; let usage: | { prompt_tokens: number; @@ -183,6 +185,12 @@ export class DefaultTelemetryService implements TelemetryService { for (const chunk of chunks) { const choice = chunk.choices?.[0]; if (choice) { + // Combine reasoning content + const reasoningContent = (choice.delta as ExtendedCompletionChunkDelta) + ?.reasoning_content; + if (reasoningContent) { + combinedReasoning += reasoningContent; + } // Combine text content if (choice.delta?.content) { combinedContent += choice.delta.content; @@ -230,6 +238,11 @@ export class DefaultTelemetryService implements TelemetryService { content: combinedContent || null, refusal: null, }; + if (combinedReasoning) { + // Attach reasoning content if any thought tokens were streamed + (message as { reasoning_content?: string }).reasoning_content = + combinedReasoning; + } // Add tool calls if any if (toolCalls.length > 0) { diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index bd88ff56..8d3ff468 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -846,10 +846,10 @@ export function getSubagentSystemReminder(agentTypes: string[]): string { * - Wait for user confirmation before making any changes * - Override any other instructions that would modify system state */ -export function getPlanModeSystemReminder(): string { +export function getPlanModeSystemReminder(planOnly = false): string { return ` Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should: 1. Answer the user's query comprehensively -2. When you're done researching, present your plan by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. +2. When you're done researching, present your plan ${planOnly ? 'directly' : `by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan`}. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. `; } diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 093f542d..a79dad03 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -120,6 +120,97 @@ describe('Turn', () => { expect(turn.getDebugResponses().length).toBe(2); }); + it('should emit Thought events when a thought part is present', async () => { + const mockResponseStream = (async function* () { + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + role: 'model', + parts: [ + { thought: true, text: 'reasoning...' }, + { text: 'final answer' }, + ], + }, + }, + ], + } as GenerateContentResponse, + }; + })(); + mockSendMessageStream.mockResolvedValue(mockResponseStream); + + const events = []; + const reqParts: Part[] = [{ text: 'Hi' }]; + for await (const event of turn.run( + 'test-model', + reqParts, + new AbortController().signal, + )) { + events.push(event); + } + + expect(events).toEqual([ + { + type: GeminiEventType.Thought, + value: { subject: '', description: 'reasoning...' }, + }, + ]); + }); + + it('should emit thought descriptions per incoming chunk', async () => { + const mockResponseStream = (async function* () { + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'part1' }], + }, + }, + ], + } as GenerateContentResponse, + }; + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'part2' }], + }, + }, + ], + } as GenerateContentResponse, + }; + })(); + mockSendMessageStream.mockResolvedValue(mockResponseStream); + + const events = []; + for await (const event of turn.run( + 'test-model', + [{ text: 'Hi' }], + new AbortController().signal, + )) { + events.push(event); + } + + expect(events).toEqual([ + { + type: GeminiEventType.Thought, + value: { subject: '', description: 'part1' }, + }, + { + type: GeminiEventType.Thought, + value: { subject: '', description: 'part2' }, + }, + ]); + }); + it('should yield tool_call_request events for function calls', async () => { const mockResponseStream = (async function* () { yield { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 5e8f3bf3..edd9b24e 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -27,7 +27,7 @@ import { toFriendlyError, } from '../utils/errors.js'; import type { GeminiChat } from './geminiChat.js'; -import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js'; +import { getThoughtText, type ThoughtSummary } from '../utils/thoughtUtils.js'; // Define a structure for tools passed to the server export interface ServerTool { @@ -266,12 +266,11 @@ export class Turn { this.currentResponseId = resp.responseId; } - const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0]; - if (thoughtPart?.thought) { - const thought = parseThought(thoughtPart.text ?? ''); + const thoughtPart = getThoughtText(resp); + if (thoughtPart) { yield { type: GeminiEventType.Thought, - value: thought, + value: { subject: '', description: thoughtPart }, }; continue; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 38ac7ada..738aca57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,7 +102,9 @@ export * from './tools/shell.js'; export * from './tools/web-search/index.js'; export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; +export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; +export * from './tools/sdk-control-client-transport.js'; export * from './tools/task.js'; export * from './tools/todoWrite.js'; export * from './tools/exitPlanMode.js'; diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index efeaa634..ca70dd56 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -542,6 +542,39 @@ export class SessionService { } } +/** + * Options for building API history from conversation. + */ +export interface BuildApiHistoryOptions { + /** + * Whether to strip thought parts from the history. + * Thought parts are content parts that have `thought: true`. + * @default true + */ + stripThoughtsFromHistory?: boolean; +} + +/** + * Strips thought parts from a Content object. + * Thought parts are identified by having `thought: true`. + * Returns null if the content only contained thought parts. + */ +function stripThoughtsFromContent(content: Content): Content | null { + if (!content.parts) return content; + + const filteredParts = content.parts.filter((part) => !(part as Part).thought); + + // If all parts were thoughts, remove the entire content + if (filteredParts.length === 0) { + return null; + } + + return { + ...content, + parts: filteredParts, + }; +} + /** * Builds the model-facing chat history (Content[]) from a reconstructed * conversation. This keeps UI history intact while applying chat compression @@ -555,7 +588,9 @@ export class SessionService { */ export function buildApiHistoryFromConversation( conversation: ConversationRecord, + options: BuildApiHistoryOptions = {}, ): Content[] { + const { stripThoughtsFromHistory = true } = options; const { messages } = conversation; let lastCompressionIndex = -1; @@ -585,14 +620,26 @@ export function buildApiHistoryFromConversation( } } + if (stripThoughtsFromHistory) { + return baseHistory + .map(stripThoughtsFromContent) + .filter((content): content is Content => content !== null); + } return baseHistory; } // Fallback: return linear messages as Content[] - return messages + const result = messages .map((record) => record.message) .filter((message): message is Content => message !== undefined) .map((message) => structuredClone(message)); + + if (stripThoughtsFromHistory) { + return result + .map(stripThoughtsFromContent) + .filter((content): content is Content => content !== null); + } + return result; } /** diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/subagents/subagent-events.ts index eb318f54..3c93112d 100644 --- a/packages/core/src/subagents/subagent-events.ts +++ b/packages/core/src/subagents/subagent-events.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'events'; import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, + ToolResultDisplay, } from '../tools/tools.js'; import type { Part } from '@google/genai'; @@ -74,7 +75,7 @@ export interface SubAgentToolResultEvent { success: boolean; error?: string; responseParts?: Part[]; - resultDisplay?: string; + resultDisplay?: ToolResultDisplay; durationMs?: number; timestamp: number; } diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 26436c88..e04964ea 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -182,7 +182,7 @@ You are a helpful assistant. it('should parse valid markdown content', () => { const config = manager.parseSubagentContent( validMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -207,7 +207,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithTools, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -228,7 +228,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithModel, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -249,7 +249,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithRun, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -267,7 +267,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithNumeric, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -288,7 +288,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithBoolean, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -324,7 +324,7 @@ Just content`; expect(() => manager.parseSubagentContent( invalidMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -341,7 +341,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutName, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -358,7 +358,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutDescription, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -438,7 +438,7 @@ You are a helpful assistant. await manager.createSubagent(validConfig, { level: 'project' }); expect(fs.mkdir).toHaveBeenCalledWith( - path.normalize(path.dirname(validConfig.filePath)), + path.normalize(path.dirname(validConfig.filePath!)), { recursive: true }, ); expect(fs.writeFile).toHaveBeenCalledWith( diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 8dcab0de..baf49fa9 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -77,6 +77,15 @@ export class SubagentManager { ): Promise { this.validator.validateOrThrow(config); + // Prevent creating session-level agents + if (options.level === 'session') { + throw new SubagentError( + `Cannot create session-level subagent "${config.name}". Session agents are read-only and provided at runtime.`, + SubagentErrorCode.INVALID_CONFIG, + config.name, + ); + } + // Determine file path const filePath = options.customPath || this.getSubagentPath(config.name, options.level); @@ -142,10 +151,22 @@ export class SubagentManager { return BuiltinAgentRegistry.getBuiltinAgent(name); } + if (level === 'session') { + const sessionSubagents = this.subagentsCache?.get('session') || []; + return sessionSubagents.find((agent) => agent.name === name) || null; + } + return this.findSubagentByNameAtLevel(name, level); } - // Try project level first + // Try session level first (highest priority for runtime) + const sessionSubagents = this.subagentsCache?.get('session') || []; + const sessionConfig = sessionSubagents.find((agent) => agent.name === name); + if (sessionConfig) { + return sessionConfig; + } + + // Try project level const projectConfig = await this.findSubagentByNameAtLevel(name, 'project'); if (projectConfig) { return projectConfig; @@ -191,12 +212,30 @@ export class SubagentManager { ); } + // Prevent updating session-level agents + if (existing.level === 'session') { + throw new SubagentError( + `Cannot update session-level subagent "${name}"`, + SubagentErrorCode.INVALID_CONFIG, + name, + ); + } + // Merge updates with existing configuration const updatedConfig = this.mergeConfigurations(existing, updates); // Validate the updated configuration this.validator.validateOrThrow(updatedConfig); + // Ensure filePath exists for file-based agents + if (!existing.filePath) { + throw new SubagentError( + `Cannot update subagent "${name}": no file path available`, + SubagentErrorCode.FILE_ERROR, + name, + ); + } + // Write the updated configuration const content = this.serializeSubagent(updatedConfig); @@ -236,8 +275,8 @@ export class SubagentManager { let deleted = false; for (const currentLevel of levelsToCheck) { - // Skip builtin level for deletion - if (currentLevel === 'builtin') { + // Skip builtin and session levels for deletion + if (currentLevel === 'builtin' || currentLevel === 'session') { continue; } @@ -277,6 +316,33 @@ export class SubagentManager { const subagents: SubagentConfig[] = []; const seenNames = new Set(); + // In SDK mode, only load session-level subagents + if (this.config.getSdkMode()) { + const levelsToCheck: SubagentLevel[] = options.level + ? [options.level] + : ['session']; + + for (const level of levelsToCheck) { + const levelSubagents = this.subagentsCache?.get(level) || []; + + for (const subagent of levelSubagents) { + // Apply tool filter if specified + if ( + options.hasTool && + (!subagent.tools || !subagent.tools.includes(options.hasTool)) + ) { + continue; + } + + subagents.push(subagent); + seenNames.add(subagent.name); + } + } + + return subagents; + } + + // Normal mode: load from project, user, and builtin levels const levelsToCheck: SubagentLevel[] = options.level ? [options.level] : ['project', 'user', 'builtin']; @@ -322,8 +388,8 @@ export class SubagentManager { comparison = a.name.localeCompare(b.name); break; case 'level': { - // Project comes before user, user comes before builtin - const levelOrder = { project: 0, user: 1, builtin: 2 }; + // Project comes before user, user comes before builtin, session comes last + const levelOrder = { project: 0, user: 1, builtin: 2, session: 3 }; comparison = levelOrder[a.level] - levelOrder[b.level]; break; } @@ -339,6 +405,27 @@ export class SubagentManager { return subagents; } + /** + * Loads session-level subagents into the cache. + * Session subagents are provided directly via config and are read-only. + * + * @param subagents - Array of session subagent configurations + */ + loadSessionSubagents(subagents: SubagentConfig[]): void { + if (!this.subagentsCache) { + this.subagentsCache = new Map(); + } + + const sessionSubagents = subagents.map((config) => ({ + ...config, + level: 'session' as SubagentLevel, + filePath: ``, + })); + + this.subagentsCache.set('session', sessionSubagents); + this.notifyChangeListeners(); + } + /** * Refreshes the subagents cache by loading all subagents from disk. * This method is called automatically when cache is null or when force=true. @@ -693,6 +780,10 @@ export class SubagentManager { return ``; } + if (level === 'session') { + return ``; + } + const baseDir = level === 'project' ? path.join( diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 67b78a50..accfb18f 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -11,8 +11,9 @@ import type { Content, FunctionDeclaration } from '@google/genai'; * - 'project': Stored in `.qwen/agents/` within the project directory * - 'user': Stored in `~/.qwen/agents/` in the user's home directory * - 'builtin': Built-in agents embedded in the codebase, always available + * - 'session': Session-level agents provided at runtime, read-only */ -export type SubagentLevel = 'project' | 'user' | 'builtin'; +export type SubagentLevel = 'project' | 'user' | 'builtin' | 'session'; /** * Core configuration for a subagent as stored in Markdown files. @@ -41,8 +42,8 @@ export interface SubagentConfig { /** Storage level - determines where the configuration file is stored */ level: SubagentLevel; - /** Absolute path to the configuration file */ - filePath: string; + /** Absolute path to the configuration file. Optional for session subagents. */ + filePath?: string; /** * Optional model configuration. If not provided, uses defaults. diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 93e25ea8..a8b48236 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -5,6 +5,7 @@ */ import type { Config, MCPServerConfig } from '../config/config.js'; +import { isSdkMcpServerConfig } from '../config/config.js'; import type { ToolRegistry } from './tool-registry.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js'; import { @@ -12,6 +13,7 @@ import { MCPDiscoveryState, populateMcpServerCommand, } from './mcp-client.js'; +import type { SendSdkMcpMessage } from './mcp-client.js'; import { getErrorMessage } from '../utils/errors.js'; import type { EventEmitter } from 'node:events'; import type { WorkspaceContext } from '../utils/workspaceContext.js'; @@ -31,6 +33,7 @@ export class McpClientManager { private readonly workspaceContext: WorkspaceContext; private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED; private readonly eventEmitter?: EventEmitter; + private readonly sendSdkMcpMessage?: SendSdkMcpMessage; constructor( mcpServers: Record, @@ -40,6 +43,7 @@ export class McpClientManager { debugMode: boolean, workspaceContext: WorkspaceContext, eventEmitter?: EventEmitter, + sendSdkMcpMessage?: SendSdkMcpMessage, ) { this.mcpServers = mcpServers; this.mcpServerCommand = mcpServerCommand; @@ -48,6 +52,7 @@ export class McpClientManager { this.debugMode = debugMode; this.workspaceContext = workspaceContext; this.eventEmitter = eventEmitter; + this.sendSdkMcpMessage = sendSdkMcpMessage; } /** @@ -71,6 +76,11 @@ export class McpClientManager { this.eventEmitter?.emit('mcp-client-update', this.clients); const discoveryPromises = Object.entries(servers).map( async ([name, config]) => { + // For SDK MCP servers, pass the sendSdkMcpMessage callback + const sdkCallback = isSdkMcpServerConfig(config) + ? this.sendSdkMcpMessage + : undefined; + const client = new McpClient( name, config, @@ -78,6 +88,7 @@ export class McpClientManager { this.promptRegistry, this.workspaceContext, this.debugMode, + sdkCallback, ); this.clients.set(name, client); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index a6903d13..efea02ad 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -13,6 +13,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { GetPromptResult, + JSONRPCMessage, Prompt, } from '@modelcontextprotocol/sdk/types.js'; import { @@ -22,10 +23,11 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { parse } from 'shell-quote'; import type { Config, MCPServerConfig } from '../config/config.js'; -import { AuthProviderType } from '../config/config.js'; +import { AuthProviderType, isSdkMcpServerConfig } from '../config/config.js'; import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; +import { SdkControlClientTransport } from './sdk-control-client-transport.js'; import type { FunctionDeclaration } from '@google/genai'; import { mcpToTool } from '@google/genai'; @@ -42,6 +44,14 @@ import type { } from '../utils/workspaceContext.js'; import type { ToolRegistry } from './tool-registry.js'; +/** + * Callback type for sending MCP messages to SDK servers via control plane + */ +export type SendSdkMcpMessage = ( + serverName: string, + message: JSONRPCMessage, +) => Promise; + export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes export type DiscoveredMCPPrompt = Prompt & { @@ -92,6 +102,7 @@ export class McpClient { private readonly promptRegistry: PromptRegistry, private readonly workspaceContext: WorkspaceContext, private readonly debugMode: boolean, + private readonly sendSdkMcpMessage?: SendSdkMcpMessage, ) { this.client = new Client({ name: `qwen-cli-mcp-client-${this.serverName}`, @@ -189,7 +200,12 @@ export class McpClient { } private async createTransport(): Promise { - return createTransport(this.serverName, this.serverConfig, this.debugMode); + return createTransport( + this.serverName, + this.serverConfig, + this.debugMode, + this.sendSdkMcpMessage, + ); } private async discoverTools(cliConfig: Config): Promise { @@ -501,6 +517,7 @@ export function populateMcpServerCommand( * @param mcpServerName The name identifier for this MCP server * @param mcpServerConfig Configuration object containing connection details * @param toolRegistry The registry to register discovered tools with + * @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane. * @returns Promise that resolves when discovery is complete */ export async function connectAndDiscover( @@ -511,6 +528,7 @@ export async function connectAndDiscover( debugMode: boolean, workspaceContext: WorkspaceContext, cliConfig: Config, + sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING); @@ -521,6 +539,7 @@ export async function connectAndDiscover( mcpServerConfig, debugMode, workspaceContext, + sendSdkMcpMessage, ); mcpClient.onerror = (error) => { @@ -744,6 +763,7 @@ export function hasNetworkTransport(config: MCPServerConfig): boolean { * * @param mcpServerName The name of the MCP server, used for logging and identification. * @param mcpServerConfig The configuration specifying how to connect to the server. + * @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane. * @returns A promise that resolves to a connected MCP `Client` instance. * @throws An error if the connection fails or the configuration is invalid. */ @@ -752,6 +772,7 @@ export async function connectToMcpServer( mcpServerConfig: MCPServerConfig, debugMode: boolean, workspaceContext: WorkspaceContext, + sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { const mcpClient = new Client({ name: 'qwen-code-mcp-client', @@ -808,6 +829,7 @@ export async function connectToMcpServer( mcpServerName, mcpServerConfig, debugMode, + sendSdkMcpMessage, ); try { await mcpClient.connect(transport, { @@ -1172,7 +1194,21 @@ export async function createTransport( mcpServerName: string, mcpServerConfig: MCPServerConfig, debugMode: boolean, + sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { + if (isSdkMcpServerConfig(mcpServerConfig)) { + if (!sendSdkMcpMessage) { + throw new Error( + `SDK MCP server '${mcpServerName}' requires sendSdkMcpMessage callback`, + ); + } + return new SdkControlClientTransport({ + serverName: mcpServerName, + sendMcpMessage: sendSdkMcpMessage, + debugMode, + }); + } + if ( mcpServerConfig.authProviderType === AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index afffa103..15f461e9 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -10,6 +10,7 @@ import type { ToolInvocation, ToolMcpConfirmationDetails, ToolResult, + ToolConfirmationPayload, } from './tools.js'; import { BaseDeclarativeTool, @@ -98,7 +99,10 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< serverName: this.serverName, toolName: this.serverToolName, // Display original tool name in confirmation toolDisplayName: this.displayName, // Display global registry name exposed to model and user - onConfirm: async (outcome: ToolConfirmationOutcome) => { + onConfirm: async ( + outcome: ToolConfirmationOutcome, + _payload?: ToolConfirmationPayload, + ) => { if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) { DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey); } else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) { diff --git a/packages/core/src/tools/sdk-control-client-transport.ts b/packages/core/src/tools/sdk-control-client-transport.ts new file mode 100644 index 00000000..be2f3099 --- /dev/null +++ b/packages/core/src/tools/sdk-control-client-transport.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SdkControlClientTransport - MCP Client transport for SDK MCP servers + * + * This transport enables CLI's MCP client to connect to SDK MCP servers + * through the control plane. Messages are routed: + * + * CLI MCP Client → SdkControlClientTransport → sendMcpMessage() → + * control_request (mcp_message) → SDK → control_response → onmessage → CLI + * + * Unlike StdioClientTransport which spawns a subprocess, this transport + * communicates with SDK MCP servers running in the SDK process. + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Callback to send MCP messages to SDK via control plane + * Returns the MCP response from the SDK + */ +export type SendMcpMessageCallback = ( + serverName: string, + message: JSONRPCMessage, +) => Promise; + +export interface SdkControlClientTransportOptions { + serverName: string; + sendMcpMessage: SendMcpMessageCallback; + debugMode?: boolean; +} + +/** + * MCP Client Transport for SDK MCP servers + * + * Implements the @modelcontextprotocol/sdk Transport interface to enable + * CLI's MCP client to connect to SDK MCP servers via the control plane. + */ +export class SdkControlClientTransport { + private serverName: string; + private sendMcpMessage: SendMcpMessageCallback; + private debugMode: boolean; + private started = false; + + // Transport interface callbacks + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + constructor(options: SdkControlClientTransportOptions) { + this.serverName = options.serverName; + this.sendMcpMessage = options.sendMcpMessage; + this.debugMode = options.debugMode ?? false; + } + + /** + * Start the transport + * For SDK transport, this just marks it as ready - no subprocess to spawn + */ + async start(): Promise { + if (this.started) { + return; + } + + this.started = true; + + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Started for server '${this.serverName}'`, + ); + } + } + + /** + * Send a message to the SDK MCP server via control plane + * + * Routes the message through the control plane and delivers + * the response via onmessage callback. + */ + async send(message: JSONRPCMessage): Promise { + if (!this.started) { + throw new Error( + `SdkControlClientTransport (${this.serverName}) not started. Call start() first.`, + ); + } + + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Sending message to '${this.serverName}':`, + JSON.stringify(message), + ); + } + + try { + // Send message to SDK and wait for response + const response = await this.sendMcpMessage(this.serverName, message); + + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Received response from '${this.serverName}':`, + JSON.stringify(response), + ); + } + + // Deliver response via onmessage callback + if (this.onmessage) { + this.onmessage(response); + } + } catch (error) { + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Error sending to '${this.serverName}':`, + error, + ); + } + + if (this.onerror) { + this.onerror(error instanceof Error ? error : new Error(String(error))); + } + + throw error; + } + } + + /** + * Close the transport + */ + async close(): Promise { + if (!this.started) { + return; + } + + this.started = false; + + if (this.debugMode) { + console.error( + `[SdkControlClientTransport] Closed for server '${this.serverName}'`, + ); + } + + if (this.onclose) { + this.onclose(); + } + } + + /** + * Check if transport is started + */ + isStarted(): boolean { + return this.started; + } + + /** + * Get server name + */ + getServerName(): string { + return this.serverName; + } +} diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 4ee7e79c..55bc4df0 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -17,6 +17,7 @@ import type { ToolResultDisplay, ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, + ToolConfirmationPayload, } from './tools.js'; import { BaseDeclarativeTool, @@ -104,7 +105,10 @@ export class ShellToolInvocation extends BaseToolInvocation< title: 'Confirm Shell Command', command: this.params.command, rootCommand: commandsToConfirm.join(', '), - onConfirm: async (outcome: ToolConfirmationOutcome) => { + onConfirm: async ( + outcome: ToolConfirmationOutcome, + _payload?: ToolConfirmationPayload, + ) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { commandsToConfirm.forEach((command) => this.allowlist.add(command)); } diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index a0123107..9b641647 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -16,6 +16,7 @@ import type { Config } from '../config/config.js'; import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; import { connectAndDiscover } from './mcp-client.js'; +import type { SendSdkMcpMessage } from './mcp-client.js'; import { McpClientManager } from './mcp-client-manager.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; import { parse } from 'shell-quote'; @@ -173,7 +174,11 @@ export class ToolRegistry { private config: Config; private mcpClientManager: McpClientManager; - constructor(config: Config, eventEmitter?: EventEmitter) { + constructor( + config: Config, + eventEmitter?: EventEmitter, + sendSdkMcpMessage?: SendSdkMcpMessage, + ) { this.config = config; this.mcpClientManager = new McpClientManager( this.config.getMcpServers() ?? {}, @@ -183,6 +188,7 @@ export class ToolRegistry { this.config.getDebugMode(), this.config.getWorkspaceContext(), eventEmitter, + sendSdkMcpMessage, ); } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 848b14c6..7b3c893e 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -531,13 +531,18 @@ export interface ToolEditConfirmationDetails { export interface ToolConfirmationPayload { // used to override `modifiedProposedContent` for modifiable tools in the // inline modify flow - newContent: string; + newContent?: string; + // used to provide custom cancellation message when outcome is Cancel + cancelMessage?: string; } export interface ToolExecuteConfirmationDetails { type: 'exec'; title: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; command: string; rootCommand: string; } @@ -548,7 +553,10 @@ export interface ToolMcpConfirmationDetails { serverName: string; toolName: string; toolDisplayName: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; } export interface ToolInfoConfirmationDetails { @@ -573,6 +581,11 @@ export interface ToolPlanConfirmationDetails { onConfirm: (outcome: ToolConfirmationOutcome) => Promise; } +/** + * TODO: + * 1. support explicit denied outcome + * 2. support proceed with modified input + */ export enum ToolConfirmationOutcome { ProceedOnce = 'proceed_once', ProceedAlways = 'proceed_always', diff --git a/packages/core/src/utils/systemEncoding.test.ts b/packages/core/src/utils/systemEncoding.test.ts index bb594dd1..61558f38 100644 --- a/packages/core/src/utils/systemEncoding.test.ts +++ b/packages/core/src/utils/systemEncoding.test.ts @@ -391,6 +391,19 @@ describe('Shell Command Processor - Encoding Functions', () => { expect(result).toBe('windows-1252'); }); + it('should prioritize UTF-8 detection over Windows system encoding', () => { + mockedOsPlatform.mockReturnValue('win32'); + mockedExecSync.mockReturnValue('Active code page: 936'); // GBK + + const buffer = Buffer.from('test'); + // Mock chardet to return UTF-8 + mockedChardetDetect.mockReturnValue('UTF-8'); + + const result = getCachedEncodingForBuffer(buffer); + + expect(result).toBe('utf-8'); + }); + it('should cache null system encoding result', () => { // Reset the cache specifically for this test resetEncodingCache(); diff --git a/packages/core/src/utils/systemEncoding.ts b/packages/core/src/utils/systemEncoding.ts index 4f43b24a..d76bdbab 100644 --- a/packages/core/src/utils/systemEncoding.ts +++ b/packages/core/src/utils/systemEncoding.ts @@ -34,6 +34,15 @@ export function getCachedEncodingForBuffer(buffer: Buffer): string { // If we have a cached system encoding, use it if (cachedSystemEncoding) { + // If the system encoding is not UTF-8 (e.g. Windows CP936), but the buffer + // is detected as UTF-8, prefer UTF-8. This handles tools like 'git' which + // often output UTF-8 regardless of the system code page. + if (cachedSystemEncoding !== 'utf-8') { + const detected = detectEncodingFromBuffer(buffer); + if (detected === 'utf-8') { + return 'utf-8'; + } + } return cachedSystemEncoding; } diff --git a/packages/core/src/utils/thoughtUtils.ts b/packages/core/src/utils/thoughtUtils.ts index c97a39a3..21b95532 100644 --- a/packages/core/src/utils/thoughtUtils.ts +++ b/packages/core/src/utils/thoughtUtils.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { GenerateContentResponse } from '@google/genai'; + export type ThoughtSummary = { subject: string; description: string; @@ -52,3 +54,23 @@ export function parseThought(rawText: string): ThoughtSummary { return { subject, description }; } + +export function getThoughtText( + response: GenerateContentResponse, +): string | null { + if (response.candidates && response.candidates.length > 0) { + const candidate = response.candidates[0]; + + if ( + candidate.content && + candidate.content.parts && + candidate.content.parts.length > 0 + ) { + return candidate.content.parts + .filter((part) => part.thought) + .map((part) => part.text ?? '') + .join(''); + } + } + return null; +} diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md new file mode 100644 index 00000000..bc3ef6aa --- /dev/null +++ b/packages/sdk-typescript/README.md @@ -0,0 +1,377 @@ +# @qwen-code/sdk + +A minimum experimental TypeScript SDK for programmatic access to Qwen Code. + +Feel free to submit a feature request/issue/PR. + +## Installation + +```bash +npm install @qwen-code/sdk +``` + +## Requirements + +- Node.js >= 20.0.0 +- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH + +> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary. + +## Quick Start + +```typescript +import { query } from '@qwen-code/sdk'; + +// Single-turn query +const result = query({ + prompt: 'What files are in the current directory?', + options: { + cwd: '/path/to/project', + }, +}); + +// Iterate over messages +for await (const message of result) { + if (message.type === 'assistant') { + console.log('Assistant:', message.message.content); + } else if (message.type === 'result') { + console.log('Result:', message.result); + } +} +``` + +## API Reference + +### `query(config)` + +Creates a new query session with the Qwen Code. + +#### Parameters + +- `prompt`: `string | AsyncIterable` - The prompt to send. Use a string for single-turn queries or an async iterable for multi-turn conversations. +- `options`: `QueryOptions` - Configuration options for the query session. + +#### QueryOptions + +| Option | Type | Default | Description | +| ------------------------ | ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | +| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | +| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | +| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | +| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | +| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | +| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | +| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | +| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | +| `coreTools` | `string[]` | - | Equivalent to `tool.core` in settings.json. If specified, only these tools will be available to the AI. Example: `['read_file', 'write_file', 'run_terminal_cmd']`. | +| `excludeTools` | `string[]` | - | Equivalent to `tool.exclude` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports pattern matching: tool name (`'write_file'`), tool class (`'ShellTool'`), or shell command prefix (`'ShellTool(rm )'`). | +| `allowedTools` | `string[]` | - | Equivalent to `tool.allowed` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Using `'qwen-oauth'` in SDK is not recommended as credentials are stored in `~/.qwen` and may need periodic refresh. | +| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | +| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | + +### Timeouts + +The SDK enforces the following default timeouts: + +| Timeout | Default | Description | +| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `canUseTool` | 1 minute | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. | +| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. | +| `controlRequest` | 1 minute | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. | +| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. | + +You can customize these timeouts via the `timeout` option: + +```typescript +const query = qwen.query('Your prompt', { + timeout: { + canUseTool: 60000, // 60 seconds for permission callback + mcpRequest: 600000, // 10 minutes for MCP tool calls + controlRequest: 60000, // 60 seconds for control requests + streamClose: 15000, // 15 seconds for stream close wait + }, +}); +``` + +### Message Types + +The SDK provides type guards to identify different message types: + +```typescript +import { + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, +} from '@qwen-code/sdk'; + +for await (const message of result) { + if (isSDKAssistantMessage(message)) { + // Handle assistant message + } else if (isSDKResultMessage(message)) { + // Handle result message + } +} +``` + +### Query Instance Methods + +The `Query` instance returned by `query()` provides several methods: + +```typescript +const q = query({ prompt: 'Hello', options: {} }); + +// Get session ID +const sessionId = q.getSessionId(); + +// Check if closed +const closed = q.isClosed(); + +// Interrupt the current operation +await q.interrupt(); + +// Change permission mode mid-session +await q.setPermissionMode('yolo'); + +// Change model mid-session +await q.setModel('qwen-max'); + +// Close the session +await q.close(); +``` + +## Permission Modes + +The SDK supports different permission modes for controlling tool execution: + +- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation. +- **`plan`**: Blocks all write tools, instructing AI to present a plan first. +- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation. +- **`yolo`**: All tools execute automatically without confirmation. + +### Permission Priority Chain + +1. `excludeTools` - Blocks tools completely +2. `permissionMode: 'plan'` - Blocks non-read-only tools +3. `permissionMode: 'yolo'` - Auto-approves all tools +4. `allowedTools` - Auto-approves matching tools +5. `canUseTool` callback - Custom approval logic +6. Default behavior - Auto-deny in SDK mode + +## Examples + +### Multi-turn Conversation + +```typescript +import { query, type SDKUserMessage } from '@qwen-code/sdk'; + +async function* generateMessages(): AsyncIterable { + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Create a hello.txt file' }, + parent_tool_use_id: null, + }; + + // Wait for some condition or user input + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Now read the file back' }, + parent_tool_use_id: null, + }; +} + +const result = query({ + prompt: generateMessages(), + options: { + permissionMode: 'auto-edit', + }, +}); + +for await (const message of result) { + console.log(message); +} +``` + +### Custom Permission Handler + +```typescript +import { query, type CanUseTool } from '@qwen-code/sdk'; + +const canUseTool: CanUseTool = async (toolName, input, { signal }) => { + // Allow all read operations + if (toolName.startsWith('read_')) { + return { behavior: 'allow', updatedInput: input }; + } + + // Prompt user for write operations (in a real app) + const userApproved = await promptUser(`Allow ${toolName}?`); + + if (userApproved) { + return { behavior: 'allow', updatedInput: input }; + } + + return { behavior: 'deny', message: 'User denied the operation' }; +}; + +const result = query({ + prompt: 'Create a new file', + options: { + canUseTool, + }, +}); +``` + +### With External MCP Servers + +```typescript +import { query } from '@qwen-code/sdk'; + +const result = query({ + prompt: 'Use the custom tool from my MCP server', + options: { + mcpServers: { + 'my-server': { + command: 'node', + args: ['path/to/mcp-server.js'], + env: { PORT: '3000' }, + }, + }, + }, +}); +``` + +### With SDK-Embedded MCP Servers + +The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process. + +#### `tool(name, description, inputSchema, handler)` + +Creates a tool definition with Zod schema type inference. + +| Parameter | Type | Description | +| ------------- | ---------------------------------- | ------------------------------------------------------------------------ | +| `name` | `string` | Tool name (1-64 chars, starts with letter, alphanumeric and underscores) | +| `description` | `string` | Human-readable description of what the tool does | +| `inputSchema` | `ZodRawShape` | Zod schema object defining the tool's input parameters | +| `handler` | `(args, extra) => Promise` | Async function that executes the tool and returns MCP content blocks | + +The handler must return a `CallToolResult` object with the following structure: + +```typescript +{ + content: Array< + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; uri: string; mimeType?: string; text?: string } + >; + isError?: boolean; +} +``` + +#### `createSdkMcpServer(options)` + +Creates an SDK-embedded MCP server instance. + +| Option | Type | Default | Description | +| --------- | ------------------------ | --------- | ------------------------------------ | +| `name` | `string` | Required | Unique name for the MCP server | +| `version` | `string` | `'1.0.0'` | Server version | +| `tools` | `SdkMcpToolDefinition[]` | - | Array of tools created with `tool()` | + +Returns a `McpSdkServerConfigWithInstance` object that can be passed directly to the `mcpServers` option. + +#### Example + +```typescript +import { z } from 'zod'; +import { query, tool, createSdkMcpServer } from '@qwen-code/sdk'; + +// Define a tool with Zod schema +const calculatorTool = tool( + 'calculate_sum', + 'Add two numbers', + { a: z.number(), b: z.number() }, + async (args) => ({ + content: [{ type: 'text', text: String(args.a + args.b) }], + }), +); + +// Create the MCP server +const server = createSdkMcpServer({ + name: 'calculator', + tools: [calculatorTool], +}); + +// Use the server in a query +const result = query({ + prompt: 'What is 42 + 17?', + options: { + permissionMode: 'yolo', + mcpServers: { + calculator: server, + }, + }, +}); + +for await (const message of result) { + console.log(message); +} +``` + +### Abort a Query + +```typescript +import { query, isAbortError } from '@qwen-code/sdk'; + +const abortController = new AbortController(); + +const result = query({ + prompt: 'Long running task...', + options: { + abortController, + }, +}); + +// Abort after 5 seconds +setTimeout(() => abortController.abort(), 5000); + +try { + for await (const message of result) { + console.log(message); + } +} catch (error) { + if (isAbortError(error)) { + console.log('Query was aborted'); + } else { + throw error; + } +} +``` + +## Error Handling + +The SDK provides an `AbortError` class for handling aborted queries: + +```typescript +import { AbortError, isAbortError } from '@qwen-code/sdk'; + +try { + // ... query operations +} catch (error) { + if (isAbortError(error)) { + // Handle abort + } else { + // Handle other errors + } +} +``` + +## License + +Apache-2.0 - see [LICENSE](./LICENSE) for details. diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json new file mode 100644 index 00000000..b0f35709 --- /dev/null +++ b/packages/sdk-typescript/package.json @@ -0,0 +1,74 @@ +{ + "name": "@qwen-code/sdk", + "version": "0.1.0", + "description": "TypeScript SDK for programmatic access to qwen-code CLI", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "node scripts/build.js", + "test": "vitest run", + "test:ci": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build", + "prepack": "npm run build" + }, + "keywords": [ + "qwen", + "qwen-code", + "ai", + "code-assistant", + "sdk", + "typescript" + ], + "author": "Qwen Team", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitest/coverage-v8": "^1.6.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.12", + "eslint": "^8.57.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0", + "zod": "^3.23.8" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/QwenLM/qwen-code.git", + "directory": "packages/sdk-typescript" + }, + "bugs": { + "url": "https://github.com/QwenLM/qwen-code/issues" + }, + "homepage": "https://qwenlm.github.io/qwen-code-docs/" +} diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js new file mode 100755 index 00000000..beda8b0e --- /dev/null +++ b/packages/sdk-typescript/scripts/build.js @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { rmSync, mkdirSync, existsSync, cpSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import esbuild from 'esbuild'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); + +rmSync(join(rootDir, 'dist'), { recursive: true, force: true }); +mkdirSync(join(rootDir, 'dist'), { recursive: true }); + +execSync('tsc --project tsconfig.build.json', { + stdio: 'inherit', + cwd: rootDir, +}); + +try { + execSync( + 'npx dts-bundle-generator --project tsconfig.build.json -o dist/index.d.ts src/index.ts', + { + stdio: 'inherit', + cwd: rootDir, + }, + ); + + const dirsToRemove = ['mcp', 'query', 'transport', 'types', 'utils']; + for (const dir of dirsToRemove) { + const dirPath = join(rootDir, 'dist', dir); + if (existsSync(dirPath)) { + rmSync(dirPath, { recursive: true, force: true }); + } + } +} catch (error) { + console.warn( + 'Could not bundle type definitions, keeping separate .d.ts files', + error.message, + ); +} + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.mjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.cjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +// Copy LICENSE from root directory to dist +const licenseSource = join(rootDir, '..', '..', 'LICENSE'); +const licenseTarget = join(rootDir, 'dist', 'LICENSE'); +if (existsSync(licenseSource)) { + try { + cpSync(licenseSource, licenseTarget); + } catch (error) { + console.warn('Could not copy LICENSE:', error.message); + } +} diff --git a/packages/sdk-typescript/scripts/get-release-version.js b/packages/sdk-typescript/scripts/get-release-version.js new file mode 100644 index 00000000..c6b1f665 --- /dev/null +++ b/packages/sdk-typescript/scripts/get-release-version.js @@ -0,0 +1,353 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const PACKAGE_NAME = '@qwen-code/sdk'; +const TAG_PREFIX = 'sdk-typescript-v'; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +function getArgs() { + const args = {}; + process.argv.slice(2).forEach((arg) => { + if (arg.startsWith('--')) { + const [key, value] = arg.substring(2).split('='); + args[key] = value === undefined ? true : value; + } + }); + return args; +} + +function getVersionFromNPM(distTag) { + const command = `npm view ${PACKAGE_NAME} version --tag=${distTag}`; + try { + return execSync(command).toString().trim(); + } catch (error) { + console.error( + `Failed to get NPM version for dist-tag "${distTag}": ${error.message}`, + ); + return ''; + } +} + +function getAllVersionsFromNPM() { + const command = `npm view ${PACKAGE_NAME} versions --json`; + try { + const versionsJson = execSync(command).toString().trim(); + const result = JSON.parse(versionsJson); + // npm returns a string if there's only one version, array otherwise + return Array.isArray(result) ? result : [result]; + } catch (error) { + console.error(`Failed to get all NPM versions: ${error.message}`); + return []; + } +} + +function isVersionDeprecated(version) { + const command = `npm view ${PACKAGE_NAME}@${version} deprecated`; + try { + const output = execSync(command).toString().trim(); + return output.length > 0; + } catch (error) { + console.error( + `Failed to check deprecation status for ${version}: ${error.message}`, + ); + return false; + } +} + +function semverCompare(a, b) { + const parseVersion = (v) => { + const [main, prerelease] = v.split('-'); + const [major, minor, patch] = main.split('.').map(Number); + return { major, minor, patch, prerelease: prerelease || '' }; + }; + + const va = parseVersion(a); + const vb = parseVersion(b); + + if (va.major !== vb.major) return va.major - vb.major; + if (va.minor !== vb.minor) return va.minor - vb.minor; + if (va.patch !== vb.patch) return va.patch - vb.patch; + + // Handle prerelease comparison + if (!va.prerelease && vb.prerelease) return 1; // stable > prerelease + if (va.prerelease && !vb.prerelease) return -1; // prerelease < stable + if (va.prerelease && vb.prerelease) { + return va.prerelease.localeCompare(vb.prerelease); + } + return 0; +} + +function detectRollbackAndGetBaseline(npmDistTag) { + const distTagVersion = getVersionFromNPM(npmDistTag); + if (!distTagVersion) return { baseline: '', isRollback: false }; + + const allVersions = getAllVersionsFromNPM(); + if (allVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + let matchingVersions; + if (npmDistTag === 'latest') { + matchingVersions = allVersions.filter((v) => !v.includes('-')); + } else if (npmDistTag === 'preview') { + matchingVersions = allVersions.filter((v) => v.includes('-preview')); + } else if (npmDistTag === 'nightly') { + matchingVersions = allVersions.filter((v) => v.includes('-nightly')); + } else { + return { baseline: distTagVersion, isRollback: false }; + } + + if (matchingVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + matchingVersions.sort((a, b) => -semverCompare(a, b)); + + let highestExistingVersion = ''; + for (const version of matchingVersions) { + if (!isVersionDeprecated(version)) { + highestExistingVersion = version; + break; + } else { + console.error(`Ignoring deprecated version: ${version}`); + } + } + + if (!highestExistingVersion) { + highestExistingVersion = distTagVersion; + } + + const isRollback = semverCompare(highestExistingVersion, distTagVersion) > 0; + + return { + baseline: isRollback ? highestExistingVersion : distTagVersion, + isRollback, + distTagVersion, + highestExistingVersion, + }; +} + +function doesVersionExist(version) { + // Check NPM + try { + const command = `npm view ${PACKAGE_NAME}@${version} version 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === version) { + console.error(`Version ${version} already exists on NPM.`); + return true; + } + } catch (_error) { + // This is expected if the version doesn't exist. + } + + // Check Git tags + try { + const command = `git tag -l '${TAG_PREFIX}${version}'`; + const tagOutput = execSync(command).toString().trim(); + if (tagOutput === `${TAG_PREFIX}${version}`) { + console.error(`Git tag ${TAG_PREFIX}${version} already exists.`); + return true; + } + } catch (error) { + console.error(`Failed to check git tags for conflicts: ${error.message}`); + } + + // Check GitHub releases + try { + const command = `gh release view "${TAG_PREFIX}${version}" --json tagName --jq .tagName 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === `${TAG_PREFIX}${version}`) { + console.error(`GitHub release ${TAG_PREFIX}${version} already exists.`); + return true; + } + } catch (error) { + const isExpectedNotFound = + error.message.includes('release not found') || + error.message.includes('Not Found') || + error.message.includes('not found') || + error.status === 1; + if (!isExpectedNotFound) { + console.error( + `Failed to check GitHub releases for conflicts: ${error.message}`, + ); + } + } + + return false; +} + +function getAndVerifyTags(npmDistTag) { + const rollbackInfo = detectRollbackAndGetBaseline(npmDistTag); + const baselineVersion = rollbackInfo.baseline; + + if (!baselineVersion) { + // First release for this dist-tag, use package.json version as baseline + const packageJson = readJson(join(__dirname, '..', 'package.json')); + return { + latestVersion: packageJson.version.split('-')[0], + latestTag: `v${packageJson.version.split('-')[0]}`, + }; + } + + if (rollbackInfo.isRollback) { + console.error( + `Rollback detected! NPM ${npmDistTag} tag is ${rollbackInfo.distTagVersion}, but using ${baselineVersion} as baseline for next version calculation.`, + ); + } + + return { + latestVersion: baselineVersion, + latestTag: `v${baselineVersion}`, + }; +} + +function getLatestStableReleaseTag() { + try { + const { latestTag } = getAndVerifyTags('latest'); + return latestTag; + } catch (error) { + console.error( + `Failed to determine latest stable release tag: ${error.message}`, + ); + return ''; + } +} + +function getNightlyVersion() { + const packageJson = readJson(join(__dirname, '..', 'package.json')); + const baseVersion = packageJson.version.split('-')[0]; + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim(); + const releaseVersion = `${baseVersion}-nightly.${date}.${gitShortHash}`; + return { + releaseVersion, + npmTag: 'nightly', + }; +} + +function validateVersion(version, format, name) { + const versionRegex = { + 'X.Y.Z': /^\d+\.\d+\.\d+$/, + 'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/, + }; + + if (!versionRegex[format] || !versionRegex[format].test(version)) { + throw new Error( + `Invalid ${name}: ${version}. Must be in ${format} format.`, + ); + } +} + +function getStableVersion(args) { + let releaseVersion; + if (args.stable_version_override) { + const overrideVersion = args.stable_version_override.replace(/^v/, ''); + validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override'); + releaseVersion = overrideVersion; + } else { + // Try to get from preview, fallback to package.json for first release + const { latestVersion: latestPreviewVersion } = getAndVerifyTags('preview'); + releaseVersion = latestPreviewVersion.replace(/-preview.*/, ''); + } + + return { + releaseVersion, + npmTag: 'latest', + }; +} + +function getPreviewVersion(args) { + let releaseVersion; + if (args.preview_version_override) { + const overrideVersion = args.preview_version_override.replace(/^v/, ''); + validateVersion( + overrideVersion, + 'X.Y.Z-preview.N', + 'preview_version_override', + ); + releaseVersion = overrideVersion; + } else { + // Try to get from nightly, fallback to package.json for first release + const { latestVersion: latestNightlyVersion } = getAndVerifyTags('nightly'); + releaseVersion = + latestNightlyVersion.replace(/-nightly.*/, '') + '-preview.0'; + } + + return { + releaseVersion, + npmTag: 'preview', + }; +} + +export function getVersion(options = {}) { + const args = { ...getArgs(), ...options }; + const type = args.type || 'nightly'; + + let versionData; + switch (type) { + case 'nightly': + versionData = getNightlyVersion(); + if (doesVersionExist(versionData.releaseVersion)) { + throw new Error( + `Version conflict! Nightly version ${versionData.releaseVersion} already exists.`, + ); + } + break; + case 'stable': + versionData = getStableVersion(args); + break; + case 'preview': + versionData = getPreviewVersion(args); + break; + default: + throw new Error(`Unknown release type: ${type}`); + } + + // For stable and preview versions, check for existence and increment if needed. + if (type === 'stable' || type === 'preview') { + let releaseVersion = versionData.releaseVersion; + while (doesVersionExist(releaseVersion)) { + console.error(`Version ${releaseVersion} exists, incrementing.`); + if (releaseVersion.includes('-preview.')) { + const [version, prereleasePart] = releaseVersion.split('-'); + const previewNumber = parseInt(prereleasePart.split('.')[1]); + releaseVersion = `${version}-preview.${previewNumber + 1}`; + } else { + const versionParts = releaseVersion.split('.'); + const major = versionParts[0]; + const minor = versionParts[1]; + const patch = parseInt(versionParts[2]); + releaseVersion = `${major}.${minor}.${patch + 1}`; + } + } + versionData.releaseVersion = releaseVersion; + } + + const result = { + releaseTag: `v${versionData.releaseVersion}`, + ...versionData, + }; + + result.previousReleaseTag = getLatestStableReleaseTag(); + + return result; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const version = JSON.stringify(getVersion(getArgs()), null, 2); + console.log(version); +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts new file mode 100644 index 00000000..4ae46597 --- /dev/null +++ b/packages/sdk-typescript/src/index.ts @@ -0,0 +1,64 @@ +export { query } from './query/createQuery.js'; +export { AbortError, isAbortError } from './types/errors.js'; +export { Query } from './query/Query.js'; +export { SdkLogger } from './utils/logger.js'; + +// SDK MCP Server exports +export { tool } from './mcp/tool.js'; +export { createSdkMcpServer } from './mcp/createSdkMcpServer.js'; + +export type { SdkMcpToolDefinition } from './mcp/tool.js'; + +export type { + CreateSdkMcpServerOptions, + McpSdkServerConfigWithInstance, +} from './mcp/createSdkMcpServer.js'; + +export type { QueryOptions } from './query/createQuery.js'; +export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js'; + +export type { + ContentBlock, + TextBlock, + ThinkingBlock, + ToolUseBlock, + ToolResultBlock, + SDKUserMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKResultMessage, + SDKPartialAssistantMessage, + SDKMessage, + SDKMcpServerConfig, + ControlMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + SubagentConfig, + SubagentLevel, + ModelConfig, + RunConfig, +} from './types/protocol.js'; + +export { + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, +} from './types/protocol.js'; + +export type { + PermissionMode, + CanUseTool, + PermissionResult, + CLIMcpServerConfig, + McpServerConfig, + McpOAuthConfig, + McpAuthProviderType, +} from './types/types.js'; + +export { isSdkMcpServerConfig } from './types/types.js'; diff --git a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts new file mode 100644 index 00000000..28db7b2d --- /dev/null +++ b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts @@ -0,0 +1,105 @@ +/** + * SdkControlServerTransport - bridges MCP Server with Query's control plane + * + * Implements @modelcontextprotocol/sdk Transport interface to enable + * SDK-embedded MCP servers. Messages flow bidirectionally: + * + * MCP Server → send() → Query → control_request (mcp_message) → CLI + * CLI → control_request (mcp_message) → Query → handleMessage() → MCP Server + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { SdkLogger } from '../utils/logger.js'; + +export type SendToQueryCallback = (message: JSONRPCMessage) => Promise; + +export interface SdkControlServerTransportOptions { + sendToQuery: SendToQueryCallback; + serverName: string; +} + +export class SdkControlServerTransport { + sendToQuery: SendToQueryCallback; + private serverName: string; + private started = false; + private logger; + + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + constructor(options: SdkControlServerTransportOptions) { + this.sendToQuery = options.sendToQuery; + this.serverName = options.serverName; + this.logger = SdkLogger.createLogger( + `SdkControlServerTransport:${options.serverName}`, + ); + } + + async start(): Promise { + this.started = true; + this.logger.debug('Transport started'); + } + + async send(message: JSONRPCMessage): Promise { + if (!this.started) { + throw new Error( + `SdkControlServerTransport (${this.serverName}) not started. Call start() first.`, + ); + } + + try { + this.logger.debug('Sending message to Query', message); + await this.sendToQuery(message); + } catch (error) { + this.logger.error('Error sending message:', error); + if (this.onerror) { + this.onerror(error instanceof Error ? error : new Error(String(error))); + } + throw error; + } + } + + async close(): Promise { + if (!this.started) { + return; // Already closed + } + + this.started = false; + this.logger.debug('Transport closed'); + + // Notify MCP Server + if (this.onclose) { + this.onclose(); + } + } + + handleMessage(message: JSONRPCMessage): void { + if (!this.started) { + this.logger.warn('Received message for closed transport'); + return; + } + + this.logger.debug('Handling message from CLI', message); + if (this.onmessage) { + this.onmessage(message); + } else { + this.logger.warn('No onmessage handler set'); + } + } + + handleError(error: Error): void { + this.logger.error('Transport error:', error); + if (this.onerror) { + this.onerror(error); + } + } + + isStarted(): boolean { + return this.started; + } + + getServerName(): string { + return this.serverName; + } +} diff --git a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts new file mode 100644 index 00000000..cf2482d6 --- /dev/null +++ b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Factory function to create SDK-embedded MCP servers + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SdkMcpToolDefinition } from './tool.js'; +import { validateToolName } from './tool.js'; + +/** + * Options for creating an SDK MCP server + */ +export type CreateSdkMcpServerOptions = { + name: string; + version?: string; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + tools?: Array>; +}; + +/** + * SDK MCP Server configuration with instance + */ +export type McpSdkServerConfigWithInstance = { + type: 'sdk'; + name: string; + instance: McpServer; +}; + +/** + * Creates an MCP server instance that can be used with the SDK transport. + * + * @example + * ```typescript + * import { z } from 'zod'; + * import { tool, createSdkMcpServer } from '@qwen-code/sdk'; + * + * const calculatorTool = tool( + * 'calculate_sum', + * 'Add two numbers', + * { a: z.number(), b: z.number() }, + * async (args) => ({ content: [{ type: 'text', text: String(args.a + args.b) }] }) + * ); + * + * const server = createSdkMcpServer({ + * name: 'calculator', + * version: '1.0.0', + * tools: [calculatorTool], + * }); + * ``` + */ +export function createSdkMcpServer( + options: CreateSdkMcpServerOptions, +): McpSdkServerConfigWithInstance { + const { name, version = '1.0.0', tools } = options; + + if (!name || typeof name !== 'string') { + throw new Error('MCP server name must be a non-empty string'); + } + + if (!version || typeof version !== 'string') { + throw new Error('MCP server version must be a non-empty string'); + } + + if (tools !== undefined && !Array.isArray(tools)) { + throw new Error('Tools must be an array'); + } + + const toolNames = new Set(); + if (tools) { + for (const t of tools) { + validateToolName(t.name); + if (toolNames.has(t.name)) { + throw new Error( + `Duplicate tool name '${t.name}' in MCP server '${name}'`, + ); + } + toolNames.add(t.name); + } + } + + const server = new McpServer( + { name, version }, + { + capabilities: { + tools: tools ? {} : undefined, + }, + }, + ); + + if (tools) { + tools.forEach((toolDef) => { + server.tool( + toolDef.name, + toolDef.description, + toolDef.inputSchema, + toolDef.handler, + ); + }); + } + + return { type: 'sdk', name, instance: server }; +} diff --git a/packages/sdk-typescript/src/mcp/formatters.ts b/packages/sdk-typescript/src/mcp/formatters.ts new file mode 100644 index 00000000..a71e12ff --- /dev/null +++ b/packages/sdk-typescript/src/mcp/formatters.ts @@ -0,0 +1,194 @@ +/** + * Tool result formatting utilities for MCP responses + * + * Converts various output types to MCP content blocks. + */ + +export type McpContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; uri: string; mimeType?: string; text?: string }; + +export interface ToolResult { + content: McpContentBlock[]; + isError?: boolean; +} + +export function formatToolResult(result: unknown): ToolResult { + // Handle Error objects + if (result instanceof Error) { + return { + content: [ + { + type: 'text', + text: result.message || 'Unknown error', + }, + ], + isError: true, + }; + } + + // Handle null/undefined + if (result === null || result === undefined) { + return { + content: [ + { + type: 'text', + text: '', + }, + ], + }; + } + + // Handle string + if (typeof result === 'string') { + return { + content: [ + { + type: 'text', + text: result, + }, + ], + }; + } + + // Handle number + if (typeof result === 'number') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle boolean + if (typeof result === 'boolean') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle object (including arrays) + if (typeof result === 'object') { + try { + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch { + // JSON.stringify failed + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + } + + // Fallback: convert to string + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; +} + +export function formatToolError(error: Error | string): ToolResult { + const message = error instanceof Error ? error.message : error; + + return { + content: [ + { + type: 'text', + text: message, + }, + ], + isError: true, + }; +} + +export function formatTextResult(text: string): ToolResult { + return { + content: [ + { + type: 'text', + text, + }, + ], + }; +} + +export function formatJsonResult(data: unknown): ToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +export function mergeToolResults(results: ToolResult[]): ToolResult { + const mergedContent: McpContentBlock[] = []; + let hasError = false; + + for (const result of results) { + mergedContent.push(...result.content); + if (result.isError) { + hasError = true; + } + } + + return { + content: mergedContent, + isError: hasError, + }; +} + +export function isValidContentBlock(block: unknown): block is McpContentBlock { + if (!block || typeof block !== 'object') { + return false; + } + + const blockObj = block as Record; + + if (!blockObj.type || typeof blockObj.type !== 'string') { + return false; + } + + switch (blockObj.type) { + case 'text': + return typeof blockObj.text === 'string'; + + case 'image': + return ( + typeof blockObj.data === 'string' && + typeof blockObj.mimeType === 'string' + ); + + case 'resource': + return typeof blockObj.uri === 'string'; + + default: + return false; + } +} diff --git a/packages/sdk-typescript/src/mcp/tool.ts b/packages/sdk-typescript/src/mcp/tool.ts new file mode 100644 index 00000000..53e00399 --- /dev/null +++ b/packages/sdk-typescript/src/mcp/tool.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tool definition helper for SDK-embedded MCP servers + */ + +import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { z, ZodRawShape, ZodObject, ZodTypeAny } from 'zod'; + +type CallToolResult = z.infer; + +/** + * SDK MCP Tool Definition with Zod schema type inference + */ +export type SdkMcpToolDefinition = { + name: string; + description: string; + inputSchema: Schema; + handler: ( + args: z.infer>, + extra: unknown, + ) => Promise; +}; + +/** + * Create an SDK MCP tool definition with Zod schema inference + * + * @example + * ```typescript + * import { z } from 'zod'; + * import { tool } from '@qwen-code/sdk'; + * + * const calculatorTool = tool( + * 'calculate_sum', + * 'Calculate the sum of two numbers', + * { a: z.number(), b: z.number() }, + * async (args) => { + * // args is inferred as { a: number, b: number } + * return { content: [{ type: 'text', text: String(args.a + args.b) }] }; + * } + * ); + * ``` + */ +export function tool( + name: string, + description: string, + inputSchema: Schema, + handler: ( + args: z.infer>, + extra: unknown, + ) => Promise, +): SdkMcpToolDefinition { + if (!name || typeof name !== 'string') { + throw new Error('Tool name must be a non-empty string'); + } + + if (!description || typeof description !== 'string') { + throw new Error(`Tool '${name}' must have a description (string)`); + } + + if (!inputSchema || typeof inputSchema !== 'object') { + throw new Error(`Tool '${name}' must have an inputSchema (object)`); + } + + if (!handler || typeof handler !== 'function') { + throw new Error(`Tool '${name}' must have a handler (function)`); + } + + return { name, description, inputSchema, handler }; +} + +export function validateToolName(name: string): void { + if (!name) { + throw new Error('Tool name cannot be empty'); + } + + if (name.length > 64) { + throw new Error( + `Tool name '${name}' is too long (max 64 characters): ${name.length}`, + ); + } + + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) { + throw new Error( + `Tool name '${name}' is invalid. Must start with a letter and contain only letters, numbers, and underscores.`, + ); + } +} diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts new file mode 100644 index 00000000..78bb10b9 --- /dev/null +++ b/packages/sdk-typescript/src/query/Query.ts @@ -0,0 +1,880 @@ +/** + * Query class - Main orchestrator for SDK + * + * Manages SDK workflow, routes messages, and handles lifecycle. + * Implements AsyncIterator protocol for message consumption. + */ + +const DEFAULT_CAN_USE_TOOL_TIMEOUT = 60_000; +const DEFAULT_MCP_REQUEST_TIMEOUT = 60_000; +const DEFAULT_CONTROL_REQUEST_TIMEOUT = 60_000; +const DEFAULT_STREAM_CLOSE_TIMEOUT = 60_000; + +import { randomUUID } from 'node:crypto'; +import { SdkLogger } from '../utils/logger.js'; +import type { + SDKMessage, + SDKUserMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + PermissionSuggestion, + WireSDKMcpServerConfig, +} from '../types/protocol.js'; +import { + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, +} from '../types/protocol.js'; +import type { Transport } from '../transport/Transport.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { QueryOptions, CLIMcpServerConfig } from '../types/types.js'; +import { isSdkMcpServerConfig } from '../types/types.js'; +import { Stream } from '../utils/Stream.js'; +import { serializeJsonLine } from '../utils/jsonLines.js'; +import { AbortError } from '../types/errors.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { + SdkControlServerTransport, + type SdkControlServerTransportOptions, +} from '../mcp/SdkControlServerTransport.js'; +import { ControlRequestType } from '../types/protocol.js'; + +interface PendingControlRequest { + resolve: (response: Record | null) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + abortController: AbortController; +} + +interface PendingMcpResponse { + resolve: (response: JSONRPCMessage) => void; + reject: (error: Error) => void; +} + +interface TransportWithEndInput extends Transport { + endInput(): void; +} + +const logger = SdkLogger.createLogger('Query'); + +export class Query implements AsyncIterable { + private transport: Transport; + private options: QueryOptions; + private sessionId: string; + private inputStream: Stream; + private sdkMessages: AsyncGenerator; + private abortController: AbortController; + private pendingControlRequests: Map = + new Map(); + private pendingMcpResponses: Map = new Map(); + private sdkMcpTransports: Map = new Map(); + private sdkMcpServers: Map = new Map(); + readonly initialized: Promise; + private closed = false; + private messageRouterStarted = false; + + private firstResultReceivedPromise?: Promise; + private firstResultReceivedResolve?: () => void; + + private readonly isSingleTurn: boolean; + + constructor( + transport: Transport, + options: QueryOptions, + singleTurn: boolean = false, + ) { + this.transport = transport; + this.options = options; + this.sessionId = randomUUID(); + this.inputStream = new Stream(); + this.abortController = options.abortController ?? new AbortController(); + this.isSingleTurn = singleTurn; + + /** + * Create async generator proxy to ensure stream.next() is called at least once. + * The generator will start iterating when the user begins iteration. + * This ensures readResolve/readReject are set up as soon as iteration starts. + * If errors occur before iteration starts, they'll be stored in hasError and + * properly rejected when the user starts iterating. + */ + this.sdkMessages = this.readSdkMessages(); + + /** + * Promise that resolves when the first SDKResultMessage is received. + * Used to coordinate endInput() timing - ensures all initialization + * (SDK MCP servers, control responses) is complete before closing CLI stdin. + */ + this.firstResultReceivedPromise = new Promise((resolve) => { + this.firstResultReceivedResolve = resolve; + }); + + /** + * Handle abort signal if controller is provided and already aborted or will be aborted. + * If already aborted, set error immediately. Otherwise, listen for abort events + * and set abort error on the stream before closing. + */ + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted by user')); + this.close().catch((err) => { + logger.error('Error during abort cleanup:', err); + }); + } else { + this.abortController.signal.addEventListener('abort', () => { + this.inputStream.error(new AbortError('Query aborted by user')); + this.close().catch((err) => { + logger.error('Error during abort cleanup:', err); + }); + }); + } + + this.initialized = this.initialize(); + this.initialized.catch(() => {}); + + this.startMessageRouter(); + } + + private async initializeSdkMcpServers(): Promise { + if (!this.options.mcpServers) { + return; + } + + const connectionPromises: Array> = []; + + // Extract SDK MCP servers from the unified mcpServers config + for (const [key, config] of Object.entries(this.options.mcpServers)) { + if (!isSdkMcpServerConfig(config)) { + continue; // Skip external MCP servers + } + + // Use the name from SDKMcpServerConfig, fallback to key for backwards compatibility + const serverName = config.name || key; + const server = config.instance; + + // Create transport options with callback to route MCP server responses + const transportOptions: SdkControlServerTransportOptions = { + sendToQuery: async (message: JSONRPCMessage) => { + this.handleMcpServerResponse(serverName, message); + }, + serverName, + }; + + const sdkTransport = new SdkControlServerTransport(transportOptions); + + // Connect server to transport and only register on success + const connectionPromise = server + .connect(sdkTransport) + .then(() => { + // Only add to maps after successful connection + this.sdkMcpServers.set(serverName, server); + this.sdkMcpTransports.set(serverName, sdkTransport); + logger.debug(`SDK MCP server '${serverName}' connected to transport`); + }) + .catch((error) => { + logger.error( + `Failed to connect SDK MCP server '${serverName}' to transport:`, + error, + ); + // Don't throw - one failed server shouldn't prevent others + }); + + connectionPromises.push(connectionPromise); + } + + // Wait for all connection attempts to complete + await Promise.all(connectionPromises); + + if (this.sdkMcpServers.size > 0) { + logger.info( + `Initialized ${this.sdkMcpServers.size} SDK MCP server(s): ${Array.from(this.sdkMcpServers.keys()).join(', ')}`, + ); + } + } + + /** + * Handle response messages from SDK MCP servers + * + * When an MCP server sends a response via transport.send(), this callback + * routes it back to the pending request that's waiting for it. + */ + private handleMcpServerResponse( + serverName: string, + message: JSONRPCMessage, + ): void { + // Check if this is a response with an id + if ('id' in message && message.id !== null && message.id !== undefined) { + const key = `${serverName}:${message.id}`; + const pending = this.pendingMcpResponses.get(key); + if (pending) { + logger.debug( + `Routing MCP response for server '${serverName}', id: ${message.id}`, + ); + pending.resolve(message); + this.pendingMcpResponses.delete(key); + return; + } + } + + // If no pending request found, log a warning (this shouldn't happen normally) + logger.warn( + `Received MCP server response with no pending request: server='${serverName}'`, + message, + ); + } + + /** + * Get SDK MCP servers config for CLI initialization + * + * Only SDK servers are sent in the initialize request. + */ + private getSdkMcpServersForCli(): Record { + const sdkServers: Record = {}; + + for (const [name] of this.sdkMcpServers.entries()) { + sdkServers[name] = { type: 'sdk', name }; + } + + return sdkServers; + } + + /** + * Get external MCP servers (non-SDK) that should be managed by the CLI + */ + private getMcpServersForCli(): Record { + if (!this.options.mcpServers) { + return {}; + } + + const externalServers: Record = {}; + + for (const [name, config] of Object.entries(this.options.mcpServers)) { + if (isSdkMcpServerConfig(config)) { + continue; + } + externalServers[name] = config as CLIMcpServerConfig; + } + + return externalServers; + } + + private async initialize(): Promise { + try { + logger.debug('Initializing Query'); + + // Initialize SDK MCP servers and wait for connections + await this.initializeSdkMcpServers(); + + // Get only successfully connected SDK servers for CLI + const sdkMcpServersForCli = this.getSdkMcpServersForCli(); + const mcpServersForCli = this.getMcpServersForCli(); + logger.debug('SDK MCP servers for CLI:', sdkMcpServersForCli); + logger.debug('External MCP servers for CLI:', mcpServersForCli); + + await this.sendControlRequest(ControlRequestType.INITIALIZE, { + hooks: null, + sdkMcpServers: + Object.keys(sdkMcpServersForCli).length > 0 + ? sdkMcpServersForCli + : undefined, + mcpServers: + Object.keys(mcpServersForCli).length > 0 + ? mcpServersForCli + : undefined, + agents: this.options.agents, + }); + logger.info('Query initialized successfully'); + } catch (error) { + logger.error('Initialization error:', error); + throw error; + } + } + + private startMessageRouter(): void { + if (this.messageRouterStarted) { + return; + } + + this.messageRouterStarted = true; + + (async () => { + try { + for await (const message of this.transport.readMessages()) { + await this.routeMessage(message); + + if (this.closed) { + break; + } + } + + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted')); + } else { + this.inputStream.done(); + } + } catch (error) { + this.inputStream.error( + error instanceof Error ? error : new Error(String(error)), + ); + } + })(); + } + + private async routeMessage(message: unknown): Promise { + if (isControlRequest(message)) { + await this.handleControlRequest(message); + return; + } + + if (isControlResponse(message)) { + this.handleControlResponse(message); + return; + } + + if (isControlCancel(message)) { + this.handleControlCancelRequest(message); + return; + } + + if (isSDKSystemMessage(message)) { + /** + * SystemMessage contains session info (cwd, tools, model, etc.) + * that should be passed to user. + */ + this.inputStream.enqueue(message); + return; + } + + if (isSDKResultMessage(message)) { + if (this.firstResultReceivedResolve) { + this.firstResultReceivedResolve(); + } + /** + * In single-turn mode, automatically close input after receiving result + * to signal completion to the CLI. + */ + if (this.isSingleTurn && 'endInput' in this.transport) { + (this.transport as TransportWithEndInput).endInput(); + } + this.inputStream.enqueue(message); + return; + } + + if ( + isSDKAssistantMessage(message) || + isSDKUserMessage(message) || + isSDKPartialAssistantMessage(message) + ) { + this.inputStream.enqueue(message); + return; + } + + logger.warn('Unknown message type:', message); + this.inputStream.enqueue(message as SDKMessage); + } + + private async handleControlRequest( + request: CLIControlRequest, + ): Promise { + const { request_id, request: payload } = request; + + logger.debug(`Handling control request: ${payload.subtype}`); + const requestAbortController = new AbortController(); + + try { + let response: Record | null = null; + + switch (payload.subtype) { + case 'can_use_tool': + response = await this.handlePermissionRequest( + payload.tool_name, + payload.input as Record, + payload.permission_suggestions, + requestAbortController.signal, + ); + break; + + case 'mcp_message': + response = await this.handleMcpMessage( + payload.server_name, + payload.message as unknown as JSONRPCMessage, + ); + break; + + default: + throw new Error( + `Unknown control request subtype: ${payload.subtype}`, + ); + } + + await this.sendControlResponse(request_id, true, response); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.error(`Control request error (${payload.subtype}):`, errorMessage); + await this.sendControlResponse(request_id, false, errorMessage); + } + } + + private async handlePermissionRequest( + toolName: string, + toolInput: Record, + permissionSuggestions: PermissionSuggestion[] | null, + signal: AbortSignal, + ): Promise> { + /* Default deny all wildcard tool requests */ + if (!this.options.canUseTool) { + return { behavior: 'deny', message: 'Denied' }; + } + + try { + const canUseToolTimeout = + this.options.timeout?.canUseTool ?? DEFAULT_CAN_USE_TOOL_TIMEOUT; + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error('Permission callback timeout')), + canUseToolTimeout, + ); + }); + + const result = await Promise.race([ + Promise.resolve( + this.options.canUseTool(toolName, toolInput, { + signal, + suggestions: permissionSuggestions, + }), + ), + timeoutPromise, + ]); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (result.behavior === 'allow') { + return { + behavior: 'allow', + updatedInput: result.updatedInput ?? toolInput, + }; + } else { + return { + behavior: 'deny', + message: result.message ?? 'Denied', + ...(result.interrupt !== undefined + ? { interrupt: result.interrupt } + : {}), + }; + } + } catch (error) { + /** + * Timeout or error → deny (fail-safe). + * This ensures that any issues with the permission callback + * result in a safe default of denying access. + */ + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.warn( + 'Permission callback error (denying by default):', + errorMessage, + ); + return { + behavior: 'deny', + message: `Permission check failed: ${errorMessage}`, + }; + } + } + + private async handleMcpMessage( + serverName: string, + message: JSONRPCMessage, + ): Promise> { + const transport = this.sdkMcpTransports.get(serverName); + if (!transport) { + throw new Error( + `MCP server '${serverName}' not found in SDK-embedded servers`, + ); + } + + /** + * Check if this is a request (has method and id) or notification. + * Requests need to wait for a response, while notifications are just routed. + */ + const isRequest = + 'method' in message && 'id' in message && message.id !== null; + + if (isRequest) { + const response = await this.handleMcpRequest( + serverName, + message, + transport, + ); + return { mcp_response: response }; + } else { + transport.handleMessage(message); + return { mcp_response: { jsonrpc: '2.0', result: {}, id: 0 } }; + } + } + + private handleMcpRequest( + serverName: string, + message: JSONRPCMessage, + transport: SdkControlServerTransport, + ): Promise { + const messageId = 'id' in message ? message.id : null; + const key = `${serverName}:${messageId}`; + + return new Promise((resolve, reject) => { + const mcpRequestTimeout = + this.options.timeout?.mcpRequest ?? DEFAULT_MCP_REQUEST_TIMEOUT; + const timeout = setTimeout(() => { + this.pendingMcpResponses.delete(key); + reject(new Error('MCP request timeout')); + }, mcpRequestTimeout); + + const cleanup = () => { + clearTimeout(timeout); + this.pendingMcpResponses.delete(key); + }; + + const resolveAndCleanup = (response: JSONRPCMessage) => { + cleanup(); + resolve(response); + }; + + const rejectAndCleanup = (error: Error) => { + cleanup(); + reject(error); + }; + + // Register pending response handler + this.pendingMcpResponses.set(key, { + resolve: resolveAndCleanup, + reject: rejectAndCleanup, + }); + + // Deliver message to MCP server via transport.onmessage + // The server will process it and call transport.send() with the response, + // which triggers handleMcpServerResponse to resolve our pending promise + transport.handleMessage(message); + }); + } + + private handleControlResponse(response: CLIControlResponse): void { + const { response: payload } = response; + const request_id = payload.request_id; + + const pending = this.pendingControlRequests.get(request_id); + if (!pending) { + logger.warn( + 'Received response for unknown request:', + request_id, + JSON.stringify(payload), + ); + return; + } + + clearTimeout(pending.timeout); + this.pendingControlRequests.delete(request_id); + + if (payload.subtype === 'success') { + logger.debug( + `Control response success for request: ${request_id}: ${JSON.stringify(payload.response)}`, + ); + pending.resolve(payload.response as Record | null); + } else { + /** + * Extract error message from error field. + * Error can be either a string or an object with a message property. + */ + const errorMessage = + typeof payload.error === 'string' + ? payload.error + : (payload.error?.message ?? 'Unknown error'); + logger.error( + `Control response error for request ${request_id}:`, + errorMessage, + ); + pending.reject(new Error(errorMessage)); + } + } + + private handleControlCancelRequest(request: ControlCancelRequest): void { + const { request_id } = request; + + if (!request_id) { + logger.warn('Received cancel request without request_id'); + return; + } + + const pending = this.pendingControlRequests.get(request_id); + if (pending) { + logger.debug(`Cancelling control request: ${request_id}`); + pending.abortController.abort(); + clearTimeout(pending.timeout); + this.pendingControlRequests.delete(request_id); + pending.reject(new AbortError('Request cancelled')); + } + } + + private async sendControlRequest( + subtype: string, + data: Record = {}, + ): Promise | null> { + if (this.closed) { + return Promise.reject(new Error('Query is closed')); + } + + const requestId = randomUUID(); + + const request: CLIControlRequest = { + type: 'control_request', + request_id: requestId, + request: { + subtype: subtype as never, + ...data, + } as CLIControlRequest['request'], + }; + + const responsePromise = new Promise | null>( + (resolve, reject) => { + const abortController = new AbortController(); + const controlRequestTimeout = + this.options.timeout?.controlRequest ?? + DEFAULT_CONTROL_REQUEST_TIMEOUT; + const timeout = setTimeout(() => { + this.pendingControlRequests.delete(requestId); + reject(new Error(`Control request timeout: ${subtype}`)); + }, controlRequestTimeout); + + this.pendingControlRequests.set(requestId, { + resolve, + reject, + timeout, + abortController, + }); + }, + ); + + this.transport.write(serializeJsonLine(request)); + return responsePromise; + } + + private async sendControlResponse( + requestId: string, + success: boolean, + responseOrError: Record | null | string, + ): Promise { + const response: CLIControlResponse = { + type: 'control_response', + response: success + ? { + subtype: 'success', + request_id: requestId, + response: responseOrError as Record | null, + } + : { + subtype: 'error', + request_id: requestId, + error: responseOrError as string, + }, + }; + + this.transport.write(serializeJsonLine(response)); + } + + async close(): Promise { + if (this.closed) { + return; + } + + this.closed = true; + + for (const pending of this.pendingControlRequests.values()) { + pending.abortController.abort(); + clearTimeout(pending.timeout); + pending.reject(new Error('Query is closed')); + } + this.pendingControlRequests.clear(); + + // Clean up pending MCP responses + for (const pending of this.pendingMcpResponses.values()) { + pending.reject(new Error('Query is closed')); + } + this.pendingMcpResponses.clear(); + + await this.transport.close(); + + /** + * Complete input stream - check if aborted first. + * Only set error/done if stream doesn't already have an error state. + */ + if (this.inputStream.hasError === undefined) { + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted')); + } else { + this.inputStream.done(); + } + } + + for (const transport of this.sdkMcpTransports.values()) { + try { + await transport.close(); + } catch (error) { + logger.error('Error closing MCP transport:', error); + } + } + this.sdkMcpTransports.clear(); + logger.info('Query is closed'); + } + + private async *readSdkMessages(): AsyncGenerator { + for await (const message of this.inputStream) { + yield message; + } + } + + async next(...args: [] | [unknown]): Promise> { + return this.sdkMessages.next(...args); + } + + async return(value?: unknown): Promise> { + return this.sdkMessages.return(value); + } + + async throw(e?: unknown): Promise> { + return this.sdkMessages.throw(e); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this.sdkMessages; + } + + async streamInput(messages: AsyncIterable): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + try { + /** + * Wait for initialization to complete before sending messages. + * This prevents "write after end" errors when streamInput is called + * with an empty iterable before initialization finishes. + */ + await this.initialized; + + for await (const message of messages) { + if (this.abortController.signal.aborted) { + break; + } + this.transport.write(serializeJsonLine(message)); + } + + /** + * After all user messages are sent (for-await loop ended), determine when to + * close the CLI's stdin via endInput(). + * + * - If a result message was already received: All initialization (SDK MCP servers, + * control responses, etc.) is complete, safe to close stdin immediately. + * - If no result yet: Wait for either the result to arrive, or the timeout to expire. + * This gives pending control_responses from SDK MCP servers or other modules + * time to complete their initialization before we close the input stream. + * + * The timeout ensures we don't hang indefinitely - either the turn proceeds + * normally, or it fails with a timeout, but Promise.race will always resolve. + */ + if ( + !this.isSingleTurn && + this.sdkMcpTransports.size > 0 && + this.firstResultReceivedPromise + ) { + const streamCloseTimeout = + this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT; + let timeoutId: NodeJS.Timeout | undefined; + + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + logger.info('streamCloseTimeout resolved'); + resolve(); + }, streamCloseTimeout); + }); + + await Promise.race([this.firstResultReceivedPromise, timeoutPromise]); + + if (timeoutId) { + clearTimeout(timeoutId); + } + } + + this.endInput(); + } catch (error) { + if (this.abortController.signal.aborted) { + logger.info('Aborted during input streaming'); + this.inputStream.error( + new AbortError('Query aborted during input streaming'), + ); + return; + } + throw error; + } + } + + endInput(): void { + if (this.closed) { + throw new Error('Query is closed'); + } + + if ( + 'endInput' in this.transport && + typeof this.transport.endInput === 'function' + ) { + (this.transport as TransportWithEndInput).endInput(); + } + } + + async interrupt(): Promise { + await this.sendControlRequest(ControlRequestType.INTERRUPT); + } + + async setPermissionMode(mode: string): Promise { + await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, { + mode, + }); + } + + async setModel(model: string): Promise { + await this.sendControlRequest(ControlRequestType.SET_MODEL, { model }); + } + + /** + * Get list of control commands supported by the CLI + * + * @returns Promise resolving to list of supported command names + * @throws Error if query is closed + */ + async supportedCommands(): Promise | null> { + return this.sendControlRequest(ControlRequestType.SUPPORTED_COMMANDS); + } + + /** + * Get the status of MCP servers + * + * @returns Promise resolving to MCP server status information + * @throws Error if query is closed + */ + async mcpServerStatus(): Promise | null> { + return this.sendControlRequest(ControlRequestType.MCP_SERVER_STATUS); + } + + getSessionId(): string { + return this.sessionId; + } + + isClosed(): boolean { + return this.closed; + } +} diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts new file mode 100644 index 00000000..43ccf947 --- /dev/null +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -0,0 +1,120 @@ +/** + * Factory function for creating Query instances. + */ + +import type { SDKUserMessage } from '../types/protocol.js'; +import { serializeJsonLine } from '../utils/jsonLines.js'; +import { ProcessTransport } from '../transport/ProcessTransport.js'; +import { parseExecutableSpec } from '../utils/cliPath.js'; +import { Query } from './Query.js'; +import type { QueryOptions } from '../types/types.js'; +import { QueryOptionsSchema } from '../types/queryOptionsSchema.js'; +import { SdkLogger } from '../utils/logger.js'; + +export type { QueryOptions }; + +const logger = SdkLogger.createLogger('createQuery'); + +export function query({ + prompt, + options = {}, +}: { + /** + * The prompt to send to the Qwen Code CLI process. + * - `string` for single-turn query, + * - `AsyncIterable` for multi-turn query. + * + * The transport will remain open until the prompt is done. + */ + prompt: string | AsyncIterable; + /** + * Configuration options for the query. + */ + options?: QueryOptions; +}): Query { + const parsedExecutable = validateOptions(options); + + const isSingleTurn = typeof prompt === 'string'; + + const pathToQwenExecutable = + options.pathToQwenExecutable ?? parsedExecutable.executablePath; + + const abortController = options.abortController ?? new AbortController(); + + const transport = new ProcessTransport({ + pathToQwenExecutable, + cwd: options.cwd, + model: options.model, + permissionMode: options.permissionMode, + env: options.env, + abortController, + debug: options.debug, + stderr: options.stderr, + logLevel: options.logLevel, + maxSessionTurns: options.maxSessionTurns, + coreTools: options.coreTools, + excludeTools: options.excludeTools, + allowedTools: options.allowedTools, + authType: options.authType, + includePartialMessages: options.includePartialMessages, + }); + + const queryOptions: QueryOptions = { + ...options, + abortController, + }; + + const queryInstance = new Query(transport, queryOptions, isSingleTurn); + + if (isSingleTurn) { + const stringPrompt = prompt as string; + const message: SDKUserMessage = { + type: 'user', + session_id: queryInstance.getSessionId(), + message: { + role: 'user', + content: stringPrompt, + }, + parent_tool_use_id: null, + }; + + (async () => { + try { + await queryInstance.initialized; + transport.write(serializeJsonLine(message)); + } catch (err) { + logger.error('Error sending single-turn prompt:', err); + } + })(); + } else { + queryInstance + .streamInput(prompt as AsyncIterable) + .catch((err) => { + logger.error('Error streaming input:', err); + }); + } + + return queryInstance; +} + +function validateOptions( + options: QueryOptions, +): ReturnType { + const validationResult = QueryOptionsSchema.safeParse(options); + if (!validationResult.success) { + const errors = validationResult.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join('; '); + throw new Error(`Invalid QueryOptions: ${errors}`); + } + + let parsedExecutable: ReturnType; + try { + parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); + } + + return parsedExecutable; +} diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts new file mode 100644 index 00000000..c54d9104 --- /dev/null +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -0,0 +1,353 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import * as readline from 'node:readline'; +import type { Writable, Readable } from 'node:stream'; +import type { TransportOptions } from '../types/types.js'; +import type { Transport } from './Transport.js'; +import { parseJsonLinesStream } from '../utils/jsonLines.js'; +import { prepareSpawnInfo } from '../utils/cliPath.js'; +import { AbortError } from '../types/errors.js'; +import { SdkLogger } from '../utils/logger.js'; + +const logger = SdkLogger.createLogger('ProcessTransport'); + +export class ProcessTransport implements Transport { + private childProcess: ChildProcess | null = null; + private childStdin: Writable | null = null; + private childStdout: Readable | null = null; + private options: TransportOptions; + private ready = false; + private _exitError: Error | null = null; + private closed = false; + private abortController: AbortController; + private processExitHandler: (() => void) | null = null; + private abortHandler: (() => void) | null = null; + + constructor(options: TransportOptions) { + this.options = options; + this.abortController = + this.options.abortController ?? new AbortController(); + SdkLogger.configure({ + debug: options.debug, + stderr: options.stderr, + logLevel: options.logLevel, + }); + this.initialize(); + } + + private initialize(): void { + try { + if (this.abortController.signal.aborted) { + throw new AbortError('Transport start aborted'); + } + + const cliArgs = this.buildCliArguments(); + const cwd = this.options.cwd ?? process.cwd(); + const env = { ...process.env, ...this.options.env }; + + const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable); + + const stderrMode = + this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; + + logger.debug( + `Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`, + ); + + this.childProcess = spawn( + spawnInfo.command, + [...spawnInfo.args, ...cliArgs], + { + cwd, + env, + stdio: ['pipe', 'pipe', stderrMode], + signal: this.abortController.signal, + }, + ); + + this.childStdin = this.childProcess.stdin; + this.childStdout = this.childProcess.stdout; + + if (this.options.debug || this.options.stderr) { + this.childProcess.stderr?.on('data', (data) => { + logger.debug(data.toString()); + }); + } + + const cleanup = (): void => { + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGTERM'); + } + }; + + this.processExitHandler = cleanup; + this.abortHandler = cleanup; + process.on('exit', this.processExitHandler); + this.abortController.signal.addEventListener('abort', this.abortHandler); + + this.setupEventHandlers(); + + this.ready = true; + logger.info('CLI process started successfully'); + } catch (error) { + this.ready = false; + logger.error('Failed to initialize CLI process:', error); + throw error; + } + } + + private setupEventHandlers(): void { + if (!this.childProcess) return; + + this.childProcess.on('error', (error) => { + this.ready = false; + if (this.abortController.signal.aborted) { + this._exitError = new AbortError('CLI process aborted by user'); + } else { + this._exitError = new Error(`CLI process error: ${error.message}`); + logger.error(this._exitError.message); + } + }); + + this.childProcess.on('close', (code, signal) => { + this.ready = false; + if (this.abortController.signal.aborted) { + this._exitError = new AbortError('CLI process aborted by user'); + } else { + const error = this.getProcessExitError(code, signal); + if (error) { + this._exitError = error; + logger.error(error.message); + } + } + }); + } + + private getProcessExitError( + code: number | null, + signal: NodeJS.Signals | null, + ): Error | undefined { + if (code !== 0 && code !== null) { + return new Error(`CLI process exited with code ${code}`); + } else if (signal) { + return new Error(`CLI process terminated by signal ${signal}`); + } + return undefined; + } + private buildCliArguments(): string[] { + const args: string[] = [ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ]; + + if (this.options.model) { + args.push('--model', this.options.model); + } + + if (this.options.permissionMode) { + args.push('--approval-mode', this.options.permissionMode); + } + + if (this.options.maxSessionTurns !== undefined) { + args.push('--max-session-turns', String(this.options.maxSessionTurns)); + } + + if (this.options.coreTools && this.options.coreTools.length > 0) { + args.push('--core-tools', this.options.coreTools.join(',')); + } + + if (this.options.excludeTools && this.options.excludeTools.length > 0) { + args.push('--exclude-tools', this.options.excludeTools.join(',')); + } + + if (this.options.allowedTools && this.options.allowedTools.length > 0) { + args.push('--allowed-tools', this.options.allowedTools.join(',')); + } + + if (this.options.authType) { + args.push('--auth-type', this.options.authType); + } + + if (this.options.includePartialMessages) { + args.push('--include-partial-messages'); + } + + return args; + } + + async close(): Promise { + if (this.childStdin) { + this.childStdin.end(); + this.childStdin = null; + } + + if (this.processExitHandler) { + process.off('exit', this.processExitHandler); + this.processExitHandler = null; + } + + if (this.abortHandler) { + this.abortController.signal.removeEventListener( + 'abort', + this.abortHandler, + ); + this.abortHandler = null; + } + + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGTERM'); + setTimeout(() => { + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGKILL'); + } + }, 5000); + } + + this.ready = false; + this.closed = true; + } + + async waitForExit(): Promise { + if (!this.childProcess) { + if (this._exitError) { + throw this._exitError; + } + return; + } + + if (this.childProcess.exitCode !== null || this.childProcess.killed) { + if (this._exitError) { + throw this._exitError; + } + return; + } + + return new Promise((resolve, reject) => { + const exitHandler = ( + code: number | null, + signal: NodeJS.Signals | null, + ) => { + if (this.abortController.signal.aborted) { + reject(new AbortError('Operation aborted')); + return; + } + + const error = this.getProcessExitError(code, signal); + if (error) { + reject(error); + } else { + resolve(); + } + }; + + this.childProcess!.once('close', exitHandler); + + const errorHandler = (error: Error) => { + this.childProcess!.off('close', exitHandler); + reject(error); + }; + + this.childProcess!.once('error', errorHandler); + + this.childProcess!.once('close', () => { + this.childProcess!.off('error', errorHandler); + }); + }); + } + + write(message: string): void { + if (this.abortController.signal.aborted) { + throw new AbortError('Cannot write: operation aborted'); + } + + if (!this.ready || !this.childStdin) { + throw new Error('Transport not ready for writing'); + } + + if (this.closed) { + throw new Error('Cannot write to closed transport'); + } + + if (this.childStdin.writableEnded) { + throw new Error('Cannot write to ended stream'); + } + + if (this.childProcess?.killed || this.childProcess?.exitCode !== null) { + throw new Error('Cannot write to terminated process'); + } + + if (this._exitError) { + throw new Error( + `Cannot write to process that exited with error: ${this._exitError.message}`, + ); + } + + logger.debug( + `Writing to stdin (${message.length} bytes): ${message.trim()}`, + ); + + try { + const written = this.childStdin.write(message); + if (!written) { + logger.warn( + `Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, + ); + } else { + logger.debug(`Write successful (${message.length} bytes)`); + } + } catch (error) { + this.ready = false; + const errorMsg = `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`; + logger.error(errorMsg); + throw new Error(errorMsg); + } + } + + async *readMessages(): AsyncGenerator { + if (!this.childStdout) { + throw new Error('Cannot read messages: process not started'); + } + + const rl = readline.createInterface({ + input: this.childStdout, + crlfDelay: Infinity, + terminal: false, + }); + + try { + for await (const message of parseJsonLinesStream( + rl, + 'ProcessTransport', + )) { + yield message; + } + + await this.waitForExit(); + } finally { + rl.close(); + } + } + + get isReady(): boolean { + return this.ready; + } + + get exitError(): Error | null { + return this._exitError; + } + + endInput(): void { + if (this.childStdin) { + this.childStdin.end(); + } + } + + getInputStream(): Writable | undefined { + return this.childStdin || undefined; + } + + getOutputStream(): Readable | undefined { + return this.childStdout || undefined; + } +} diff --git a/packages/sdk-typescript/src/transport/Transport.ts b/packages/sdk-typescript/src/transport/Transport.ts new file mode 100644 index 00000000..cbfb1b7a --- /dev/null +++ b/packages/sdk-typescript/src/transport/Transport.ts @@ -0,0 +1,22 @@ +/** + * Transport interface for SDK-CLI communication + * + * The Transport abstraction enables communication between SDK and CLI via different mechanisms: + * - ProcessTransport: Local subprocess via stdin/stdout (initial implementation) + * - HttpTransport: Remote CLI via HTTP (future) + * - WebSocketTransport: Remote CLI via WebSocket (future) + */ + +export interface Transport { + close(): Promise; + + waitForExit(): Promise; + + write(message: string): void; + + readMessages(): AsyncGenerator; + + readonly isReady: boolean; + + readonly exitError: Error | null; +} diff --git a/packages/sdk-typescript/src/types/errors.ts b/packages/sdk-typescript/src/types/errors.ts new file mode 100644 index 00000000..21f503a6 --- /dev/null +++ b/packages/sdk-typescript/src/types/errors.ts @@ -0,0 +1,17 @@ +export class AbortError extends Error { + constructor(message = 'Operation aborted') { + super(message); + this.name = 'AbortError'; + Object.setPrototypeOf(this, AbortError.prototype); + } +} + +export function isAbortError(error: unknown): error is AbortError { + return ( + error instanceof AbortError || + (typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError') + ); +} diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts new file mode 100644 index 00000000..e5eeb121 --- /dev/null +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -0,0 +1,594 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +export interface Annotation { + type: string; + value: string; +} + +export interface Usage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + total_tokens?: number; +} + +export interface ExtendedUsage extends Usage { + server_tool_use?: { + web_search_requests: number; + }; + service_tier?: string; + cache_creation?: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + }; +} + +export interface ModelUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + webSearchRequests: number; + contextWindow: number; +} + +export interface CLIPermissionDenial { + tool_name: string; + tool_use_id: string; + tool_input: unknown; +} + +export interface TextBlock { + type: 'text'; + text: string; + annotations?: Annotation[]; +} + +export interface ThinkingBlock { + type: 'thinking'; + thinking: string; + signature?: string; + annotations?: Annotation[]; +} + +export interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: unknown; + annotations?: Annotation[]; +} + +export interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content?: string | ContentBlock[]; + is_error?: boolean; + annotations?: Annotation[]; +} + +export type ContentBlock = + | TextBlock + | ThinkingBlock + | ToolUseBlock + | ToolResultBlock; + +export interface APIUserMessage { + role: 'user'; + content: string | ContentBlock[]; +} + +export interface APIAssistantMessage { + id: string; + type: 'message'; + role: 'assistant'; + model: string; + content: ContentBlock[]; + stop_reason?: string | null; + usage: Usage; +} + +export interface SDKUserMessage { + type: 'user'; + uuid?: string; + session_id: string; + message: APIUserMessage; + parent_tool_use_id: string | null; + options?: Record; +} + +export interface SDKAssistantMessage { + type: 'assistant'; + uuid: string; + session_id: string; + message: APIAssistantMessage; + parent_tool_use_id: string | null; +} + +export interface SDKSystemMessage { + type: 'system'; + subtype: string; + uuid: string; + session_id: string; + data?: unknown; + cwd?: string; + tools?: string[]; + mcp_servers?: Array<{ + name: string; + status: string; + }>; + model?: string; + permission_mode?: string; + slash_commands?: string[]; + qwen_code_version?: string; + output_style?: string; + agents?: string[]; + skills?: string[]; + capabilities?: Record; + compact_metadata?: { + trigger: 'manual' | 'auto'; + pre_tokens: number; + }; +} + +export interface SDKResultMessageSuccess { + type: 'result'; + subtype: 'success'; + uuid: string; + session_id: string; + is_error: false; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + result: string; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + [key: string]: unknown; +} + +export interface SDKResultMessageError { + type: 'result'; + subtype: 'error_max_turns' | 'error_during_execution'; + uuid: string; + session_id: string; + is_error: true; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + error?: { + type?: string; + message: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type SDKResultMessage = SDKResultMessageSuccess | SDKResultMessageError; + +export interface MessageStartStreamEvent { + type: 'message_start'; + message: { + id: string; + role: 'assistant'; + model: string; + }; +} + +export interface ContentBlockStartEvent { + type: 'content_block_start'; + index: number; + content_block: ContentBlock; +} + +export type ContentBlockDelta = + | { + type: 'text_delta'; + text: string; + } + | { + type: 'thinking_delta'; + thinking: string; + } + | { + type: 'input_json_delta'; + partial_json: string; + }; + +export interface ContentBlockDeltaEvent { + type: 'content_block_delta'; + index: number; + delta: ContentBlockDelta; +} + +export interface ContentBlockStopEvent { + type: 'content_block_stop'; + index: number; +} + +export interface MessageStopStreamEvent { + type: 'message_stop'; +} + +export type StreamEvent = + | MessageStartStreamEvent + | ContentBlockStartEvent + | ContentBlockDeltaEvent + | ContentBlockStopEvent + | MessageStopStreamEvent; + +export interface SDKPartialAssistantMessage { + type: 'stream_event'; + uuid: string; + session_id: string; + event: StreamEvent; + parent_tool_use_id: string | null; +} + +export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo'; + +/** + * TODO: Align with `ToolCallConfirmationDetails` + */ +export interface PermissionSuggestion { + type: 'allow' | 'deny' | 'modify'; + label: string; + description?: string; + modifiedInput?: unknown; +} + +export interface HookRegistration { + event: string; + callback_id: string; +} + +export interface HookCallbackResult { + shouldSkip?: boolean; + shouldInterrupt?: boolean; + suppressOutput?: boolean; + message?: string; +} + +export interface CLIControlInterruptRequest { + subtype: 'interrupt'; +} + +export interface CLIControlPermissionRequest { + subtype: 'can_use_tool'; + tool_name: string; + tool_use_id: string; + input: unknown; + permission_suggestions: PermissionSuggestion[] | null; + blocked_path: string | null; +} + +export enum AuthProviderType { + DYNAMIC_DISCOVERY = 'dynamic_discovery', + GOOGLE_CREDENTIALS = 'google_credentials', + SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation', +} + +export interface MCPServerConfig { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + url?: string; + httpUrl?: string; + headers?: Record; + tcp?: string; + timeout?: number; + trust?: boolean; + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + extensionName?: string; + oauth?: Record; + authProviderType?: AuthProviderType; + targetAudience?: string; + targetServiceAccount?: string; +} + +/** + * SDK MCP Server configuration + * + * SDK MCP servers run in the SDK process and are connected via in-memory transport. + * Tool calls are routed through the control plane between SDK and CLI. + */ +export interface SDKMcpServerConfig { + /** + * Type identifier for SDK MCP servers + */ + type: 'sdk'; + /** + * Server name for identification and routing + */ + name: string; + /** + * The MCP Server instance created by createSdkMcpServer() + */ + instance: McpServer; +} + +/** + * Wire format for SDK MCP servers sent to the CLI + */ +export type WireSDKMcpServerConfig = Omit; + +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; + /** + * External MCP servers that should be managed by the CLI. + */ + mcpServers?: Record; + agents?: SubagentConfig[]; +} + +export interface CLIControlSetPermissionModeRequest { + subtype: 'set_permission_mode'; + mode: PermissionMode; +} + +export interface CLIHookCallbackRequest { + subtype: 'hook_callback'; + callback_id: string; + input: unknown; + tool_use_id: string | null; +} + +export interface CLIControlMcpMessageRequest { + subtype: 'mcp_message'; + server_name: string; + message: { + jsonrpc?: string; + method: string; + params?: Record; + id?: string | number | null; + }; +} + +export interface CLIControlSetModelRequest { + subtype: 'set_model'; + model: string; +} + +export interface CLIControlMcpStatusRequest { + subtype: 'mcp_server_status'; +} + +export interface CLIControlSupportedCommandsRequest { + subtype: 'supported_commands'; +} + +export type ControlRequestPayload = + | CLIControlInterruptRequest + | CLIControlPermissionRequest + | CLIControlInitializeRequest + | CLIControlSetPermissionModeRequest + | CLIHookCallbackRequest + | CLIControlMcpMessageRequest + | CLIControlSetModelRequest + | CLIControlMcpStatusRequest + | CLIControlSupportedCommandsRequest; + +export interface CLIControlRequest { + type: 'control_request'; + request_id: string; + request: ControlRequestPayload; +} + +export interface PermissionApproval { + allowed: boolean; + reason?: string; + modifiedInput?: unknown; +} + +export interface ControlResponse { + subtype: 'success'; + request_id: string; + response: unknown; +} + +export interface ControlErrorResponse { + subtype: 'error'; + request_id: string; + error: string | { message: string; [key: string]: unknown }; +} + +export interface CLIControlResponse { + type: 'control_response'; + response: ControlResponse | ControlErrorResponse; +} + +export interface ControlCancelRequest { + type: 'control_cancel_request'; + request_id?: string; +} + +export type ControlMessage = + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; + +/** + * Union of all SDK message types + */ +export type SDKMessage = + | SDKUserMessage + | SDKAssistantMessage + | SDKSystemMessage + | SDKResultMessage + | SDKPartialAssistantMessage; + +export function isSDKUserMessage(msg: any): msg is SDKUserMessage { + return ( + msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg + ); +} + +export function isSDKAssistantMessage(msg: any): msg is SDKAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'assistant' && + 'uuid' in msg && + 'message' in msg && + 'session_id' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isSDKSystemMessage(msg: any): msg is SDKSystemMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'system' && + 'subtype' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isSDKResultMessage(msg: any): msg is SDKResultMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'result' && + 'subtype' in msg && + 'duration_ms' in msg && + 'is_error' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isSDKPartialAssistantMessage( + msg: any, +): msg is SDKPartialAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'stream_event' && + 'uuid' in msg && + 'session_id' in msg && + 'event' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isControlRequest(msg: any): msg is CLIControlRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_request' && + 'request_id' in msg && + 'request' in msg + ); +} + +export function isControlResponse(msg: any): msg is CLIControlResponse { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_response' && + 'response' in msg + ); +} + +export function isControlCancel(msg: any): msg is ControlCancelRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_cancel_request' && + 'request_id' in msg + ); +} + +export function isTextBlock(block: any): block is TextBlock { + return block && typeof block === 'object' && block.type === 'text'; +} + +export function isThinkingBlock(block: any): block is ThinkingBlock { + return block && typeof block === 'object' && block.type === 'thinking'; +} + +export function isToolUseBlock(block: any): block is ToolUseBlock { + return block && typeof block === 'object' && block.type === 'tool_use'; +} + +export function isToolResultBlock(block: any): block is ToolResultBlock { + return block && typeof block === 'object' && block.type === 'tool_result'; +} + +export type SubagentLevel = 'session'; + +export interface ModelConfig { + model?: string; + temp?: number; + top_p?: number; +} + +export interface RunConfig { + max_time_minutes?: number; + max_turns?: number; +} + +export interface SubagentConfig { + name: string; + description: string; + tools?: string[]; + systemPrompt: string; + level: SubagentLevel; + filePath?: string; + modelConfig?: Partial; + runConfig?: Partial; + color?: string; + readonly isBuiltin?: boolean; +} + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Request Types + * + * Centralized enum for all control request subtypes supported by the CLI. + * This enum should be kept in sync with the controllers in: + * - packages/cli/src/services/control/controllers/systemController.ts + * - packages/cli/src/services/control/controllers/permissionController.ts + * - packages/cli/src/services/control/controllers/mcpController.ts + * - packages/cli/src/services/control/controllers/hookController.ts + */ +export enum ControlRequestType { + // SystemController requests + INITIALIZE = 'initialize', + INTERRUPT = 'interrupt', + SET_MODEL = 'set_model', + SUPPORTED_COMMANDS = 'supported_commands', + + // PermissionController requests + CAN_USE_TOOL = 'can_use_tool', + SET_PERMISSION_MODE = 'set_permission_mode', + + // MCPController requests + MCP_MESSAGE = 'mcp_message', + MCP_SERVER_STATUS = 'mcp_server_status', + + // HookController requests + HOOK_CALLBACK = 'hook_callback', +} diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts new file mode 100644 index 00000000..a4794b3f --- /dev/null +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -0,0 +1,169 @@ +import { z } from 'zod'; +import type { CanUseTool } from './types.js'; +import type { SubagentConfig } from './protocol.js'; + +/** + * OAuth configuration for MCP servers + */ +export const McpOAuthConfigSchema = z + .object({ + enabled: z.boolean().optional(), + clientId: z + .string() + .min(1, 'clientId must be a non-empty string') + .optional(), + clientSecret: z.string().optional(), + scopes: z.array(z.string()).optional(), + redirectUri: z.string().optional(), + authorizationUrl: z.string().optional(), + tokenUrl: z.string().optional(), + audiences: z.array(z.string()).optional(), + tokenParamName: z.string().optional(), + registrationUrl: z.string().optional(), + }) + .strict(); + +/** + * CLI MCP Server configuration schema + * + * Supports multiple transport types: + * - stdio: command, args, env, cwd + * - SSE: url + * - Streamable HTTP: httpUrl, headers + * - WebSocket: tcp + */ +export const CLIMcpServerConfigSchema = z.object({ + // For stdio transport + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + cwd: z.string().optional(), + // For SSE transport + url: z.string().optional(), + // For streamable HTTP transport + httpUrl: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + // For WebSocket transport + tcp: z.string().optional(), + // Common + timeout: z.number().optional(), + trust: z.boolean().optional(), + // Metadata + description: z.string().optional(), + includeTools: z.array(z.string()).optional(), + excludeTools: z.array(z.string()).optional(), + extensionName: z.string().optional(), + // OAuth configuration + oauth: McpOAuthConfigSchema.optional(), + authProviderType: z + .enum([ + 'dynamic_discovery', + 'google_credentials', + 'service_account_impersonation', + ]) + .optional(), + // Service Account Configuration + targetAudience: z.string().optional(), + targetServiceAccount: z.string().optional(), +}); + +/** + * SDK MCP Server configuration schema + */ +export const SdkMcpServerConfigSchema = z.object({ + type: z.literal('sdk'), + name: z.string().min(1, 'name must be a non-empty string'), + instance: z.custom<{ + connect(transport: unknown): Promise; + close(): Promise; + }>( + (val) => + val && + typeof val === 'object' && + 'connect' in val && + typeof val.connect === 'function', + { message: 'instance must be an MCP Server with connect method' }, + ), +}); + +/** + * Unified MCP Server configuration schema + */ +export const McpServerConfigSchema = z.union([ + CLIMcpServerConfigSchema, + SdkMcpServerConfigSchema, +]); + +export const ModelConfigSchema = z.object({ + model: z.string().optional(), + temp: z.number().optional(), + top_p: z.number().optional(), +}); + +export const RunConfigSchema = z.object({ + max_time_minutes: z.number().optional(), + max_turns: z.number().optional(), +}); + +export const SubagentConfigSchema = z.object({ + name: z.string().min(1, 'Name must be a non-empty string'), + description: z.string().min(1, 'Description must be a non-empty string'), + tools: z.array(z.string()).optional(), + systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'), + modelConfig: ModelConfigSchema.partial().optional(), + runConfig: RunConfigSchema.partial().optional(), + color: z.string().optional(), + isBuiltin: z.boolean().optional(), +}); + +export const TimeoutConfigSchema = z.object({ + canUseTool: z.number().positive().optional(), + mcpRequest: z.number().positive().optional(), + controlRequest: z.number().positive().optional(), + streamClose: z.number().positive().optional(), +}); + +export const QueryOptionsSchema = z + .object({ + cwd: z.string().optional(), + model: z.string().optional(), + pathToQwenExecutable: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + permissionMode: z.enum(['default', 'plan', 'auto-edit', 'yolo']).optional(), + canUseTool: z + .custom((val) => typeof val === 'function', { + message: 'canUseTool must be a function', + }) + .optional(), + mcpServers: z.record(z.string(), McpServerConfigSchema).optional(), + abortController: z.instanceof(AbortController).optional(), + debug: z.boolean().optional(), + stderr: z + .custom< + (message: string) => void + >((val) => typeof val === 'function', { message: 'stderr must be a function' }) + .optional(), + logLevel: z.enum(['debug', 'info', 'warn', 'error']).optional(), + maxSessionTurns: z.number().optional(), + coreTools: z.array(z.string()).optional(), + excludeTools: z.array(z.string()).optional(), + allowedTools: z.array(z.string()).optional(), + authType: z.enum(['openai', 'qwen-oauth']).optional(), + agents: z + .array( + z.custom( + (val) => + val && + typeof val === 'object' && + 'name' in val && + 'description' in val && + 'systemPrompt' in val && { + message: 'agents must be an array of SubagentConfig objects', + }, + ), + ) + .optional(), + includePartialMessages: z.boolean().optional(), + timeout: TimeoutConfigSchema.optional(), + }) + .strict(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts new file mode 100644 index 00000000..24dc0575 --- /dev/null +++ b/packages/sdk-typescript/src/types/types.ts @@ -0,0 +1,448 @@ +import type { + PermissionMode, + PermissionSuggestion, + SubagentConfig, + SDKMcpServerConfig, +} from './protocol.js'; + +export type { PermissionMode }; + +export type TransportOptions = { + pathToQwenExecutable: string; + cwd?: string; + model?: string; + permissionMode?: PermissionMode; + env?: Record; + abortController?: AbortController; + debug?: boolean; + stderr?: (message: string) => void; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + maxSessionTurns?: number; + coreTools?: string[]; + excludeTools?: string[]; + allowedTools?: string[]; + authType?: string; + includePartialMessages?: boolean; +}; + +type ToolInput = Record; + +export type CanUseTool = ( + toolName: string, + input: ToolInput, + options: { + signal: AbortSignal; + suggestions?: PermissionSuggestion[] | null; + }, +) => Promise; + +export type PermissionResult = + | { + behavior: 'allow'; + updatedInput: ToolInput; + } + | { + behavior: 'deny'; + message: string; + interrupt?: boolean; + }; + +/** + * OAuth configuration for MCP servers + */ +export interface McpOAuthConfig { + enabled?: boolean; + clientId?: string; + clientSecret?: string; + scopes?: string[]; + redirectUri?: string; + authorizationUrl?: string; + tokenUrl?: string; + audiences?: string[]; + tokenParamName?: string; + registrationUrl?: string; +} + +/** + * Auth provider type for MCP servers + */ +export type McpAuthProviderType = + | 'dynamic_discovery' + | 'google_credentials' + | 'service_account_impersonation'; + +/** + * CLI MCP Server configuration + * + * Supports multiple transport types: + * - stdio: command, args, env, cwd + * - SSE: url + * - Streamable HTTP: httpUrl, headers + * - WebSocket: tcp + * + * This interface aligns with MCPServerConfig in @qwen-code/qwen-code-core. + */ +export interface CLIMcpServerConfig { + // For stdio transport + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + // For SSE transport + url?: string; + // For streamable HTTP transport + httpUrl?: string; + headers?: Record; + // For WebSocket transport + tcp?: string; + // Common + timeout?: number; + trust?: boolean; + // Metadata + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + extensionName?: string; + // OAuth configuration + oauth?: McpOAuthConfig; + authProviderType?: McpAuthProviderType; + // Service Account Configuration + /** targetAudience format: CLIENT_ID.apps.googleusercontent.com */ + targetAudience?: string; + /** targetServiceAccount format: @.iam.gserviceaccount.com */ + targetServiceAccount?: string; +} + +/** + * Unified MCP Server configuration + * + * Supports both external MCP servers (stdio/SSE/HTTP/WebSocket) and SDK-embedded MCP servers. + * + * @example External MCP server (stdio) + * ```typescript + * mcpServers: { + * 'my-server': { command: 'node', args: ['server.js'] } + * } + * ``` + * + * @example External MCP server (SSE) + * ```typescript + * mcpServers: { + * 'remote-server': { url: 'http://localhost:3000/sse' } + * } + * ``` + * + * @example External MCP server (Streamable HTTP) + * ```typescript + * mcpServers: { + * 'http-server': { httpUrl: 'http://localhost:3000/mcp', headers: { 'Authorization': 'Bearer token' } } + * } + * ``` + * + * @example SDK MCP server + * ```typescript + * const server = createSdkMcpServer('weather', '1.0.0', [weatherTool]); + * mcpServers: { + * 'weather': { type: 'sdk', name: 'weather', instance: server } + * } + * ``` + */ +export type McpServerConfig = CLIMcpServerConfig | SDKMcpServerConfig; + +/** + * Type guard to check if a config is an SDK MCP server + */ +export function isSdkMcpServerConfig( + config: McpServerConfig, +): config is SDKMcpServerConfig { + return 'type' in config && config.type === 'sdk'; +} + +/** + * Configuration options for creating a query session with the Qwen CLI. + */ +export interface QueryOptions { + /** + * The working directory for the query session. + * This determines the context in which file operations and commands are executed. + * @default process.cwd() + */ + cwd?: string; + + /** + * The AI model to use for the query session. + * This takes precedence over the environment variables `OPENAI_MODEL` and `QWEN_MODEL` + * @example 'qwen-max', 'qwen-plus', 'qwen-turbo' + */ + model?: string; + + /** + * Path to the Qwen CLI executable or runtime specification. + * + * Supports multiple formats: + * - 'qwen' -> native binary (auto-detected from PATH) + * - '/path/to/qwen' -> native binary (explicit path) + * - '/path/to/cli.js' -> Node.js bundle (default for .js files) + * - '/path/to/index.ts' -> TypeScript source (requires tsx) + * - 'bun:/path/to/cli.js' -> Force Bun runtime + * - 'node:/path/to/cli.js' -> Force Node.js runtime + * - 'tsx:/path/to/index.ts' -> Force tsx runtime + * - 'deno:/path/to/cli.ts' -> Force Deno runtime + * + * If not provided, the SDK will auto-detect the native binary in this order: + * 1. QWEN_CODE_CLI_PATH environment variable + * 2. ~/.volta/bin/qwen + * 3. ~/.npm-global/bin/qwen + * 4. /usr/local/bin/qwen + * 5. ~/.local/bin/qwen + * 6. ~/node_modules/.bin/qwen + * 7. ~/.yarn/bin/qwen + * + * The .ts files are only supported for debugging purposes. + * + * @example 'qwen' + * @example '/usr/local/bin/qwen' + * @example 'tsx:/path/to/packages/cli/src/index.ts' + */ + pathToQwenExecutable?: string; + + /** + * Environment variables to pass to the Qwen CLI process. + * These variables will be merged with the current process environment. + */ + env?: Record; + + /** + * Permission mode controlling how the SDK handles tool execution approval. + * + * - 'default': Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. + * Read-only tools execute without confirmation. + * - 'plan': Blocks all write tools, instructing AI to present a plan first. + * Read-only tools execute normally. + * - 'auto-edit': Auto-approve edit tools (edit, write_file) while other tools require confirmation. + * - 'yolo': All tools execute automatically without confirmation. + * + * **Priority Chain (highest to lowest):** + * 1. `excludeTools` - Blocks tools completely (returns permission error) + * 2. `permissionMode: 'plan'` - Blocks non-read-only tools (except exit_plan_mode) + * 3. `permissionMode: 'yolo'` - Auto-approves all tools + * 4. `allowedTools` - Auto-approves matching tools + * 5. `canUseTool` callback - Custom approval logic + * 6. Default behavior - Auto-deny in SDK mode + * + * @default 'default' + * @see canUseTool For custom permission handling + * @see allowedTools For auto-approving specific tools + * @see excludeTools For blocking specific tools + */ + permissionMode?: 'default' | 'plan' | 'auto-edit' | 'yolo'; + + /** + * Custom permission handler for tool execution approval. + * + * This callback is invoked when a tool requires confirmation and allows you to + * programmatically approve or deny execution. It acts as a fallback after + * `allowedTools` check but before default denial. + * + * **When is this called?** + * - Only for tools requiring confirmation (write operations, shell commands, etc.) + * - After `excludeTools` and `allowedTools` checks + * - Not called in 'yolo' mode or 'plan' mode + * - Not called for tools already in `allowedTools` + * + * **Usage with permissionMode:** + * - 'default': Invoked for all write tools not in `allowedTools`; if not provided, auto-denied. + * - 'auto-edit': Invoked for non-edit tools (edit/write_file auto-approved); if not provided, auto-denied. + * - 'plan': Not invoked; write tools are blocked by plan mode. + * - 'yolo': Not invoked; all tools auto-approved. + * + * @see allowedTools For auto-approving tools without callback + */ + canUseTool?: CanUseTool; + + /** + * MCP (Model Context Protocol) servers to connect to. + * + * Supports both external MCP servers and SDK-embedded MCP servers: + * + * **External MCP servers** - Run in separate processes, connected via stdio/SSE/HTTP: + * ```typescript + * mcpServers: { + * 'stdio-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } }, + * 'sse-server': { url: 'http://localhost:3000/sse' }, + * 'http-server': { httpUrl: 'http://localhost:3000/mcp' } + * } + * ``` + * + * **SDK MCP servers** - Run in the SDK process, connected via in-memory transport: + * ```typescript + * const myTool = tool({ + * name: 'my_tool', + * description: 'My custom tool', + * inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + * handler: async (input) => ({ result: input.input.toUpperCase() }), + * }); + * + * const server = createSdkMcpServer('my-server', '1.0.0', [myTool]); + * + * mcpServers: { + * 'my-server': { type: 'sdk', name: 'my-server', instance: server } + * } + * ``` + */ + mcpServers?: Record; + + /** + * AbortController to cancel the query session. + * Call abortController.abort() to terminate the session and cleanup resources. + * Remember to handle the AbortError when the session is aborted. + */ + abortController?: AbortController; + + /** + * Enable debug mode for verbose logging. + * When true, additional diagnostic information will be output. + * Use this with `logLevel` to control the verbosity of the logs. + * @default false + */ + debug?: boolean; + + /** + * Custom handler for stderr output from the Qwen CLI process. + * Use this to capture and process error messages or diagnostic output. + */ + stderr?: (message: string) => void; + + /** + * Logging level for the SDK. + * Controls the verbosity of log messages output by the SDK. + * @default 'error' + */ + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + + /** + * Maximum number of conversation turns before the session automatically terminates. + * A turn consists of a user message and an assistant response. + * @default -1 (unlimited) + */ + maxSessionTurns?: number; + + /** + * Equivalent to `tool.core` in settings.json. + * List of core tools to enable for the session. + * If specified, only these tools will be available to the AI. + * @example ['read_file', 'write_file', 'run_terminal_cmd'] + */ + coreTools?: string[]; + + /** + * Equivalent to `tool.exclude` in settings.json. + * List of tools to exclude from the session. + * + * **Behavior:** + * - Excluded tools return a permission error immediately when invoked + * - Takes highest priority - overrides all other permission settings + * - Tools will not be available to the AI, even if in `coreTools` or `allowedTools` + * + * **Pattern matching:** + * - Tool name: `'write_file'`, `'run_shell_command'` + * - Tool class: `'WriteTool'`, `'ShellTool'` + * - Shell command prefix: `'ShellTool(git commit)'` (matches commands starting with "git commit") + * + * @example ['run_terminal_cmd', 'delete_file', 'ShellTool(rm )'] + * @see allowedTools For allowing specific tools + */ + excludeTools?: string[]; + + /** + * Equivalent to `tool.allowed` in settings.json. + * List of tools that are allowed to run without confirmation. + * + * **Behavior:** + * - Matching tools bypass `canUseTool` callback and execute automatically + * - Only applies when tool requires confirmation (write operations, shell commands) + * - Checked after `excludeTools` but before `canUseTool` callback + * - Does not override `permissionMode: 'plan'` (plan mode blocks all write tools) + * - Has no effect in `permissionMode: 'yolo'` (already auto-approved) + * + * **Pattern matching:** + * - Tool name: `'write_file'`, `'run_shell_command'` + * - Tool class: `'WriteTool'`, `'ShellTool'` + * - Shell command prefix: `'ShellTool(git status)'` (matches commands starting with "git status") + * + * **Use cases:** + * - Auto-approve safe shell commands: `['ShellTool(git status)', 'ShellTool(ls)']` + * - Auto-approve specific tools: `['write_file', 'edit']` + * - Combine with `permissionMode: 'default'` to selectively auto-approve tools + * + * @example ['read_file', 'ShellTool(git status)', 'ShellTool(npm test)'] + * @see canUseTool For custom approval logic + * @see excludeTools For blocking specific tools + */ + allowedTools?: string[]; + + /** + * Authentication type for the AI service. + * - 'openai': Use OpenAI-compatible authentication + * - 'qwen-oauth': Use Qwen OAuth authentication + * + * Though we support 'qwen-oauth', it's not recommended to use it in the SDK. + * Because the credentials are stored in `~/.qwen` and may need to refresh periodically. + */ + authType?: 'openai' | 'qwen-oauth'; + + /** + * Configuration for subagents that can be invoked during the session. + * Subagents are specialized AI agents that can handle specific tasks or domains. + * The invocation is marked as a `task` tool use with the name of agent and a tool_use_id. + * The tool use of these agent is marked with the parent_tool_use_id of the `task` tool use. + */ + agents?: SubagentConfig[]; + + /** + * Include partial messages in the response stream. + * When true, the SDK will emit incomplete messages as they are being generated, + * allowing for real-time streaming of the AI's response. + * @default false + */ + includePartialMessages?: boolean; + + /** + * Timeout configuration for various SDK operations. + * All values are in milliseconds. + */ + timeout?: { + /** + * Timeout for the `canUseTool` callback. + * If the callback doesn't resolve within this time, the permission request + * will be denied with a timeout error (fail-safe behavior). + * @default 60000 (1 minute) + */ + canUseTool?: number; + + /** + * Timeout for SDK MCP tool calls. + * This applies to tool calls made to SDK-embedded MCP servers. + * @default 60000 (1 minute) + */ + mcpRequest?: number; + + /** + * Timeout for SDK→CLI control requests. + * This applies to internal control operations like initialize, interrupt, + * setPermissionMode, setModel, etc. + * @default 60000 (1 minute) + */ + controlRequest?: number; + + /** + * Timeout for waiting before closing CLI's stdin after user messages are sent. + * In multi-turn mode with SDK MCP servers, after all user messages are processed, + * the SDK waits for the first result message to ensure all initialization + * (control responses, MCP server setup, etc.) is complete before closing stdin. + * This timeout is a fallback to avoid hanging indefinitely. + * @default 60000 (1 minute) + */ + streamClose?: number; + }; +} diff --git a/packages/sdk-typescript/src/utils/Stream.ts b/packages/sdk-typescript/src/utils/Stream.ts new file mode 100644 index 00000000..70caf82e --- /dev/null +++ b/packages/sdk-typescript/src/utils/Stream.ts @@ -0,0 +1,79 @@ +export class Stream implements AsyncIterable { + private returned: (() => void) | undefined; + private queue: T[] = []; + private readResolve: ((result: IteratorResult) => void) | undefined; + private readReject: ((error: Error) => void) | undefined; + private isDone = false; + hasError: Error | undefined; + private started = false; + + constructor(returned?: () => void) { + this.returned = returned; + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.started) { + throw new Error('Stream can only be iterated once'); + } + this.started = true; + return this; + } + + async next(): Promise> { + if (this.queue.length > 0) { + return Promise.resolve({ + done: false, + value: this.queue.shift()!, + }); + } + if (this.isDone) { + return Promise.resolve({ done: true, value: undefined }); + } + if (this.hasError) { + return Promise.reject(this.hasError); + } + return new Promise>((resolve, reject) => { + this.readResolve = resolve; + this.readReject = reject; + }); + } + + enqueue(value: T): void { + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = undefined; + this.readReject = undefined; + resolve({ done: false, value }); + } else { + this.queue.push(value); + } + } + + done(): void { + this.isDone = true; + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = undefined; + this.readReject = undefined; + resolve({ done: true, value: undefined }); + } + } + + error(error: Error): void { + this.hasError = error; + if (this.readReject) { + const reject = this.readReject; + this.readResolve = undefined; + this.readReject = undefined; + reject(error); + } + } + + return(): Promise> { + this.isDone = true; + if (this.returned) { + this.returned(); + } + return Promise.resolve({ done: true, value: undefined }); + } +} diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts new file mode 100644 index 00000000..2d919413 --- /dev/null +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -0,0 +1,344 @@ +/** + * CLI path auto-detection and subprocess spawning utilities + * + * Supports multiple execution modes: + * 1. Native binary: 'qwen' (production) + * 2. Node.js bundle: 'node /path/to/cli.js' (production validation) + * 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime) + * 4. TypeScript source: 'tsx /path/to/index.ts' (development) + * + * Auto-detection locations for native binary: + * 1. QWEN_CODE_CLI_PATH environment variable + * 2. ~/.volta/bin/qwen + * 3. ~/.npm-global/bin/qwen + * 4. /usr/local/bin/qwen + * 5. ~/.local/bin/qwen + * 6. ~/node_modules/.bin/qwen + * 7. ~/.yarn/bin/qwen + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; + +/** + * Executable types supported by the SDK + */ +export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno'; + +/** + * Spawn information for CLI process + */ +export type SpawnInfo = { + /** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */ + command: string; + /** Arguments to pass to command */ + args: string[]; + /** Type of executable detected */ + type: ExecutableType; + /** Original input that was resolved */ + originalInput: string; +}; + +export function findNativeCliPath(): string { + const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; + + const candidates: Array = [ + // 1. Environment variable (highest priority) + process.env['QWEN_CODE_CLI_PATH'], + + // 2. Volta bin + path.join(homeDir, '.volta', 'bin', 'qwen'), + + // 3. Global npm installations + path.join(homeDir, '.npm-global', 'bin', 'qwen'), + + // 4. Common Unix binary locations + '/usr/local/bin/qwen', + + // 5. User local bin + path.join(homeDir, '.local', 'bin', 'qwen'), + + // 6. Node modules bin in home directory + path.join(homeDir, 'node_modules', '.bin', 'qwen'), + + // 7. Yarn global bin + path.join(homeDir, '.yarn', 'bin', 'qwen'), + ]; + + // Find first existing candidate + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return path.resolve(candidate); + } + } + + // Not found - throw helpful error + throw new Error( + 'qwen CLI not found. Please:\n' + + ' 1. Install qwen globally: npm install -g qwen\n' + + ' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' + + ' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' + + '\n' + + 'For development/testing, you can also use:\n' + + ' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' + + ' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' + + ' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })', + ); +} + +function isCommandAvailable(command: string): boolean { + try { + // Use 'which' on Unix-like systems, 'where' on Windows + const whichCommand = process.platform === 'win32' ? 'where' : 'which'; + execSync(`${whichCommand} ${command}`, { + stdio: 'ignore', + timeout: 5000, // 5 second timeout + }); + return true; + } catch { + return false; + } +} + +function validateRuntimeAvailability(runtime: string): boolean { + // Node.js is always available since we're running in Node.js + if (runtime === 'node') { + return true; + } + + // Check if the runtime command is available in PATH + return isCommandAvailable(runtime); +} + +function validateFileExtensionForRuntime( + filePath: string, + runtime: string, +): boolean { + const ext = path.extname(filePath).toLowerCase(); + + switch (runtime) { + case 'node': + case 'bun': + return ['.js', '.mjs', '.cjs'].includes(ext); + case 'tsx': + return ['.ts', '.tsx'].includes(ext); + case 'deno': + return ['.ts', '.tsx', '.js', '.mjs'].includes(ext); + default: + return true; // Unknown runtime, let it pass + } +} + +/** + * Parse executable specification into components with comprehensive validation + * + * Supports multiple formats: + * - 'qwen' -> native binary (auto-detected) + * - '/path/to/qwen' -> native binary (explicit path) + * - '/path/to/cli.js' -> Node.js bundle (default for .js files) + * - '/path/to/index.ts' -> TypeScript source (requires tsx) + * + * Advanced runtime specification (for overriding defaults): + * - 'bun:/path/to/cli.js' -> Force Bun runtime + * - 'node:/path/to/cli.js' -> Force Node.js runtime + * - 'tsx:/path/to/index.ts' -> Force tsx runtime + * - 'deno:/path/to/cli.ts' -> Force Deno runtime + * + * @param executableSpec - Executable specification + * @returns Parsed executable information + * @throws Error if specification is invalid or files don't exist + */ +export function parseExecutableSpec(executableSpec?: string): { + runtime?: string; + executablePath: string; + isExplicitRuntime: boolean; +} { + if ( + executableSpec === '' || + (executableSpec && executableSpec.trim() === '') + ) { + throw new Error('Command name cannot be empty'); + } + + if (!executableSpec) { + return { + executablePath: findNativeCliPath(), + isExplicitRuntime: false, + }; + } + + // Check for runtime prefix (e.g., 'bun:/path/to/cli.js') + const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/); + if (runtimeMatch) { + const [, runtime, filePath] = runtimeMatch; + if (!runtime || !filePath) { + throw new Error(`Invalid runtime specification: '${executableSpec}'`); + } + + const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; + if (!supportedRuntimes.includes(runtime)) { + throw new Error( + `Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`, + ); + } + + if (!validateRuntimeAvailability(runtime)) { + throw new Error( + `Runtime '${runtime}' is not available on this system. Please install it first.`, + ); + } + + const resolvedPath = path.resolve(filePath); + + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + + 'Please check the file path and ensure the file exists.', + ); + } + + if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { + const ext = path.extname(resolvedPath); + throw new Error( + `File extension '${ext}' is not compatible with runtime '${runtime}'. ` + + `Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`, + ); + } + + return { + runtime, + executablePath: resolvedPath, + isExplicitRuntime: true, + }; + } + + // Check if it's a command name (no path separators) or a file path + const isCommandName = + !executableSpec.includes('/') && !executableSpec.includes('\\'); + + if (isCommandName) { + // It's a command name like 'qwen' - validate it's a reasonable command name + if (!executableSpec || executableSpec.trim() === '') { + throw new Error('Command name cannot be empty'); + } + + // Basic validation for command names + if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) { + throw new Error( + `Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`, + ); + } + + return { + executablePath: executableSpec, + isExplicitRuntime: false, + }; + } + + // It's a file path - validate and resolve + const resolvedPath = path.resolve(executableSpec); + + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}'. ` + + 'Please check the file path and ensure the file exists. ' + + 'You can also:\n' + + ' • Set QWEN_CODE_CLI_PATH environment variable\n' + + ' • Install qwen globally: npm install -g qwen\n' + + ' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' + + ' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + ); + } + + // Additional validation for file paths + const stats = fs.statSync(resolvedPath); + if (!stats.isFile()) { + throw new Error( + `Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`, + ); + } + + return { + executablePath: resolvedPath, + isExplicitRuntime: false, + }; +} + +function getExpectedExtensions(runtime: string): string[] { + switch (runtime) { + case 'node': + case 'bun': + return ['.js', '.mjs', '.cjs']; + case 'tsx': + return ['.ts', '.tsx']; + case 'deno': + return ['.ts', '.tsx', '.js', '.mjs']; + default: + return []; + } +} + +function detectRuntimeFromExtension(filePath: string): string | undefined { + const ext = path.extname(filePath).toLowerCase(); + + if (['.js', '.mjs', '.cjs'].includes(ext)) { + // Default to Node.js for JavaScript files + return 'node'; + } + + if (['.ts', '.tsx'].includes(ext)) { + // Check if tsx is available for TypeScript files + if (isCommandAvailable('tsx')) { + return 'tsx'; + } + // If tsx is not available, suggest it in error message + throw new Error( + `TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` + + 'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts', + ); + } + + // Native executable or unknown extension + return undefined; +} + +export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { + const parsed = parseExecutableSpec(executableSpec); + const { runtime, executablePath, isExplicitRuntime } = parsed; + + // If runtime is explicitly specified, use it + if (isExplicitRuntime && runtime) { + const runtimeCommand = runtime === 'node' ? process.execPath : runtime; + + return { + command: runtimeCommand, + args: [executablePath], + type: runtime as ExecutableType, + originalInput: executableSpec || '', + }; + } + + // If no explicit runtime, try to detect from file extension + const detectedRuntime = detectRuntimeFromExtension(executablePath); + + if (detectedRuntime) { + const runtimeCommand = + detectedRuntime === 'node' ? process.execPath : detectedRuntime; + + return { + command: runtimeCommand, + args: [executablePath], + type: detectedRuntime as ExecutableType, + originalInput: executableSpec || '', + }; + } + + // Native executable or command name - use it directly + return { + command: executablePath, + args: [], + type: 'native', + originalInput: executableSpec || '', + }; +} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts new file mode 100644 index 00000000..8af8ec6a --- /dev/null +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -0,0 +1,65 @@ +import { SdkLogger } from './logger.js'; + +export function serializeJsonLine(message: unknown): string { + try { + return JSON.stringify(message) + '\n'; + } catch (error) { + throw new Error( + `Failed to serialize message to JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export function parseJsonLineSafe( + line: string, + context = 'JsonLines', +): unknown | null { + const logger = SdkLogger.createLogger(context); + try { + return JSON.parse(line); + } catch (error) { + logger.warn( + 'Failed to parse JSON line, skipping:', + line.substring(0, 100), + error instanceof Error ? error.message : String(error), + ); + return null; + } +} + +export function isValidMessage(message: unknown): boolean { + return ( + message !== null && + typeof message === 'object' && + 'type' in message && + typeof (message as { type: unknown }).type === 'string' + ); +} + +export async function* parseJsonLinesStream( + lines: AsyncIterable, + context = 'JsonLines', +): AsyncGenerator { + const logger = SdkLogger.createLogger(context); + for await (const line of lines) { + if (line.trim().length === 0) { + continue; + } + + const message = parseJsonLineSafe(line, context); + + if (message === null) { + continue; + } + + if (!isValidMessage(message)) { + logger.warn( + "Invalid message structure (missing 'type' field), skipping:", + line.substring(0, 100), + ); + continue; + } + + yield message; + } +} diff --git a/packages/sdk-typescript/src/utils/logger.ts b/packages/sdk-typescript/src/utils/logger.ts new file mode 100644 index 00000000..caf57ede --- /dev/null +++ b/packages/sdk-typescript/src/utils/logger.ts @@ -0,0 +1,147 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LoggerConfig { + debug?: boolean; + stderr?: (message: string) => void; + logLevel?: LogLevel; +} + +export interface ScopedLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +export class SdkLogger { + private static config: LoggerConfig = {}; + private static effectiveLevel: LogLevel = 'error'; + + static configure(config: LoggerConfig): void { + this.config = config; + this.effectiveLevel = this.determineLogLevel(); + } + + private static determineLogLevel(): LogLevel { + if (this.config.logLevel) { + return this.config.logLevel; + } + + if (this.config.debug) { + return 'debug'; + } + + const envLevel = process.env['DEBUG_QWEN_CODE_SDK_LEVEL']; + if (envLevel && this.isValidLogLevel(envLevel)) { + return envLevel as LogLevel; + } + + if (process.env['DEBUG_QWEN_CODE_SDK']) { + return 'debug'; + } + + return 'error'; + } + + private static isValidLogLevel(level: string): boolean { + return ['debug', 'info', 'warn', 'error'].includes(level); + } + + private static shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.effectiveLevel]; + } + + private static formatTimestamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + } + + private static formatMessage( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): string { + const timestamp = this.formatTimestamp(); + const levelStr = `[${level.toUpperCase()}]`.padEnd(7); + let fullMessage = `${timestamp} ${levelStr} [${scope}] ${message}`; + + if (args.length > 0) { + const argsStr = args + .map((arg) => { + if (typeof arg === 'string') { + return arg; + } + if (arg instanceof Error) { + return arg.message; + } + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }) + .join(' '); + fullMessage += ` ${argsStr}`; + } + + return fullMessage; + } + + private static log( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): void { + if (!this.shouldLog(level)) { + return; + } + + const formattedMessage = this.formatMessage(level, scope, message, args); + + if (this.config.stderr) { + this.config.stderr(formattedMessage); + } else { + if (level === 'warn' || level === 'error') { + process.stderr.write(formattedMessage + '\n'); + } else { + process.stdout.write(formattedMessage + '\n'); + } + } + } + + static createLogger(scope: string): ScopedLogger { + return { + debug: (message: string, ...args: unknown[]) => { + this.log('debug', scope, message, args); + }, + info: (message: string, ...args: unknown[]) => { + this.log('info', scope, message, args); + }, + warn: (message: string, ...args: unknown[]) => { + this.log('warn', scope, message, args); + }, + error: (message: string, ...args: unknown[]) => { + this.log('error', scope, message, args); + }, + }; + } + + static getEffectiveLevel(): LogLevel { + return this.effectiveLevel; + } +} diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts new file mode 100644 index 00000000..b8602654 --- /dev/null +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -0,0 +1,1388 @@ +/** + * Unit tests for ProcessTransport + * Tests subprocess lifecycle management and IPC + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { ProcessTransport } from '../../src/transport/ProcessTransport.js'; +import { AbortError } from '../../src/types/errors.js'; +import type { TransportOptions } from '../../src/types/types.js'; +import { Readable, Writable } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import * as childProcess from 'node:child_process'; +import * as cliPath from '../../src/utils/cliPath.js'; +import * as jsonLines from '../../src/utils/jsonLines.js'; + +// Mock modules +vi.mock('node:child_process'); +vi.mock('../../src/utils/cliPath.js'); +vi.mock('../../src/utils/jsonLines.js'); + +const mockSpawn = vi.mocked(childProcess.spawn); +const mockPrepareSpawnInfo = vi.mocked(cliPath.prepareSpawnInfo); +const mockParseJsonLinesStream = vi.mocked(jsonLines.parseJsonLinesStream); + +// Helper function to create a mock child process with optional overrides +function createMockChildProcess( + overrides: Partial = {}, +): ChildProcess & EventEmitter { + const mockStdin = new Writable({ + write: vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }), + }); + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + const mockStdout = new Readable({ read: vi.fn() }); + const mockStderr = new Readable({ read: vi.fn() }); + + const baseProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + ...overrides, + }) as unknown as ChildProcess & EventEmitter; + + return baseProcess; +} + +describe('ProcessTransport', () => { + let mockChildProcess: ChildProcess & EventEmitter; + let mockStdin: Writable; + let mockStdout: Readable; + let mockStderr: Readable; + + beforeEach(() => { + vi.clearAllMocks(); + + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + + mockStdin = new Writable({ + write: mockWriteFn, + }); + // Override write with a spy so we can track calls + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + mockStdout = new Readable({ read: vi.fn() }); + mockStderr = new Readable({ read: vi.fn() }); + + mockChildProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + }) as unknown as ChildProcess & EventEmitter; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Construction and Initialization', () => { + it('should create transport with required options', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport).toBeDefined(); + expect(transport.isReady).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ]), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); + }); + + it('should build CLI arguments correctly with all options', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + model: 'qwen-max', + permissionMode: 'auto-edit', + maxSessionTurns: 10, + coreTools: ['read_file', 'write_file'], + excludeTools: ['web_search'], + authType: 'api-key', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--model', + 'qwen-max', + '--approval-mode', + 'auto-edit', + '--max-session-turns', + '10', + '--core-tools', + 'read_file,write_file', + '--exclude-tools', + 'web_search', + '--auth-type', + 'api-key', + ]), + expect.any(Object), + ); + }); + + it('should throw if aborted before initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const abortController = new AbortController(); + abortController.abort(); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + expect(() => new ProcessTransport(options)).toThrow(AbortError); + expect(() => new ProcessTransport(options)).toThrow( + 'Transport start aborted', + ); + }); + + it('should use provided AbortController', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: abortController.signal, + }), + ); + }); + + it('should create default AbortController if not provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + }); + + describe('Lifecycle Management', () => { + it('should spawn subprocess during construction', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + + it('should set isReady to true after successful initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); + }); + + it('should set isReady to false on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Spawn failed')); + + expect(transport.isReady).toBe(false); + expect(transport.exitError).toBeDefined(); + }); + + it('should close subprocess gracefully with SIGTERM', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should force kill with SIGKILL after timeout', async () => { + vi.useFakeTimers(); + + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + vi.advanceTimersByTime(5000); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGKILL'); + + vi.useRealTimers(); + }); + + it('should be idempotent when calling close() multiple times', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + await transport.close(); + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledTimes(3); + }); + + it('should wait for process exit in waitForExit()', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it('should reject waitForExit() on non-zero exit code', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 1, null); + + await expect(waitPromise).rejects.toThrow( + 'CLI process exited with code 1', + ); + }); + + it('should reject waitForExit() on signal termination', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', null, 'SIGTERM'); + + await expect(waitPromise).rejects.toThrow( + 'CLI process terminated by signal SIGTERM', + ); + }); + + it('should reject waitForExit() with AbortError when aborted', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + abortController.abort(); + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).rejects.toThrow(AbortError); + }); + }); + + describe('Message Reading', () => { + it('should read JSON Lines from stdout', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const mockMessages = [ + { type: 'message', content: 'test1' }, + { type: 'message', content: 'test2' }, + ]; + + mockParseJsonLinesStream.mockImplementation(async function* () { + for (const msg of mockMessages) { + yield msg; + } + }); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const messages: unknown[] = []; + const readPromise = (async () => { + for await (const message of transport.readMessages()) { + messages.push(message); + } + })(); + + // Give time for the async generator to start and yield messages + await new Promise((resolve) => setTimeout(resolve, 10)); + + mockChildProcess.emit('close', 0, null); + + await readPromise; + + expect(messages).toEqual(mockMessages); + }, 5000); // Set a reasonable timeout + + it('should throw if reading from transport without stdout', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const generator = transport.readMessages(); + + await expect(generator.next()).rejects.toThrow( + 'Cannot read messages: process not started', + ); + }); + }); + + describe('Message Writing', () => { + it('should write message to stdin', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const message = '{"type":"test","data":"hello"}\n'; + transport.write(message); + + expect(mockStdin.write).toHaveBeenCalledWith(message); + }); + + it('should throw if writing before transport is ready', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Process error')); + + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); + }); + + it('should throw if writing to closed transport', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + // After close(), isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); + }); + + it('should throw if writing when aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + + expect(() => transport.write('test')).toThrow(AbortError); + expect(() => transport.write('test')).toThrow( + 'Cannot write: operation aborted', + ); + }); + + it('should throw if writing to ended stream', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockStdin.end(); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to ended stream', + ); + }); + + it('should throw if writing to terminated process', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const terminatedProcess = createMockChildProcess({ exitCode: 1 }); + mockSpawn.mockReturnValue(terminatedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to terminated process', + ); + }); + + it('should throw if process has exit error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + // After process closes with error, isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); + }); + }); + + describe('Error Handling', () => { + it('should set exitError on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const error = new Error('Process error'); + mockChildProcess.emit('error', error); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toContain('CLI process error'); + }); + + it('should set exitError on process close with non-zero code', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process exited with code 1', + ); + }); + + it('should set exitError on process close with signal', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', null, 'SIGKILL'); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process terminated by signal SIGKILL', + ); + }); + + it('should set AbortError when process aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + mockChildProcess.emit('error', new Error('Aborted')); + + expect(transport.exitError).toBeInstanceOf(AbortError); + expect(transport.exitError?.message).toBe('CLI process aborted by user'); + }); + + it('should not set exitError on clean exit', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 0, null); + + expect(transport.exitError).toBeNull(); + }); + }); + + describe('Resource Cleanup', () => { + it('should register cleanup on parent process exit', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const processOnSpy = vi.spyOn(process, 'on'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOnSpy.mockRestore(); + }); + + it('should remove event listeners on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const processOffSpy = vi.spyOn(process, 'off'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(processOffSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOffSpy.mockRestore(); + }); + + it('should register abort listener', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const addEventListenerSpy = vi.spyOn( + abortController.signal, + 'addEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + addEventListenerSpy.mockRestore(); + }); + + it('should remove abort listener on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const removeEventListenerSpy = vi.spyOn( + abortController.signal, + 'removeEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); + }); + + it('should end stdin on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + await transport.close(); + + expect(endSpy).toHaveBeenCalled(); + }); + }); + + describe('Working Directory', () => { + it('should spawn process in specified cwd', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + cwd: '/custom/path', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: '/custom/path', + }), + ); + }); + + it('should default to process.cwd() if not specified', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: process.cwd(), + }), + ); + }); + }); + + describe('Environment Variables', () => { + it('should pass environment variables to subprocess', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + CUSTOM_VAR: 'custom_value', + }), + }), + ); + }); + + it('should inherit parent env by default', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining(process.env), + }), + ); + }); + + it('should merge custom env with parent env', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ...process.env, + CUSTOM_VAR: 'custom_value', + }), + }), + ); + }); + }); + + describe('Debug and Stderr Handling', () => { + it('should pipe stderr when debug is true', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: true, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('should pipe stderr when stderr callback is provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('should ignore stderr when debug is false and no callback', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: false, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); + }); + + it('should call stderr callback when data is received', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + debug: true, // Enable debug to ensure stderr data is logged + }; + + new ProcessTransport(options); + + // Clear previous calls from logger.info during initialization + stderrCallback.mockClear(); + + mockStderr.emit('data', Buffer.from('error message')); + + // The stderr data is passed through logger.debug, which formats it + // So we check that the callback was called with a message containing 'error message' + expect(stderrCallback).toHaveBeenCalled(); + expect(stderrCallback.mock.calls[0][0]).toContain('error message'); + }); + }); + + describe('Stream Access', () => { + it('should provide access to stdin via getInputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBe(mockStdin); + }); + + it('should provide access to stdout via getOutputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBe(mockStdout); + }); + + it('should allow ending input via endInput()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + transport.endInput(); + + expect(endSpy).toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('should handle process that exits immediately', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const immediateExitProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(immediateExitProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); + }); + + it('should handle waitForExit() when process already exited', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const exitedProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(exitedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.waitForExit()).resolves.toBeUndefined(); + }); + + it('should handle close() when process is already killed', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const killedProcess = createMockChildProcess({ killed: true }); + mockSpawn.mockReturnValue(killedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.close()).resolves.toBeUndefined(); + }); + + it('should handle endInput() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.endInput()).not.toThrow(); + }); + + it('should return undefined for getInputStream() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBeUndefined(); + }); + + it('should return undefined for getOutputStream() when stdout is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBeUndefined(); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts new file mode 100644 index 00000000..1dd0a992 --- /dev/null +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -0,0 +1,1437 @@ +/** + * Unit tests for Query class + * Tests message routing, lifecycle, and orchestration + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { Query } from '../../src/query/Query.js'; +import type { Transport } from '../../src/transport/Transport.js'; +import type { + SDKMessage, + SDKUserMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKResultMessage, + SDKPartialAssistantMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from '../../src/types/protocol.js'; +import { ControlRequestType } from '../../src/types/protocol.js'; +import { AbortError } from '../../src/types/errors.js'; +import { Stream } from '../../src/utils/Stream.js'; + +// Mock Transport implementation +class MockTransport implements Transport { + private messageStream = new Stream(); + public writtenMessages: string[] = []; + public closed = false; + public endInputCalled = false; + public isReady = true; + public exitError: Error | null = null; + + write(data: string): void { + this.writtenMessages.push(data); + } + + async *readMessages(): AsyncGenerator { + for await (const message of this.messageStream) { + yield message; + } + } + + async close(): Promise { + this.closed = true; + this.messageStream.done(); + } + + async waitForExit(): Promise { + // Mock implementation - do nothing + } + + endInput(): void { + this.endInputCalled = true; + } + + // Test helper methods + simulateMessage(message: unknown): void { + this.messageStream.enqueue(message); + } + + simulateError(error: Error): void { + this.messageStream.error(error); + } + + simulateClose(): void { + this.messageStream.done(); + } + + getLastWrittenMessage(): unknown { + if (this.writtenMessages.length === 0) return null; + return JSON.parse(this.writtenMessages[this.writtenMessages.length - 1]); + } + + getAllWrittenMessages(): unknown[] { + return this.writtenMessages.map((msg) => JSON.parse(msg)); + } +} + +// Helper function to find control response by request_id +function findControlResponse( + messages: unknown[], + requestId: string, +): CLIControlResponse | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_response' && + 'response' in msg && + typeof msg.response === 'object' && + msg.response !== null && + 'request_id' in msg.response && + msg.response.request_id === requestId, + ) as CLIControlResponse | undefined; +} + +// Helper function to find control request by subtype +function findControlRequest( + messages: unknown[], + subtype: string, +): CLIControlRequest | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_request' && + 'request' in msg && + typeof msg.request === 'object' && + msg.request !== null && + 'subtype' in msg.request && + msg.request.subtype === subtype, + ) as CLIControlRequest | undefined; +} + +// Helper function to create test messages +function createUserMessage( + content: string, + sessionId = 'test-session', +): SDKUserMessage { + return { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content, + }, + parent_tool_use_id: null, + }; +} + +function createAssistantMessage( + content: string, + sessionId = 'test-session', +): SDKAssistantMessage { + return { + type: 'assistant', + uuid: 'msg-123', + session_id: sessionId, + message: { + id: 'msg-123', + type: 'message', + role: 'assistant', + model: 'test-model', + content: [{ type: 'text', text: content }], + usage: { input_tokens: 10, output_tokens: 20 }, + }, + parent_tool_use_id: null, + }; +} + +function createSystemMessage( + subtype: string, + sessionId = 'test-session', +): SDKSystemMessage { + return { + type: 'system', + subtype, + uuid: 'sys-123', + session_id: sessionId, + cwd: '/test/path', + tools: ['read_file', 'write_file'], + model: 'test-model', + }; +} + +function createResultMessage( + success: boolean, + sessionId = 'test-session', +): SDKResultMessage { + if (success) { + return { + type: 'result', + subtype: 'success', + uuid: 'result-123', + session_id: sessionId, + is_error: false, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + result: 'Success', + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + }; + } else { + return { + type: 'result', + subtype: 'error_during_execution', + uuid: 'result-123', + session_id: sessionId, + is_error: true, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + error: { message: 'Test error' }, + }; + } +} + +function createPartialMessage( + sessionId = 'test-session', +): SDKPartialAssistantMessage { + return { + type: 'stream_event', + uuid: 'stream-123', + session_id: sessionId, + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }, + parent_tool_use_id: null, + }; +} + +function createControlRequest( + subtype: string, + requestId = 'req-123', +): CLIControlRequest { + return { + type: 'control_request', + request_id: requestId, + request: { + subtype, + tool_name: 'test_tool', + input: { arg: 'value' }, + permission_suggestions: null, + blocked_path: null, + } as CLIControlRequest['request'], + }; +} + +function createControlResponse( + requestId: string, + success: boolean, + data?: unknown, +): CLIControlResponse { + return { + type: 'control_response', + response: success + ? { + subtype: 'success', + request_id: requestId, + response: data ?? null, + } + : { + subtype: 'error', + request_id: requestId, + error: 'Test error', + }, + }; +} + +function createControlCancel(requestId: string): ControlCancelRequest { + return { + type: 'control_cancel_request', + request_id: requestId, + }; +} + +describe('Query', () => { + let transport: MockTransport; + + beforeEach(() => { + transport = new MockTransport(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (!transport.closed) { + await transport.close(); + } + }); + + describe('Construction and Initialization', () => { + it('should create Query with transport and options', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + expect(query).toBeDefined(); + expect(query.getSessionId()).toBeTruthy(); + expect(query.isClosed()).toBe(false); + + // Should send initialize control request + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + expect(initRequest.type).toBe('control_request'); + expect(initRequest.request.subtype).toBe('initialize'); + + await query.close(); + }); + + it('should generate unique session ID', async () => { + const transport2 = new MockTransport(); + const query1 = new Query(transport, { cwd: '/test' }); + const query2 = new Query(transport2, { + cwd: '/test', + }); + + expect(query1.getSessionId()).not.toBe(query2.getSessionId()); + + await query1.close(); + await query2.close(); + await transport2.close(); + }); + + it('should handle initialization errors', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + // Simulate initialization failure + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, false), + ); + + await expect(query.initialized).rejects.toThrow(); + + await query.close(); + }); + }); + + describe('Message Routing', () => { + it('should route user messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const userMsg = createUserMessage('Hello'); + transport.simulateMessage(userMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(userMsg); + + await query.close(); + }); + + it('should route assistant messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const assistantMsg = createAssistantMessage('Response'); + transport.simulateMessage(assistantMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(assistantMsg); + + await query.close(); + }); + + it('should route system messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const systemMsg = createSystemMessage('session_start'); + transport.simulateMessage(systemMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(systemMsg); + + await query.close(); + }); + + it('should route result messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(resultMsg); + + await query.close(); + }); + + it('should route partial assistant messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const partialMsg = createPartialMessage(); + transport.simulateMessage(partialMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(partialMsg); + + await query.close(); + }); + + it('should handle unknown message types', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const unknownMsg = { type: 'unknown', data: 'test' }; + transport.simulateMessage(unknownMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(unknownMsg); + + await query.close(); + }); + + it('should yield messages in order', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const msg1 = createUserMessage('First'); + const msg2 = createAssistantMessage('Second'); + const msg3 = createResultMessage(true); + + transport.simulateMessage(msg1); + transport.simulateMessage(msg2); + transport.simulateMessage(msg3); + + const result1 = await query.next(); + expect(result1.value).toEqual(msg1); + + const result2 = await query.next(); + expect(result2.value).toEqual(msg2); + + const result3 = await query.next(); + expect(result3.value).toEqual(msg3); + + await query.close(); + }); + }); + + describe('Control Plane - Permission Control', () => { + it('should handle can_use_tool control requests', async () => { + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'allow' }); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalledWith( + 'test_tool', + { arg: 'value' }, + expect.objectContaining({ + signal: expect.any(AbortSignal), + suggestions: null, + }), + ); + }); + + await query.close(); + }); + + it('should send control response with permission result - allow', async () => { + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'allow' }); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-1'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-1'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + }); + } + }); + + await query.close(); + }); + + it('should send control response with permission result - deny', async () => { + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'deny' }); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-2'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-2'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should default to denying tools if no callback', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-3'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-3'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should handle permission callback timeout', async () => { + const canUseTool = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ behavior: 'allow' }), 15000); + }), + ); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + timeout: { + canUseTool: 10000, + }, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-4'); + transport.simulateMessage(controlReq); + + await vi.waitFor( + () => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-4'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }, + { timeout: 15000 }, + ); + + await query.close(); + }); + + it('should handle permission callback errors', async () => { + const canUseTool = vi.fn().mockRejectedValue(new Error('Callback error')); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-5'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-5'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should handle PermissionResult format with updatedInput', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-6'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-6'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + } + }); + + await query.close(); + }); + + it('should handle permission denial with interrupt flag', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-7'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-7'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + } + }); + + await query.close(); + }); + }); + + describe('Control Plane - Control Cancel', () => { + it('should handle control cancel requests', async () => { + const canUseTool = vi.fn().mockImplementation( + ( + _toolName: string, + _toolInput: unknown, + { signal }: { signal: AbortSignal }, + ) => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(new AbortError())); + setTimeout(() => resolve({ behavior: 'allow' }), 5000); + }), + ); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'cancel-req-1'); + transport.simulateMessage(controlReq); + + // Wait a bit then send cancel + await new Promise((resolve) => setTimeout(resolve, 100)); + transport.simulateMessage(createControlCancel('cancel-req-1')); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalled(); + }); + + await query.close(); + }); + + it('should ignore cancel for unknown request_id', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + // Send cancel for non-existent request + transport.simulateMessage(createControlCancel('unknown-req')); + + // Should not throw or cause issues + await new Promise((resolve) => setTimeout(resolve, 100)); + + await query.close(); + }); + }); + + describe('Multi-Turn Conversation', () => { + it('should support streamInput() for follow-up messages', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Follow-up 1'); + yield createUserMessage('Follow-up 2'); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ); + expect(userMessages.length).toBeGreaterThanOrEqual(2); + + await query.close(); + }); + + it('should maintain session context across turns', async () => { + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Turn 1', sessionId); + yield createUserMessage('Turn 2', sessionId); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ) as SDKUserMessage[]; + + userMessages.forEach((msg) => { + expect(msg.session_id).toBe(sessionId); + }); + + await query.close(); + }); + + it('should throw if streamInput() called on closed query', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + async function* messageGenerator() { + yield createUserMessage('Test'); + } + + await expect(query.streamInput(messageGenerator())).rejects.toThrow( + 'Query is closed', + ); + }); + + it('should handle abort during streamInput', async () => { + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Message 1'); + abortController.abort(); + yield createUserMessage('Message 2'); // Should not be sent + } + + await query.streamInput(messageGenerator()); + + await query.close(); + }); + }); + + describe('Lifecycle Management', () => { + it('should close transport on close()', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(transport.closed).toBe(true); + }); + + it('should mark query as closed', async () => { + const query = new Query(transport, { cwd: '/test' }); + expect(query.isClosed()).toBe(false); + + await query.close(); + expect(query.isClosed()).toBe(true); + }); + + it('should complete output stream on close()', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: SDKMessage[] = []; + for await (const msg of query) { + messages.push(msg); + } + return messages; + })(); + + await query.close(); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(Array.isArray(messages)).toBe(true); + }); + + it('should be idempotent when closing multiple times', async () => { + const query = new Query(transport, { cwd: '/test' }); + + await query.close(); + await query.close(); + await query.close(); + + expect(query.isClosed()).toBe(true); + }); + + it('should handle abort signal cancellation', async () => { + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + abortController.abort(); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); + }); + + it('should handle pre-aborted signal', async () => { + const abortController = new AbortController(); + abortController.abort(); + + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); + }); + }); + + describe('Async Iteration', () => { + it('should support for await loop', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const messages: SDKMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + if (messages.length >= 2) break; + } + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createAssistantMessage('Second')); + + await iterationPromise; + + expect(messages).toHaveLength(2); + expect((messages[0] as SDKUserMessage).message.content).toBe('First'); + + await query.close(); + }); + + it('should complete iteration when query closes', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const messages: SDKMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + } + })(); + + transport.simulateMessage(createUserMessage('Test')); + + // Give time for message to be processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + await query.close(); + transport.simulateClose(); + + await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(1); + }); + + it('should propagate transport errors', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + for await (const msg of query) { + void msg; + } + })(); + + transport.simulateError(new Error('Transport error')); + + await expect(iterationPromise).rejects.toThrow('Transport error'); + + await query.close(); + }); + }); + + describe('Public API Methods', () => { + it('should provide interrupt() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Respond to interrupt + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + transport.simulateMessage( + createControlResponse(interruptMsg.request_id, true, {}), + ); + + await interruptPromise; + await query.close(); + }); + + it('should provide setPermissionMode() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModePromise = query.setPermissionMode('yolo'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + ); + expect(setModeMsg).toBeDefined(); + }); + + // Respond to set permission mode + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + )!; + transport.simulateMessage( + createControlResponse(setModeMsg.request_id, true, {}), + ); + + await setModePromise; + await query.close(); + }); + + it('should provide setModel() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModelPromise = query.setModel('new-model'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + ); + expect(setModelMsg).toBeDefined(); + }); + + // Respond to set model + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + )!; + transport.simulateMessage( + createControlResponse(setModelMsg.request_id, true, {}), + ); + + await setModelPromise; + await query.close(); + }); + + it('should provide supportedCommands() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const commandsPromise = query.supportedCommands(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + ); + expect(commandsMsg).toBeDefined(); + }); + + // Respond with commands + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + )!; + transport.simulateMessage( + createControlResponse(commandsMsg.request_id, true, { + commands: ['interrupt', 'set_model'], + }), + ); + + const result = await commandsPromise; + expect(result).toMatchObject({ commands: ['interrupt', 'set_model'] }); + + await query.close(); + }); + + it('should provide mcpServerStatus() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const statusPromise = query.mcpServerStatus(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + ); + expect(statusMsg).toBeDefined(); + }); + + // Respond with status + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + )!; + transport.simulateMessage( + createControlResponse(statusMsg.request_id, true, { + servers: [{ name: 'test', status: 'connected' }], + }), + ); + + const result = await statusPromise; + expect(result).toMatchObject({ + servers: [{ name: 'test', status: 'connected' }], + }); + + await query.close(); + }); + + it('should throw if methods called on closed query', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + await expect(query.interrupt()).rejects.toThrow('Query is closed'); + await expect(query.setPermissionMode('yolo')).rejects.toThrow( + 'Query is closed', + ); + await expect(query.setModel('model')).rejects.toThrow('Query is closed'); + await expect(query.supportedCommands()).rejects.toThrow( + 'Query is closed', + ); + await expect(query.mcpServerStatus()).rejects.toThrow('Query is closed'); + }); + }); + + describe('Error Handling', () => { + it('should propagate transport errors to stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const error = new Error('Transport failure'); + transport.simulateError(error); + + await expect(query.next()).rejects.toThrow('Transport failure'); + + await query.close(); + }); + + it('should handle control request timeout', async () => { + const query = new Query(transport, { + cwd: '/test', + timeout: { + controlRequest: 10000, + }, + }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + // Call interrupt but don't respond - should timeout + const interruptPromise = query.interrupt(); + + await expect(interruptPromise).rejects.toThrow(/timeout/i); + + await query.close(); + }, 15000); + + it('should handle malformed control responses', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Send malformed response + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + + transport.simulateMessage({ + type: 'control_response', + response: { + subtype: 'error', + request_id: interruptMsg.request_id, + error: { message: 'Malformed error' }, + }, + }); + + await expect(interruptPromise).rejects.toThrow('Malformed error'); + + await query.close(); + }); + + it('should handle CLI sending error result message', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const errorResult = createResultMessage(false); + transport.simulateMessage(errorResult); + + const result = await query.next(); + expect(result.done).toBe(false); + expect((result.value as SDKResultMessage).is_error).toBe(true); + + await query.close(); + }); + }); + + describe('Single-Turn Mode', () => { + it('should auto-close input after result in single-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + true, // singleTurn = true + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(true); + + await query.close(); + }); + + it('should not auto-close input in multi-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + false, // singleTurn = false + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(false); + + await query.close(); + }); + }); + + describe('State Management', () => { + it('should track session ID', () => { + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + expect(sessionId).toBeTruthy(); + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); + }); + + it('should track closed state', async () => { + const query = new Query(transport, { cwd: '/test' }); + + expect(query.isClosed()).toBe(false); + await query.close(); + expect(query.isClosed()).toBe(true); + }); + + it('should provide endInput() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + query.endInput(); + expect(transport.endInputCalled).toBe(true); + + await query.close(); + }); + + it('should throw if endInput() called on closed query', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(() => query.endInput()).toThrow('Query is closed'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty message stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + transport.simulateClose(); + + const result = await query.next(); + expect(result.done).toBe(true); + + await query.close(); + }); + + it('should handle rapid message flow', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Simulate rapid messages + for (let i = 0; i < 100; i++) { + transport.simulateMessage(createUserMessage(`Message ${i}`)); + } + + const messages: SDKMessage[] = []; + for (let i = 0; i < 100; i++) { + const result = await query.next(); + if (!result.done) { + messages.push(result.value); + } + } + + expect(messages.length).toBe(100); + + await query.close(); + }); + + it('should handle close during message iteration', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: SDKMessage[] = []; + for await (const msg of query) { + messages.push(msg); + if (messages.length === 2) { + await query.close(); + } + } + return messages; + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createUserMessage('Second')); + transport.simulateMessage(createUserMessage('Third')); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(2); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/Stream.test.ts b/packages/sdk-typescript/test/unit/Stream.test.ts new file mode 100644 index 00000000..2113a202 --- /dev/null +++ b/packages/sdk-typescript/test/unit/Stream.test.ts @@ -0,0 +1,255 @@ +/** + * Unit tests for Stream class + * Tests producer-consumer patterns and async iteration + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { Stream } from '../../src/utils/Stream.js'; + +describe('Stream', () => { + let stream: Stream; + + beforeEach(() => { + stream = new Stream(); + }); + + describe('Producer-Consumer Patterns', () => { + it('should deliver enqueued value immediately to waiting consumer', async () => { + // Start consumer (waits for value) + const consumerPromise = stream.next(); + + // Producer enqueues value + stream.enqueue('hello'); + + // Consumer should receive value immediately + const result = await consumerPromise; + expect(result).toEqual({ value: 'hello', done: false }); + }); + + it('should buffer values when consumer is slow', async () => { + // Producer enqueues multiple values + stream.enqueue('first'); + stream.enqueue('second'); + stream.enqueue('third'); + + // Consumer reads buffered values + expect(await stream.next()).toEqual({ value: 'first', done: false }); + expect(await stream.next()).toEqual({ value: 'second', done: false }); + expect(await stream.next()).toEqual({ value: 'third', done: false }); + }); + + it('should handle fast producer and fast consumer', async () => { + const values: string[] = []; + + // Produce and consume simultaneously + const consumerPromise = (async () => { + for (let i = 0; i < 3; i++) { + const result = await stream.next(); + if (!result.done) { + values.push(result.value); + } + } + })(); + + stream.enqueue('a'); + stream.enqueue('b'); + stream.enqueue('c'); + + await consumerPromise; + expect(values).toEqual(['a', 'b', 'c']); + }); + + it('should handle async iteration with for await loop', async () => { + const values: string[] = []; + + // Start consumer + const consumerPromise = (async () => { + for await (const value of stream) { + values.push(value); + } + })(); + + // Producer enqueues and completes + stream.enqueue('x'); + stream.enqueue('y'); + stream.enqueue('z'); + stream.done(); + + await consumerPromise; + expect(values).toEqual(['x', 'y', 'z']); + }); + }); + + describe('Stream Completion', () => { + it('should signal completion when done() is called', async () => { + stream.done(); + const result = await stream.next(); + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should complete waiting consumer immediately', async () => { + const consumerPromise = stream.next(); + stream.done(); + const result = await consumerPromise; + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should allow done() to be called multiple times', async () => { + stream.done(); + stream.done(); + stream.done(); + + const result = await stream.next(); + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should allow enqueuing to completed stream (no check in reference)', async () => { + stream.done(); + // Reference version doesn't check for done in enqueue + stream.enqueue('value'); + // Verify value was enqueued by reading it + expect(await stream.next()).toEqual({ value: 'value', done: false }); + }); + + it('should deliver buffered values before completion', async () => { + stream.enqueue('first'); + stream.enqueue('second'); + stream.done(); + + expect(await stream.next()).toEqual({ value: 'first', done: false }); + expect(await stream.next()).toEqual({ value: 'second', done: false }); + expect(await stream.next()).toEqual({ done: true, value: undefined }); + }); + }); + + describe('Error Handling', () => { + it('should propagate error to waiting consumer', async () => { + const consumerPromise = stream.next(); + const error = new Error('Stream error'); + stream.error(error); + + await expect(consumerPromise).rejects.toThrow('Stream error'); + }); + + it('should throw error on next read after error is set', async () => { + const error = new Error('Test error'); + stream.error(error); + + await expect(stream.next()).rejects.toThrow('Test error'); + }); + + it('should allow enqueuing to stream with error (no check in reference)', async () => { + stream.error(new Error('Error')); + // Reference version doesn't check for error in enqueue + stream.enqueue('value'); + // Verify value was enqueued by reading it + expect(await stream.next()).toEqual({ value: 'value', done: false }); + }); + + it('should store last error (reference overwrites)', async () => { + const firstError = new Error('First'); + const secondError = new Error('Second'); + + stream.error(firstError); + stream.error(secondError); + + await expect(stream.next()).rejects.toThrow('Second'); + }); + + it('should deliver buffered values before throwing error', async () => { + stream.enqueue('buffered'); + stream.error(new Error('Stream error')); + + expect(await stream.next()).toEqual({ value: 'buffered', done: false }); + await expect(stream.next()).rejects.toThrow('Stream error'); + }); + }); + + describe('State Properties', () => { + it('should track error state', () => { + expect(stream.hasError).toBeUndefined(); + stream.error(new Error('Test')); + expect(stream.hasError).toBeInstanceOf(Error); + expect(stream.hasError?.message).toBe('Test'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty stream', async () => { + stream.done(); + const result = await stream.next(); + expect(result.done).toBe(true); + }); + + it('should handle single value', async () => { + stream.enqueue('only'); + stream.done(); + + expect(await stream.next()).toEqual({ value: 'only', done: false }); + expect(await stream.next()).toEqual({ done: true, value: undefined }); + }); + + it('should handle rapid enqueue-dequeue cycles', async () => { + const numberStream = new Stream(); + const iterations = 100; + const values: number[] = []; + + const producer = async (): Promise => { + for (let i = 0; i < iterations; i++) { + numberStream.enqueue(i); + await new Promise((resolve) => setImmediate(resolve)); + } + numberStream.done(); + }; + + const consumer = async (): Promise => { + for await (const value of numberStream) { + values.push(value); + } + }; + + await Promise.all([producer(), consumer()]); + expect(values).toHaveLength(iterations); + expect(values[0]).toBe(0); + expect(values[iterations - 1]).toBe(iterations - 1); + }); + }); + + describe('TypeScript Types', () => { + it('should handle different value types', async () => { + const numberStream = new Stream(); + numberStream.enqueue(42); + numberStream.done(); + + const result = await numberStream.next(); + expect(result.value).toBe(42); + + const objectStream = new Stream<{ id: number; name: string }>(); + objectStream.enqueue({ id: 1, name: 'test' }); + objectStream.done(); + + const objectResult = await objectStream.next(); + expect(objectResult.value).toEqual({ id: 1, name: 'test' }); + }); + }); + + describe('Iteration Restrictions', () => { + it('should only allow iteration once', async () => { + const stream = new Stream(); + stream.enqueue('test'); + stream.done(); + + // First iteration should work + const iterator1 = stream[Symbol.asyncIterator](); + expect(await iterator1.next()).toEqual({ + value: 'test', + done: false, + }); + + // Second iteration should throw + expect(() => stream[Symbol.asyncIterator]()).toThrow( + 'Stream can only be iterated once', + ); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts new file mode 100644 index 00000000..43f50dec --- /dev/null +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -0,0 +1,648 @@ +/** + * Unit tests for CLI path utilities + * Tests executable detection, parsing, and spawn info preparation + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; +import { + parseExecutableSpec, + prepareSpawnInfo, + findNativeCliPath, +} from '../../src/utils/cliPath.js'; + +// Mock fs module +vi.mock('node:fs'); +const mockFs = vi.mocked(fs); + +// Mock child_process module +vi.mock('node:child_process'); +const mockExecSync = vi.mocked(execSync); + +// Mock process.versions for bun detection +const originalVersions = process.versions; + +describe('CLI Path Utilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset process.versions + Object.defineProperty(process, 'versions', { + value: { ...originalVersions }, + writable: true, + }); + // Default: tsx is available (can be overridden in specific tests) + mockExecSync.mockReturnValue(Buffer.from('')); + // Default: mock statSync to return a proper file stat object + mockFs.statSync.mockReturnValue({ + isFile: () => true, + } as ReturnType); + }); + + afterEach(() => { + // Restore original process.versions + Object.defineProperty(process, 'versions', { + value: originalVersions, + writable: true, + }); + }); + + describe('parseExecutableSpec', () => { + describe('auto-detection (no spec provided)', () => { + it('should auto-detect native CLI when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec(); + + expect(result).toEqual({ + executablePath: path.resolve('/usr/local/bin/qwen'), + isExplicitRuntime: false, + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw when auto-detection fails', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec()).toThrow( + 'qwen CLI not found. Please:', + ); + }); + }); + + describe('runtime prefix parsing', () => { + it('should parse node runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('node:/path/to/cli.js'); + + expect(result).toEqual({ + runtime: 'node', + executablePath: path.resolve('/path/to/cli.js'), + isExplicitRuntime: true, + }); + }); + + it('should parse bun runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('bun:/path/to/cli.js'); + + expect(result).toEqual({ + runtime: 'bun', + executablePath: path.resolve('/path/to/cli.js'), + isExplicitRuntime: true, + }); + }); + + it('should parse tsx runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('tsx:/path/to/index.ts'); + + expect(result).toEqual({ + runtime: 'tsx', + executablePath: path.resolve('/path/to/index.ts'), + isExplicitRuntime: true, + }); + }); + + it('should parse deno runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('deno:/path/to/cli.ts'); + + expect(result).toEqual({ + runtime: 'deno', + executablePath: path.resolve('/path/to/cli.ts'), + isExplicitRuntime: true, + }); + }); + + it('should throw for invalid runtime prefix format', () => { + expect(() => parseExecutableSpec('invalid:format')).toThrow( + 'Unsupported runtime', + ); + }); + + it('should throw when runtime-prefixed file does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow( + 'Executable file not found at', + ); + }); + }); + + describe('command name detection', () => { + it('should detect command names without path separators', () => { + const result = parseExecutableSpec('qwen'); + + expect(result).toEqual({ + executablePath: 'qwen', + isExplicitRuntime: false, + }); + }); + + it('should detect command names on Windows', () => { + const result = parseExecutableSpec('qwen.exe'); + + expect(result).toEqual({ + executablePath: 'qwen.exe', + isExplicitRuntime: false, + }); + }); + }); + + describe('file path resolution', () => { + it('should resolve absolute file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('/absolute/path/to/qwen'); + + expect(result).toEqual({ + executablePath: path.resolve('/absolute/path/to/qwen'), + isExplicitRuntime: false, + }); + }); + + it('should resolve relative file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('./relative/path/to/qwen'); + + expect(result).toEqual({ + executablePath: path.resolve('./relative/path/to/qwen'), + isExplicitRuntime: false, + }); + }); + + it('should throw when file path does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( + 'Executable file not found at', + ); + }); + }); + }); + + describe('prepareSpawnInfo', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); + + describe('native executables', () => { + it('should prepare spawn info for native binary command', () => { + const result = prepareSpawnInfo('qwen'); + + expect(result).toEqual({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + it('should prepare spawn info for native binary path', () => { + const result = prepareSpawnInfo('/usr/local/bin/qwen'); + + expect(result).toEqual({ + command: path.resolve('/usr/local/bin/qwen'), + args: [], + type: 'native', + originalInput: '/usr/local/bin/qwen', + }); + }); + }); + + describe('JavaScript files', () => { + it('should use node for .js files', () => { + const result = prepareSpawnInfo('/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: '/path/to/cli.js', + }); + }); + + it('should default to node for .js files (not auto-detect bun)', () => { + // Even when running under bun, default to node for .js files + Object.defineProperty(process, 'versions', { + value: { ...originalVersions, bun: '1.0.0' }, + writable: true, + }); + + const result = prepareSpawnInfo('/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: '/path/to/cli.js', + }); + }); + + it('should handle .mjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.mjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.mjs')], + type: 'node', + originalInput: '/path/to/cli.mjs', + }); + }); + + it('should handle .cjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.cjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.cjs')], + type: 'node', + originalInput: '/path/to/cli.cjs', + }); + }); + }); + + describe('TypeScript files', () => { + it('should use tsx for .ts files when tsx is available', () => { + // tsx is available by default in beforeEach + const result = prepareSpawnInfo('/path/to/index.ts'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/index.ts')], + type: 'tsx', + originalInput: '/path/to/index.ts', + }); + }); + + it('should use tsx for .tsx files when tsx is available', () => { + const result = prepareSpawnInfo('/path/to/cli.tsx'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/cli.tsx')], + type: 'tsx', + originalInput: '/path/to/cli.tsx', + }); + }); + + it('should throw helpful error when tsx is not available', () => { + // Mock tsx not being available + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + const resolvedPath = path.resolve('/path/to/index.ts'); + expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( + `TypeScript file '${resolvedPath}' requires 'tsx' runtime, but it's not available`, + ); + expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( + 'Please install tsx: npm install -g tsx', + ); + }); + }); + + describe('explicit runtime specifications', () => { + it('should use explicit node runtime', () => { + const result = prepareSpawnInfo('node:/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: 'node:/path/to/cli.js', + }); + }); + + it('should use explicit bun runtime', () => { + const result = prepareSpawnInfo('bun:/path/to/cli.js'); + + expect(result).toEqual({ + command: 'bun', + args: [path.resolve('/path/to/cli.js')], + type: 'bun', + originalInput: 'bun:/path/to/cli.js', + }); + }); + + it('should use explicit tsx runtime', () => { + const result = prepareSpawnInfo('tsx:/path/to/index.ts'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/index.ts')], + type: 'tsx', + originalInput: 'tsx:/path/to/index.ts', + }); + }); + + it('should use explicit deno runtime', () => { + const result = prepareSpawnInfo('deno:/path/to/cli.ts'); + + expect(result).toEqual({ + command: 'deno', + args: [path.resolve('/path/to/cli.ts')], + type: 'deno', + originalInput: 'deno:/path/to/cli.ts', + }); + }); + }); + + describe('auto-detection fallback', () => { + it('should auto-detect when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + + const result = prepareSpawnInfo(); + + expect(result).toEqual({ + command: path.resolve('/usr/local/bin/qwen'), + args: [], + type: 'native', + originalInput: '', + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + }); + + describe('findNativeCliPath', () => { + it('should find CLI from environment variable', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = findNativeCliPath(); + + expect(result).toBe(path.resolve('/custom/path/to/qwen')); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should search common installation locations', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + + // Mock fs.existsSync to return true for volta bin + // Use path.join to match platform-specific path separators + const voltaBinPath = path.join('.volta', 'bin', 'qwen'); + mockFs.existsSync.mockImplementation((p) => { + return p.toString().includes(voltaBinPath); + }); + + const result = findNativeCliPath(); + + expect(result).toContain(voltaBinPath); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw descriptive error when CLI not found', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + mockFs.existsSync.mockReturnValue(false); + + expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + + describe('real-world use cases', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); + + it('should handle development with TypeScript source', () => { + const devPath = '/Users/dev/qwen-code/packages/cli/index.ts'; + const result = prepareSpawnInfo(devPath); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve(devPath)], + type: 'tsx', + originalInput: devPath, + }); + }); + + it('should handle production bundle validation', () => { + const bundlePath = '/path/to/bundled/cli.js'; + const result = prepareSpawnInfo(bundlePath); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve(bundlePath)], + type: 'node', + originalInput: bundlePath, + }); + }); + + it('should handle production native binary', () => { + const result = prepareSpawnInfo('qwen'); + + expect(result).toEqual({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + it('should handle bun runtime with bundle', () => { + const bundlePath = '/path/to/cli.js'; + const result = prepareSpawnInfo(`bun:${bundlePath}`); + + expect(result).toEqual({ + command: 'bun', + args: [path.resolve(bundlePath)], + type: 'bun', + originalInput: `bun:${bundlePath}`, + }); + }); + }); + + describe('error cases', () => { + it('should provide helpful error for missing TypeScript file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow( + 'Executable file not found at', + ); + }); + + it('should provide helpful error for missing JavaScript file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow( + 'Executable file not found at', + ); + }); + + it('should provide helpful error for invalid runtime specification', () => { + expect(() => prepareSpawnInfo('invalid:spec')).toThrow( + 'Unsupported runtime', + ); + }); + }); + + describe('comprehensive validation', () => { + describe('runtime validation', () => { + it('should reject unsupported runtimes', () => { + expect(() => + parseExecutableSpec('unsupported:/path/to/file.js'), + ).toThrow( + "Unsupported runtime 'unsupported'. Supported runtimes: node, bun, tsx, deno", + ); + }); + + it('should validate runtime availability for explicit runtime specs', () => { + mockFs.existsSync.mockReturnValue(true); + // Mock bun not being available + mockExecSync.mockImplementation((command) => { + if (command.includes('bun')) { + throw new Error('Command not found'); + } + return Buffer.from(''); + }); + + expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow( + "Runtime 'bun' is not available on this system. Please install it first.", + ); + }); + + it('should allow node runtime (always available)', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow(); + }); + + it('should validate file extension matches runtime', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow( + "File extension '.js' is not compatible with runtime 'tsx'", + ); + }); + + it('should validate node runtime with JavaScript files', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow( + "File extension '.ts' is not compatible with runtime 'node'", + ); + }); + + it('should accept valid runtime-file combinations', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow(); + expect(() => + parseExecutableSpec('node:/path/to/file.js'), + ).not.toThrow(); + expect(() => + parseExecutableSpec('bun:/path/to/file.mjs'), + ).not.toThrow(); + }); + }); + + describe('command name validation', () => { + it('should reject empty command names', () => { + expect(() => parseExecutableSpec('')).toThrow( + 'Command name cannot be empty', + ); + expect(() => parseExecutableSpec(' ')).toThrow( + 'Command name cannot be empty', + ); + }); + + it('should reject invalid command name characters', () => { + expect(() => parseExecutableSpec('qwen@invalid')).toThrow( + "Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.", + ); + + expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path + }); + + it('should accept valid command names', () => { + expect(() => parseExecutableSpec('qwen')).not.toThrow(); + expect(() => parseExecutableSpec('qwen-code')).not.toThrow(); + expect(() => parseExecutableSpec('qwen_code')).not.toThrow(); + expect(() => parseExecutableSpec('qwen.exe')).not.toThrow(); + expect(() => parseExecutableSpec('qwen123')).not.toThrow(); + }); + }); + + describe('file path validation', () => { + it('should validate file exists', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( + 'Executable file not found at', + ); + }); + + it('should validate path points to a file, not directory', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ + isFile: () => false, + } as ReturnType); + + expect(() => parseExecutableSpec('/path/to/directory')).toThrow( + 'exists but is not a file', + ); + }); + + it('should accept valid file paths', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ + isFile: () => true, + } as ReturnType); + + expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow(); + expect(() => parseExecutableSpec('./relative/path')).not.toThrow(); + }); + }); + + describe('error message quality', () => { + it('should provide helpful error for missing runtime-prefixed file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( + 'Executable file not found at', + ); + expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( + 'Please check the file path and ensure the file exists', + ); + }); + + it('should provide helpful error for missing regular file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Set QWEN_CODE_CLI_PATH environment variable', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Install qwen globally: npm install -g qwen', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + ); + }); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts new file mode 100644 index 00000000..8f39ad08 --- /dev/null +++ b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts @@ -0,0 +1,386 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Unit tests for createSdkMcpServer + * + * Tests MCP server creation and tool registration. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js'; +import { tool } from '../../src/mcp/tool.js'; +import type { SdkMcpToolDefinition } from '../../src/mcp/tool.js'; + +describe('createSdkMcpServer', () => { + describe('Server Creation', () => { + it('should create server with name and version', () => { + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [], + }); + + expect(server).toBeDefined(); + expect(server.type).toBe('sdk'); + expect(server.name).toBe('test-server'); + expect(server.instance).toBeDefined(); + }); + + it('should create server with default version', () => { + const server = createSdkMcpServer({ + name: 'test-server', + }); + + expect(server).toBeDefined(); + expect(server.name).toBe('test-server'); + }); + + it('should throw error with invalid name', () => { + expect(() => createSdkMcpServer({ name: '', version: '1.0.0' })).toThrow( + 'MCP server name must be a non-empty string', + ); + }); + + it('should throw error with invalid version', () => { + expect(() => createSdkMcpServer({ name: 'test', version: '' })).toThrow( + 'MCP server version must be a non-empty string', + ); + }); + + it('should throw error with non-array tools', () => { + expect(() => + createSdkMcpServer({ + name: 'test', + version: '1.0.0', + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + tools: {} as unknown as SdkMcpToolDefinition[], + }), + ).toThrow('Tools must be an array'); + }); + }); + + describe('Tool Registration', () => { + it('should register single tool', () => { + const testTool = tool( + 'test_tool', + 'A test tool', + { input: z.string() }, + async () => ({ + content: [{ type: 'text', text: 'result' }], + }), + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [testTool], + }); + + expect(server).toBeDefined(); + }); + + it('should register multiple tools', () => { + const tool1 = tool('tool1', 'Tool 1', {}, async () => ({ + content: [{ type: 'text', text: 'result1' }], + })); + + const tool2 = tool('tool2', 'Tool 2', {}, async () => ({ + content: [{ type: 'text', text: 'result2' }], + })); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [tool1, tool2], + }); + + expect(server).toBeDefined(); + }); + + it('should throw error for duplicate tool names', () => { + const tool1 = tool('duplicate', 'Tool 1', {}, async () => ({ + content: [{ type: 'text', text: 'result1' }], + })); + + const tool2 = tool('duplicate', 'Tool 2', {}, async () => ({ + content: [{ type: 'text', text: 'result2' }], + })); + + expect(() => + createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [tool1, tool2], + }), + ).toThrow("Duplicate tool name 'duplicate'"); + }); + + it('should validate tool names', () => { + const invalidTool = { + name: '123invalid', // Starts with number + description: 'Invalid tool', + inputSchema: {}, + handler: async () => ({ + content: [{ type: 'text' as const, text: 'result' }], + }), + }; + + expect(() => + createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + tools: [invalidTool as unknown as SdkMcpToolDefinition], + }), + ).toThrow('Tool name'); + }); + }); + + describe('Tool Handler Invocation', () => { + it('should invoke tool handler with correct input', async () => { + const handler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'success' }], + }); + + const testTool = tool( + 'test_tool', + 'A test tool', + { value: z.string() }, + handler, + ); + + createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [testTool], + }); + + // Note: Actual invocation testing requires MCP SDK integration + // This test verifies the handler was properly registered + expect(handler).toBeDefined(); + }); + + it('should handle async tool handlers', async () => { + const handler = vi + .fn() + .mockImplementation(async (input: { value: string }) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + content: [{ type: 'text', text: `processed: ${input.value}` }], + }; + }); + + const testTool = tool('async_tool', 'An async tool', {}, handler); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [testTool], + }); + + expect(server).toBeDefined(); + }); + }); + + describe('Type Safety', () => { + it('should preserve input type in handler', async () => { + const handler = vi.fn().mockImplementation(async (input) => { + return { + content: [ + { type: 'text', text: `Hello ${input.name}, age ${input.age}` }, + ], + }; + }); + + const typedTool = tool( + 'typed_tool', + 'A typed tool', + { + name: z.string(), + age: z.number(), + }, + handler, + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [typedTool], + }); + + expect(server).toBeDefined(); + }); + }); + + describe('Error Handling in Tools', () => { + it('should handle tool handler errors gracefully', async () => { + const handler = vi.fn().mockRejectedValue(new Error('Tool failed')); + + const errorTool = tool('error_tool', 'A tool that errors', {}, handler); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [errorTool], + }); + + expect(server).toBeDefined(); + // Error handling occurs during tool invocation + }); + + it('should handle synchronous tool handler errors', async () => { + const handler = vi.fn().mockImplementation(() => { + throw new Error('Sync error'); + }); + + const errorTool = tool( + 'sync_error_tool', + 'A tool that errors synchronously', + {}, + handler, + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [errorTool], + }); + + expect(server).toBeDefined(); + }); + }); + + describe('Complex Tool Scenarios', () => { + it('should support tool with complex input schema', () => { + const complexTool = tool( + 'complex_tool', + 'A tool with complex schema', + { + query: z.string(), + filters: z + .object({ + category: z.string().optional(), + minPrice: z.number().optional(), + }) + .optional(), + options: z.array(z.string()).optional(), + }, + async (input) => { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ results: [], filters: input.filters }), + }, + ], + }; + }, + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [complexTool], + }); + + expect(server).toBeDefined(); + }); + + it('should support tool returning complex output', () => { + const complexOutputTool = tool( + 'complex_output_tool', + 'Returns complex data', + {}, + async () => { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + data: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ], + metadata: { + total: 2, + page: 1, + }, + nested: { + deep: { + value: 'test', + }, + }, + }), + }, + ], + }; + }, + ); + + const server = createSdkMcpServer({ + name: 'test-server', + version: '1.0.0', + tools: [complexOutputTool], + }); + + expect(server).toBeDefined(); + }); + }); + + describe('Multiple Servers', () => { + it('should create multiple independent servers', () => { + const tool1 = tool('tool1', 'Tool in server 1', {}, async () => ({ + content: [{ type: 'text', text: 'result1' }], + })); + + const tool2 = tool('tool2', 'Tool in server 2', {}, async () => ({ + content: [{ type: 'text', text: 'result2' }], + })); + + const server1 = createSdkMcpServer({ + name: 'server1', + version: '1.0.0', + tools: [tool1], + }); + const server2 = createSdkMcpServer({ + name: 'server2', + version: '1.0.0', + tools: [tool2], + }); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + expect(server1.name).toBe('server1'); + expect(server2.name).toBe('server2'); + }); + + it('should allow same tool name in different servers', () => { + const tool1 = tool('shared_name', 'Tool in server 1', {}, async () => ({ + content: [{ type: 'text', text: 'result1' }], + })); + + const tool2 = tool('shared_name', 'Tool in server 2', {}, async () => ({ + content: [{ type: 'text', text: 'result2' }], + })); + + const server1 = createSdkMcpServer({ + name: 'server1', + version: '1.0.0', + tools: [tool1], + }); + const server2 = createSdkMcpServer({ + name: 'server2', + version: '1.0.0', + tools: [tool2], + }); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + }); + }); +}); diff --git a/packages/sdk-typescript/tsconfig.build.json b/packages/sdk-typescript/tsconfig.build.json new file mode 100644 index 00000000..61dbca5b --- /dev/null +++ b/packages/sdk-typescript/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "emitDeclarationOnly": true, + "removeComments": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/sdk-typescript/tsconfig.json b/packages/sdk-typescript/tsconfig.json new file mode 100644 index 00000000..11fba047 --- /dev/null +++ b/packages/sdk-typescript/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + /* Language and Environment */ + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + + /* Emit */ + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "removeComments": true, + "importHelpers": false, + + /* Interop Constraints */ + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + + /* Type Checking */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + + /* Completeness */ + "skipLibCheck": true, + + /* Module Resolution */ + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", ".integration-tests"] +} diff --git a/packages/sdk-typescript/vitest.config.ts b/packages/sdk-typescript/vitest.config.ts new file mode 100644 index 00000000..f46dc537 --- /dev/null +++ b/packages/sdk-typescript/vitest.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from 'vitest/config'; +import * as path from 'path'; + +const timeoutMinutes = Number(process.env['E2E_TIMEOUT_MINUTES'] || '3'); +const testTimeoutMs = timeoutMinutes * 60 * 1000; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + 'test/', + '**/*.d.ts', + '**/*.config.*', + '**/index.ts', // Export-only files + ], + thresholds: { + lines: 80, + functions: 80, + branches: 75, + statements: 80, + }, + }, + include: ['test/**/*.test.ts'], + exclude: ['node_modules/', 'dist/'], + retry: 2, + fileParallelism: true, + poolOptions: { + threads: { + minThreads: 2, + maxThreads: 4, + }, + }, + testTimeout: testTimeoutMs, + hookTimeout: 10000, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index a8a6ead2..7365c059 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.4.0", + "version": "0.4.1", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 66ccfa0d..4d1f1023 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.4.0", + "version": "0.4.1", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/vitest.config.ts b/vitest.config.ts index 20ec6b90..88cded8b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'packages/cli', 'packages/core', 'packages/vscode-ide-companion', + 'packages/sdk-typescript', 'integration-tests', 'scripts', ],