feat(tests): move SDK integration tests to integration-tests to share globalSetup

This commit is contained in:
mingholy.lmh
2025-12-01 14:56:11 +08:00
parent ae7d6af717
commit 3056f8a63d
17 changed files with 95 additions and 86 deletions

View File

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

View File

@@ -0,0 +1,486 @@
/**
* E2E tests based on abort-and-lifecycle.ts example
* Tests AbortController integration and process lifecycle management
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
AbortError,
isAbortError,
isSDKAssistantMessage,
type TextBlock,
type ContentBlock,
} from '@qwen-code/sdk-typescript';
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);
});
});
});

View File

@@ -0,0 +1,620 @@
/**
* @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-typescript';
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();
}
});
it('should accept authType: qwen-oauth', async () => {
// Note: qwen-oauth requires credentials in ~/.qwen
// This test may fail if credentials are not configured
// The test verifies the option is accepted and passed correctly
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
authType: 'qwen-oauth',
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// The query should at least start (may fail due to missing credentials)
expect(messages.length).toBeGreaterThan(0);
} catch (error) {
// qwen-oauth may fail if credentials are not configured
// This is acceptable - we're testing that the option is passed correctly
expect(error).toBeDefined();
} 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,
logLevel: 'debug',
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();
}
});
});
});

View File

@@ -0,0 +1,405 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* E2E tests for MCP (Model Context Protocol) server integration via SDK
* Tests that the SDK can properly interact with MCP servers configured in qwen-code
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKAssistantMessage,
isSDKResultMessage,
isSDKSystemMessage,
isSDKUserMessage,
type SDKMessage,
type ToolUseBlock,
type SDKSystemMessage,
} from '@qwen-code/sdk-typescript';
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();
}
});
});
});

View File

@@ -0,0 +1,559 @@
/**
* E2E tests based on multi-turn.ts example
* Tests multi-turn conversation functionality with real CLI
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKUserMessage,
isSDKAssistantMessage,
isSDKSystemMessage,
isSDKResultMessage,
isSDKPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
type SDKUserMessage,
type SDKAssistantMessage,
type TextBlock,
type ContentBlock,
type SDKMessage,
type ControlMessage,
type ToolUseBlock,
} from '@qwen-code/sdk-typescript';
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
/**
* Determine the message type using protocol type guards
*/
function getMessageType(message: SDKMessage | ControlMessage): string {
if (isSDKUserMessage(message)) {
return '🧑 USER';
} else if (isSDKAssistantMessage(message)) {
return '🤖 ASSISTANT';
} else if (isSDKSystemMessage(message)) {
return `🖥️ SYSTEM(${message.subtype})`;
} else if (isSDKResultMessage(message)) {
return `✅ RESULT(${message.subtype})`;
} else if (isSDKPartialAssistantMessage(message)) {
return '⏳ STREAM_EVENT';
} else if (isControlRequest(message)) {
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
} else if (isControlResponse(message)) {
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
} else if (isControlCancel(message)) {
return '🛑 CONTROL_CANCEL';
} else {
return '❓ UNKNOWN';
}
}
/**
* Helper to extract text from ContentBlock array
*/
function extractText(content: ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
}
describe('Multi-Turn Conversations (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('multi-turn');
});
afterEach(async () => {
await helper.cleanup();
});
describe('AsyncIterable Prompt Support', () => {
it('should handle multi-turn conversation using AsyncIterable prompt', async () => {
// Create multi-turn conversation generator
async function* createMultiTurnConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 1 + 1?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 2 + 2?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 3 + 3?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
// Create multi-turn query using AsyncIterable prompt
const q = query({
prompt: createMultiTurnConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
const assistantMessages: SDKAssistantMessage[] = [];
const assistantTexts: string[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
const text = extractText(message.message.content);
assistantTexts.push(text);
}
}
expect(messages.length).toBeGreaterThan(0);
expect(assistantMessages.length).toBeGreaterThanOrEqual(3);
// Validate content of responses
expect(assistantTexts[0]).toMatch(/2/);
expect(assistantTexts[1]).toMatch(/4/);
expect(assistantTexts[2]).toMatch(/6/);
} finally {
await q.close();
}
});
it('should maintain session context across turns', async () => {
async function* createContextualConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content:
'Suppose we have 3 rabbits and 4 carrots. How many animals are there?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'How many animals are there? Only output the number',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createContextualConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const assistantMessages: SDKAssistantMessage[] = [];
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
}
}
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
// The second response should reference the color blue
const secondResponse = extractText(
assistantMessages[1].message.content,
);
expect(secondResponse.toLowerCase()).toContain('3');
} finally {
await q.close();
}
});
});
describe('Tool Usage in Multi-Turn', () => {
it('should handle tool usage across multiple turns', async () => {
async function* createToolConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Create a file named test.txt with content "hello"',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Now read the test.txt file',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createToolConversation(),
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'yolo',
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
let toolUseCount = 0;
const assistantMessages: SDKAssistantMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
const hasToolUseBlock = message.message.content.some(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (hasToolUseBlock) {
toolUseCount++;
}
}
}
expect(messages.length).toBeGreaterThan(0);
expect(toolUseCount).toBeGreaterThan(0);
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
// Validate second response mentions the file content
const secondResponse = extractText(
assistantMessages[assistantMessages.length - 1].message.content,
);
expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/);
} finally {
await q.close();
}
});
});
describe('Message Flow and Sequencing', () => {
it('should process messages in correct sequence', async () => {
async function* createSequentialConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'First question: What is 1 + 1?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Second question: What is 2 + 2?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createSequentialConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messageSequence: string[] = [];
const assistantResponses: string[] = [];
try {
for await (const message of q) {
const messageType = getMessageType(message);
messageSequence.push(messageType);
if (isSDKAssistantMessage(message)) {
const text = extractText(message.message.content);
assistantResponses.push(text);
}
}
expect(messageSequence.length).toBeGreaterThan(0);
expect(assistantResponses.length).toBeGreaterThanOrEqual(2);
// Should end with result
expect(messageSequence[messageSequence.length - 1]).toContain('RESULT');
// Should have assistant responses
expect(messageSequence.some((type) => type.includes('ASSISTANT'))).toBe(
true,
);
} finally {
await q.close();
}
});
it('should handle conversation completion correctly', async () => {
async function* createSimpleConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Hello',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Goodbye',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createSimpleConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let completedNaturally = false;
let messageCount = 0;
try {
for await (const message of q) {
messageCount++;
if (isSDKResultMessage(message)) {
completedNaturally = true;
expect(message.subtype).toBe('success');
}
}
expect(messageCount).toBeGreaterThan(0);
expect(completedNaturally).toBe(true);
} finally {
await q.close();
}
});
});
describe('Error Handling in Multi-Turn', () => {
it('should handle empty conversation gracefully', async () => {
async function* createEmptyConversation(): AsyncIterable<SDKUserMessage> {
// Generator that yields nothing
/* eslint-disable no-constant-condition */
if (false) {
yield {} as SDKUserMessage; // Unreachable, but satisfies TypeScript
}
}
const q = query({
prompt: createEmptyConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Should handle empty conversation without crashing
expect(true).toBe(true);
} finally {
await q.close();
}
});
it('should handle conversation with delays', async () => {
async function* createDelayedConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'First message',
},
parent_tool_use_id: null,
} as SDKUserMessage;
// Longer delay to test patience
await new Promise((resolve) => setTimeout(resolve, 500));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Second message after delay',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createDelayedConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const assistantMessages: SDKAssistantMessage[] = [];
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
}
}
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
} finally {
await q.close();
}
});
});
describe('Partial Messages in Multi-Turn', () => {
it('should receive partial messages when includePartialMessages is enabled', async () => {
async function* createMultiTurnConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 1 + 1?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 2 + 2?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createMultiTurnConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
includePartialMessages: true,
debug: false,
},
});
const messages: SDKMessage[] = [];
let partialMessageCount = 0;
let assistantMessageCount = 0;
try {
for await (const message of q) {
messages.push(message);
if (isSDKPartialAssistantMessage(message)) {
partialMessageCount++;
}
if (isSDKAssistantMessage(message)) {
assistantMessageCount++;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(partialMessageCount).toBeGreaterThan(0);
expect(assistantMessageCount).toBeGreaterThanOrEqual(2);
} finally {
await q.close();
}
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,529 @@
/**
* 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-typescript';
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,
logLevel: 'debug',
},
});
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);
});
});
});

View File

@@ -0,0 +1,614 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* E2E tests for subagent configuration and execution
* Tests subagent delegation and task completion
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKAssistantMessage,
type SDKMessage,
type SubagentConfig,
type ContentBlock,
type ToolUseBlock,
} from '@qwen-code/sdk-typescript';
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();
}
});
});
});

View File

@@ -0,0 +1,317 @@
/**
* E2E tests for system controller features:
* - setModel API for dynamic model switching
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKAssistantMessage,
isSDKSystemMessage,
type SDKUserMessage,
} from '@qwen-code/sdk-typescript';
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
/**
* Factory function that creates a streaming input with a control point.
* After the first message is yielded, the generator waits for a resume signal,
* allowing the test code to call query instance methods like setModel.
*
* @param firstMessage - The first user message to send
* @param secondMessage - The second user message to send after control operations
* @returns Object containing the async generator and a resume function
*/
function createStreamingInputWithControlPoint(
firstMessage: string,
secondMessage: string,
): {
generator: AsyncIterable<SDKUserMessage>;
resume: () => void;
} {
let resumeResolve: (() => void) | null = null;
const resumePromise = new Promise<void>((resolve) => {
resumeResolve = resolve;
});
const generator = (async function* () {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: firstMessage,
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromise;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: secondMessage,
},
parent_tool_use_id: null,
} as SDKUserMessage;
})();
const resume = () => {
if (resumeResolve) {
resumeResolve();
}
};
return { generator, resume };
}
describe('System Control (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('system-control');
});
afterEach(async () => {
await helper.cleanup();
});
describe('setModel API', () => {
it('should change model dynamically during streaming input', async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'Tell me the model name.',
'Tell me the model name now again.',
);
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
debug: false,
},
});
try {
const resolvers: {
first?: () => void;
second?: () => void;
} = {};
const firstResponsePromise = new Promise<void>((resolve) => {
resolvers.first = resolve;
});
const secondResponsePromise = new Promise<void>((resolve) => {
resolvers.second = resolve;
});
let firstResponseReceived = false;
let secondResponseReceived = false;
const systemMessages: Array<{ model?: string }> = [];
// Consume messages in a single loop
(async () => {
for await (const message of q) {
if (isSDKSystemMessage(message)) {
systemMessages.push({ model: message.model });
}
if (isSDKAssistantMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
resolvers.first?.();
} else if (!secondResponseReceived) {
secondResponseReceived = true;
resolvers.second?.();
}
}
}
})();
// Wait for first response
await Promise.race([
firstResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for first response')),
15000,
),
),
]);
expect(firstResponseReceived).toBe(true);
// Perform control operation: set model
await q.setModel('qwen3-vl-plus');
// Resume the input stream
resume();
// Wait for second response
await Promise.race([
secondResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for second response')),
10000,
),
),
]);
expect(secondResponseReceived).toBe(true);
// Verify system messages - model should change from qwen3-max to qwen3-vl-plus
expect(systemMessages.length).toBeGreaterThanOrEqual(2);
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
expect(systemMessages[1].model).toBe('qwen3-vl-plus');
} finally {
await q.close();
}
});
it('should handle multiple model changes in sequence', async () => {
const sessionId = crypto.randomUUID();
let resumeResolve1: (() => void) | null = null;
let resumeResolve2: (() => void) | null = null;
const resumePromise1 = new Promise<void>((resolve) => {
resumeResolve1 = resolve;
});
const resumePromise2 = new Promise<void>((resolve) => {
resumeResolve2 = resolve;
});
const generator = (async function* () {
yield {
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'First message' },
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromise1;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'Second message' },
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromise2;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'Third message' },
parent_tool_use_id: null,
} as SDKUserMessage;
})();
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
debug: false,
},
});
try {
const systemMessages: Array<{ model?: string }> = [];
let responseCount = 0;
const resolvers: Array<() => void> = [];
const responsePromises = [
new Promise<void>((resolve) => resolvers.push(resolve)),
new Promise<void>((resolve) => resolvers.push(resolve)),
new Promise<void>((resolve) => resolvers.push(resolve)),
];
(async () => {
for await (const message of q) {
if (isSDKSystemMessage(message)) {
systemMessages.push({ model: message.model });
}
if (isSDKAssistantMessage(message)) {
if (responseCount < resolvers.length) {
resolvers[responseCount]?.();
responseCount++;
}
}
}
})();
// Wait for first response
await Promise.race([
responsePromises[0],
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout 1')), 10000),
),
]);
// First model change
await q.setModel('qwen3-turbo');
resumeResolve1!();
// Wait for second response
await Promise.race([
responsePromises[1],
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout 2')), 10000),
),
]);
// Second model change
await q.setModel('qwen3-vl-plus');
resumeResolve2!();
// Wait for third response
await Promise.race([
responsePromises[2],
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout 3')), 10000),
),
]);
// Verify we received system messages for each model
expect(systemMessages.length).toBeGreaterThanOrEqual(3);
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
expect(systemMessages[1].model).toBe('qwen3-turbo');
expect(systemMessages[2].model).toBe('qwen3-vl-plus');
} finally {
await q.close();
}
});
it('should throw error when setModel is called on closed query', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
},
});
await q.close();
await expect(q.setModel('qwen3-turbo')).rejects.toThrow(
'Query is closed',
);
});
});
});

View File

@@ -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-typescript';
import {
isSDKAssistantMessage,
isSDKSystemMessage,
isSDKResultMessage,
} from '@qwen-code/sdk-typescript';
// ============================================================================
// Core Test Helper Class
// ============================================================================
export interface SDKTestHelperOptions {
/**
* Optional settings for .qwen/settings.json
*/
settings?: Record<string, unknown>;
/**
* Whether to create .qwen/settings.json
*/
createQwenConfig?: boolean;
}
/**
* Helper class for SDK E2E tests
* Provides isolated test environments for each test case
*/
export class SDKTestHelper {
testDir: string | null = null;
testName?: string;
private baseDir: string;
constructor() {
this.baseDir = process.env['E2E_TEST_FILE_DIR']!;
if (!this.baseDir) {
throw new Error('E2E_TEST_FILE_DIR environment variable not set');
}
}
/**
* Setup an isolated test directory for a specific test
*/
async setup(
testName: string,
options: SDKTestHelperOptions = {},
): Promise<string> {
this.testName = testName;
const sanitizedName = this.sanitizeTestName(testName);
this.testDir = join(this.baseDir, sanitizedName);
await mkdir(this.testDir, { recursive: true });
// Optionally create .qwen/settings.json for CLI configuration
if (options.createQwenConfig) {
const qwenDir = join(this.testDir, '.qwen');
await mkdir(qwenDir, { recursive: true });
const settings = {
telemetry: {
enabled: false, // SDK tests don't need telemetry
},
...options.settings,
};
await writeFile(
join(qwenDir, 'settings.json'),
JSON.stringify(settings, null, 2),
'utf-8',
);
}
return this.testDir;
}
/**
* Create a file in the test directory
*/
async createFile(fileName: string, content: string): Promise<string> {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const filePath = join(this.testDir, fileName);
await writeFile(filePath, content, 'utf-8');
return filePath;
}
/**
* Read a file from the test directory
*/
async readFile(fileName: string): Promise<string> {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const filePath = join(this.testDir, fileName);
return await readFile(filePath, 'utf-8');
}
/**
* Create a subdirectory in the test directory
*/
async mkdir(dirName: string): Promise<string> {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const dirPath = join(this.testDir, dirName);
await mkdir(dirPath, { recursive: true });
return dirPath;
}
/**
* Check if a file exists in the test directory
*/
fileExists(fileName: string): boolean {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const filePath = join(this.testDir, fileName);
return existsSync(filePath);
}
/**
* Get the full path to a file in the test directory
*/
getPath(fileName: string): string {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
return join(this.testDir, fileName);
}
/**
* Cleanup test directory
*/
async cleanup(): Promise<void> {
if (this.testDir && process.env['KEEP_OUTPUT'] !== 'true') {
try {
await rm(this.testDir, { recursive: true, force: true });
} catch (error) {
if (process.env['VERBOSE'] === 'true') {
console.warn('Cleanup warning:', (error as Error).message);
}
}
}
}
/**
* Sanitize test name to create valid directory name
*/
private sanitizeTestName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.substring(0, 100); // Limit length
}
}
// ============================================================================
// MCP Server Utilities
// ============================================================================
export interface MCPServerConfig {
command: string;
args: string[];
}
export interface MCPServerResult {
scriptPath: string;
config: MCPServerConfig;
}
/**
* Built-in MCP server template: Math server with add and multiply tools
*/
const MCP_MATH_SERVER_SCRIPT = `#!/usr/bin/env node
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
const readline = require('readline');
const fs = require('fs');
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true';
function debug(msg) {
if (debugEnabled) {
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
}
}
debug('MCP server starting...');
// Simple JSON-RPC implementation for MCP
class SimpleJSONRPC {
constructor() {
this.handlers = new Map();
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
this.rl.on('line', (line) => {
debug(\`Received line: \${line}\`);
try {
const message = JSON.parse(line);
debug(\`Parsed message: \${JSON.stringify(message)}\`);
this.handleMessage(message);
} catch (e) {
debug(\`Parse error: \${e.message}\`);
}
});
}
send(message) {
const msgStr = JSON.stringify(message);
debug(\`Sending message: \${msgStr}\`);
process.stdout.write(msgStr + '\\n');
}
async handleMessage(message) {
if (message.method && this.handlers.has(message.method)) {
try {
const result = await this.handlers.get(message.method)(message.params || {});
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
result
});
}
} catch (error) {
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: error.message
}
});
}
}
} else if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: 'Method not found'
}
});
}
}
on(method, handler) {
this.handlers.set(method, handler);
}
}
// Create MCP server
const rpc = new SimpleJSONRPC();
// Handle initialize
rpc.on('initialize', async (params) => {
debug('Handling initialize request');
return {
protocolVersion: '2024-11-05',
capabilities: {
tools: {}
},
serverInfo: {
name: 'test-math-server',
version: '1.0.0'
}
};
});
// Handle tools/list
rpc.on('tools/list', async () => {
debug('Handling tools/list request');
return {
tools: [
{
name: 'add',
description: 'Add two numbers together',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' }
},
required: ['a', 'b']
}
},
{
name: 'multiply',
description: 'Multiply two numbers together',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' }
},
required: ['a', 'b']
}
}
]
};
});
// Handle tools/call
rpc.on('tools/call', async (params) => {
debug(\`Handling tools/call request for tool: \${params.name}\`);
if (params.name === 'add') {
const { a, b } = params.arguments;
return {
content: [{
type: 'text',
text: String(a + b)
}]
};
}
if (params.name === 'multiply') {
const { a, b } = params.arguments;
return {
content: [{
type: 'text',
text: String(a * b)
}]
};
}
throw new Error('Unknown tool: ' + params.name);
});
// Send initialization notification
rpc.send({
jsonrpc: '2.0',
method: 'initialized'
});
`;
/**
* Create an MCP server script in the test directory
* @param helper - SDKTestHelper instance
* @param type - Type of MCP server ('math' or provide custom script)
* @param serverName - Name of the MCP server (default: 'test-math-server')
* @param customScript - Custom MCP server script (if type is not 'math')
* @returns Object with scriptPath and config
*/
export async function createMCPServer(
helper: SDKTestHelper,
type: 'math' | 'custom' = 'math',
serverName: string = 'test-math-server',
customScript?: string,
): Promise<MCPServerResult> {
if (!helper.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const script = type === 'math' ? MCP_MATH_SERVER_SCRIPT : customScript;
if (!script) {
throw new Error('Custom script required when type is "custom"');
}
const scriptPath = join(helper.testDir, `${serverName}.cjs`);
await writeFile(scriptPath, script, 'utf-8');
// Make script executable on Unix-like systems
if (process.platform !== 'win32') {
await chmod(scriptPath, 0o755);
}
return {
scriptPath,
config: {
command: 'node',
args: [scriptPath],
},
};
}
// ============================================================================
// Message & Content Utilities
// ============================================================================
/**
* Extract text from ContentBlock array
*/
export function extractText(content: ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
}
/**
* Collect messages by type
*/
export function collectMessagesByType<T extends SDKMessage>(
messages: SDKMessage[],
predicate: (msg: SDKMessage) => msg is T,
): T[] {
return messages.filter(predicate);
}
/**
* Find tool use blocks in a message
*/
export function findToolUseBlocks(
message: SDKAssistantMessage,
toolName?: string,
): ToolUseBlock[] {
const toolUseBlocks = message.message.content.filter(
(block): block is ToolUseBlock => block.type === 'tool_use',
);
if (toolName) {
return toolUseBlocks.filter((block) => block.name === toolName);
}
return toolUseBlocks;
}
/**
* Extract all assistant text from messages
*/
export function getAssistantText(messages: SDKMessage[]): string {
return messages
.filter(isSDKAssistantMessage)
.map((msg) => extractText(msg.message.content))
.join('');
}
/**
* Find system message with optional subtype filter
*/
export function findSystemMessage(
messages: SDKMessage[],
subtype?: string,
): SDKSystemMessage | null {
const systemMessages = messages.filter(isSDKSystemMessage);
if (subtype) {
return systemMessages.find((msg) => msg.subtype === subtype) || null;
}
return systemMessages[0] || null;
}
/**
* Find all tool calls in messages
*/
export function findToolCalls(
messages: SDKMessage[],
toolName?: string,
): Array<{ message: SDKAssistantMessage; toolUse: ToolUseBlock }> {
const results: Array<{
message: SDKAssistantMessage;
toolUse: ToolUseBlock;
}> = [];
for (const message of messages) {
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message, toolName);
for (const toolUse of toolUseBlocks) {
results.push({ message, toolUse });
}
}
}
return results;
}
/**
* Find tool result for a specific tool use ID
*/
export function findToolResult(
messages: SDKMessage[],
toolUseId: string,
): { content: string; isError: boolean } | null {
for (const message of messages) {
if (message.type === 'user' && 'message' in message) {
const userMsg = message as SDKUserMessage;
const content = userMsg.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (
block.type === 'tool_result' &&
(block as { tool_use_id?: string }).tool_use_id === toolUseId
) {
const resultBlock = block as {
content?: string | ContentBlock[];
is_error?: boolean;
};
let resultContent = '';
if (typeof resultBlock.content === 'string') {
resultContent = resultBlock.content;
} else if (Array.isArray(resultBlock.content)) {
resultContent = resultBlock.content
.filter((b): b is TextBlock => b.type === 'text')
.map((b) => b.text)
.join('');
}
return {
content: resultContent,
isError: resultBlock.is_error ?? false,
};
}
}
}
}
}
return null;
}
/**
* Find all tool results for a specific tool name
*/
export function findToolResults(
messages: SDKMessage[],
toolName: string,
): Array<{ toolUseId: string; content: string; isError: boolean }> {
const results: Array<{
toolUseId: string;
content: string;
isError: boolean;
}> = [];
// First find all tool calls for this tool
const toolCalls = findToolCalls(messages, toolName);
// Then find the result for each tool call
for (const { toolUse } of toolCalls) {
const result = findToolResult(messages, toolUse.id);
if (result) {
results.push({
toolUseId: toolUse.id,
content: result.content,
isError: result.isError,
});
}
}
return results;
}
/**
* Find all tool result blocks from messages (without requiring tool name)
*/
export function findAllToolResultBlocks(
messages: SDKMessage[],
): Array<{ toolUseId: string; content: string; isError: boolean }> {
const results: Array<{
toolUseId: string;
content: string;
isError: boolean;
}> = [];
for (const message of messages) {
if (message.type === 'user' && 'message' in message) {
const userMsg = message as SDKUserMessage;
const content = userMsg.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result' && 'tool_use_id' in block) {
const resultBlock = block as {
tool_use_id: string;
content?: string | ContentBlock[];
is_error?: boolean;
};
let resultContent = '';
if (typeof resultBlock.content === 'string') {
resultContent = resultBlock.content;
} else if (Array.isArray(resultBlock.content)) {
resultContent = (resultBlock.content as ContentBlock[])
.filter((b): b is TextBlock => b.type === 'text')
.map((b) => b.text)
.join('');
}
results.push({
toolUseId: resultBlock.tool_use_id,
content: resultContent,
isError: resultBlock.is_error ?? false,
});
}
}
}
}
}
return results;
}
/**
* Check if any tool results exist in messages
*/
export function hasAnyToolResults(messages: SDKMessage[]): boolean {
return findAllToolResultBlocks(messages).length > 0;
}
/**
* Check if any successful (non-error) tool results exist
*/
export function hasSuccessfulToolResults(messages: SDKMessage[]): boolean {
return findAllToolResultBlocks(messages).some((r) => !r.isError);
}
/**
* Check if any error tool results exist
*/
export function hasErrorToolResults(messages: SDKMessage[]): boolean {
return findAllToolResultBlocks(messages).some((r) => r.isError);
}
// ============================================================================
// Streaming Input Utilities
// ============================================================================
/**
* Create a simple streaming input from an array of message contents
*/
export async function* createStreamingInput(
messageContents: string[],
sessionId?: string,
): AsyncIterable<SDKUserMessage> {
const sid = sessionId || crypto.randomUUID();
for (const content of messageContents) {
yield {
type: 'user',
session_id: sid,
message: {
role: 'user',
content: content,
},
parent_tool_use_id: null,
} as SDKUserMessage;
// Small delay between messages
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* Create a controlled streaming input with pause/resume capability
*/
export function createControlledStreamingInput(
messageContents: string[],
sessionId?: string,
): {
generator: AsyncIterable<SDKUserMessage>;
resume: () => void;
resumeAll: () => void;
} {
const sid = sessionId || crypto.randomUUID();
const resumeResolvers: Array<() => void> = [];
const resumePromises: Array<Promise<void>> = [];
// Create a resume promise for each message after the first
for (let i = 1; i < messageContents.length; i++) {
const promise = new Promise<void>((resolve) => {
resumeResolvers.push(resolve);
});
resumePromises.push(promise);
}
const generator = (async function* () {
// Yield first message immediately
yield {
type: 'user',
session_id: sid,
message: {
role: 'user',
content: messageContents[0],
},
parent_tool_use_id: null,
} as SDKUserMessage;
// For subsequent messages, wait for resume
for (let i = 1; i < messageContents.length; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromises[i - 1];
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sid,
message: {
role: 'user',
content: messageContents[i],
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
})();
let currentResumeIndex = 0;
return {
generator,
resume: () => {
if (currentResumeIndex < resumeResolvers.length) {
resumeResolvers[currentResumeIndex]();
currentResumeIndex++;
}
},
resumeAll: () => {
resumeResolvers.forEach((resolve) => resolve());
currentResumeIndex = resumeResolvers.length;
},
};
}
// ============================================================================
// Assertion Utilities
// ============================================================================
/**
* Assert that messages follow expected type sequence
*/
export function assertMessageSequence(
messages: SDKMessage[],
expectedTypes: string[],
): void {
const actualTypes = messages.map((msg) => msg.type);
if (actualTypes.length < expectedTypes.length) {
throw new Error(
`Expected at least ${expectedTypes.length} messages, got ${actualTypes.length}`,
);
}
for (let i = 0; i < expectedTypes.length; i++) {
if (actualTypes[i] !== expectedTypes[i]) {
throw new Error(
`Expected message ${i} to be type '${expectedTypes[i]}', got '${actualTypes[i]}'`,
);
}
}
}
/**
* Assert that a specific tool was called
*/
export function assertToolCalled(
messages: SDKMessage[],
toolName: string,
): void {
const toolCalls = findToolCalls(messages, toolName);
if (toolCalls.length === 0) {
const allToolCalls = findToolCalls(messages);
const allToolNames = allToolCalls.map((tc) => tc.toolUse.name);
throw new Error(
`Expected tool '${toolName}' to be called. Found tools: ${allToolNames.length > 0 ? allToolNames.join(', ') : 'none'}`,
);
}
}
/**
* Assert that the conversation completed successfully
*/
export function assertSuccessfulCompletion(messages: SDKMessage[]): void {
const lastMessage = messages[messages.length - 1];
if (!isSDKResultMessage(lastMessage)) {
throw new Error(
`Expected last message to be a result message, got '${lastMessage.type}'`,
);
}
if (lastMessage.subtype !== 'success') {
throw new Error(
`Expected successful completion, got result subtype '${lastMessage.subtype}'`,
);
}
}
/**
* Wait for a condition to be true with timeout
*/
export async function waitFor(
predicate: () => boolean | Promise<boolean>,
options: {
timeout?: number;
interval?: number;
errorMessage?: string;
} = {},
): Promise<void> {
const {
timeout = 5000,
interval = 100,
errorMessage = 'Condition not met within timeout',
} = options;
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const result = await predicate();
if (result) {
return;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error(errorMessage);
}
// ============================================================================
// Debug and Validation Utilities
// ============================================================================
/**
* Validate model output and warn about unexpected content
* Inspired by integration-tests test-helper
*/
export function validateModelOutput(
result: string,
expectedContent: string | (string | RegExp)[] | null = null,
testName = '',
): boolean {
// First, check if there's any output at all
if (!result || result.trim().length === 0) {
throw new Error('Expected model to return some output');
}
// If expectedContent is provided, check for it and warn if missing
if (expectedContent) {
const contents = Array.isArray(expectedContent)
? expectedContent
: [expectedContent];
const missingContent = contents.filter((content) => {
if (typeof content === 'string') {
return !result.toLowerCase().includes(content.toLowerCase());
} else if (content instanceof RegExp) {
return !content.test(result);
}
return false;
});
if (missingContent.length > 0) {
console.warn(
`Warning: Model did not include expected content in response: ${missingContent.join(', ')}.`,
'This is not ideal but not a test failure.',
);
console.warn(
'The tool was called successfully, which is the main requirement.',
);
return false;
} else if (process.env['VERBOSE'] === 'true') {
console.log(`${testName}: Model output validated successfully.`);
}
return true;
}
return true;
}
/**
* Print debug information when tests fail
*/
export function printDebugInfo(
messages: SDKMessage[],
context: Record<string, unknown> = {},
): void {
console.error('Test failed - Debug info:');
console.error('Message count:', messages.length);
// Print message types
const messageTypes = messages.map((m) => m.type);
console.error('Message types:', messageTypes.join(', '));
// Print assistant text
const assistantText = getAssistantText(messages);
console.error(
'Assistant text (first 500 chars):',
assistantText.substring(0, 500),
);
if (assistantText.length > 500) {
console.error(
'Assistant text (last 500 chars):',
assistantText.substring(assistantText.length - 500),
);
}
// Print tool calls
const toolCalls = findToolCalls(messages);
console.error(
'Tool calls found:',
toolCalls.map((tc) => tc.toolUse.name),
);
// Print any additional context provided
Object.entries(context).forEach(([key, value]) => {
console.error(`${key}:`, value);
});
}
/**
* Create detailed error message for tool call expectations
*/
export function createToolCallErrorMessage(
expectedTools: string | string[],
foundTools: string[],
messages: SDKMessage[],
): string {
const expectedStr = Array.isArray(expectedTools)
? expectedTools.join(' or ')
: expectedTools;
const assistantText = getAssistantText(messages);
const preview = assistantText
? assistantText.substring(0, 200) + '...'
: 'no output';
return (
`Expected to find ${expectedStr} tool call(s). ` +
`Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` +
`Output preview: ${preview}`
);
}
// ============================================================================
// Shared Test Options Helper
// ============================================================================
/**
* Create shared test options with CLI path
*/
export function createSharedTestOptions(
overrides: Record<string, unknown> = {},
) {
const TEST_CLI_PATH = process.env['TEST_CLI_PATH'];
if (!TEST_CLI_PATH) {
throw new Error('TEST_CLI_PATH environment variable not set');
}
return {
pathToQwenExecutable: TEST_CLI_PATH,
...overrides,
};
}

View File

@@ -0,0 +1,748 @@
/**
* @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-typescript';
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,
);
});
});

View File

@@ -2,7 +2,13 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true
"allowJs": true,
"baseUrl": ".",
"paths": {
"@qwen-code/sdk-typescript": [
"../packages/sdk-typescript/dist/index.d.ts"
]
}
},
"include": ["**/*.ts"],
"references": [{ "path": "../packages/core" }]

View File

@@ -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-typescript': resolve(
__dirname,
'../packages/sdk-typescript/dist/index.mjs',
),
},
},
});