mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(tests): move SDK integration tests to integration-tests to share globalSetup
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src test",
|
||||
|
||||
@@ -18,6 +18,14 @@ export type {
|
||||
SDKResultMessage,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKMessage,
|
||||
ControlMessage,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
SubagentConfig,
|
||||
SubagentLevel,
|
||||
ModelConfig,
|
||||
RunConfig,
|
||||
} from './types/protocol.js';
|
||||
|
||||
export {
|
||||
@@ -26,6 +34,9 @@ export {
|
||||
isSDKSystemMessage,
|
||||
isSDKResultMessage,
|
||||
isSDKPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
} from './types/protocol.js';
|
||||
|
||||
export type {
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
/**
|
||||
* E2E tests based on abort-and-lifecycle.ts example
|
||||
* Tests AbortController integration and process lifecycle management
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
AbortError,
|
||||
isAbortError,
|
||||
isSDKAssistantMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
} from '../../src/index.js';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
describe('AbortController and Process Lifecycle (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('abort-and-lifecycle');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
describe('Basic AbortController Usage', () => {
|
||||
it('should support AbortController cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Abort after 5 seconds
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 5000);
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long story about TypeScript programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
|
||||
// Should receive some content before abort
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here - query should be aborted
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle abort during query execution', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let receivedFirstMessage = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
if (!receivedFirstMessage) {
|
||||
// Abort immediately after receiving first assistant message
|
||||
receivedFirstMessage = true;
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
// Should have received at least one message before abort
|
||||
expect(receivedFirstMessage).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle abort immediately after query starts', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long essay',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort immediately after query initialization
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May or may not receive messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Process Lifecycle Monitoring', () => {
|
||||
it('should handle normal process completion', async () => {
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let completedSuccessfully = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
completedSuccessfully = true;
|
||||
} catch (error) {
|
||||
// Should not throw for normal completion
|
||||
expect(false).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(completedSuccessfully).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle process cleanup after error', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello world',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 50);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Expected to potentially have errors
|
||||
} finally {
|
||||
// Should cleanup successfully even after error
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Stream Control', () => {
|
||||
it('should support endInput() method', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let receivedResponse = false;
|
||||
let endInputCalled = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message) && !endInputCalled) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock =>
|
||||
block.type === 'text',
|
||||
);
|
||||
const text = textBlocks.map((b: TextBlock) => b.text).join('');
|
||||
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
receivedResponse = true;
|
||||
|
||||
// End input after receiving first response
|
||||
q.endInput();
|
||||
endInputCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(receivedResponse).toBe(true);
|
||||
expect(endInputCalled).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Recovery', () => {
|
||||
it('should handle invalid executable path', async () => {
|
||||
try {
|
||||
const q = query({
|
||||
prompt: 'Hello world',
|
||||
options: {
|
||||
pathToQwenExecutable: '/nonexistent/path/to/cli',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Should not reach here - query() should throw immediately
|
||||
for await (const _message of q) {
|
||||
// Should not reach here
|
||||
}
|
||||
|
||||
// Should not reach here
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect((error as Error).message).toBeDefined();
|
||||
expect((error as Error).message).toContain(
|
||||
'Invalid pathToQwenExecutable',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw AbortError with correct properties', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Explain the concept of async programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort after allowing query to start
|
||||
setTimeout(() => controller.abort(), 1000);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May receive some messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
// Verify error type and helper functions
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBeDefined();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Debugging with stderr callback', () => {
|
||||
it('should capture stderr messages when debug is enabled', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 50);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not capture stderr when debug is disabled', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
// Should have minimal or no stderr output when debug is false
|
||||
expect(stderrMessages.length).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Abort with Cleanup', () => {
|
||||
it('should cleanup properly after abort', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long essay about programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort immediately
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May receive some messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AbortError) {
|
||||
expect(true).toBe(true); // Expected abort error
|
||||
} else {
|
||||
throw error; // Unexpected error
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed after abort
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple abort calls gracefully', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Count to 100',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Multiple abort calls
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
setTimeout(() => controller.abort(), 200);
|
||||
setTimeout(() => controller.abort(), 300);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Should be interrupted
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Management Edge Cases', () => {
|
||||
it('should handle close() called multiple times', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start the query
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
// Close multiple times
|
||||
await q.close();
|
||||
await q.close();
|
||||
await q.close();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle abort after close', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start and close immediately
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
await q.close();
|
||||
|
||||
// Abort after close
|
||||
controller.abort();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,620 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for SDK configuration options:
|
||||
* - logLevel: Controls SDK internal logging verbosity
|
||||
* - env: Environment variables passed to CLI process
|
||||
* - authType: Authentication type for AI service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
type SDKMessage,
|
||||
} from '../../src/types/protocol.js';
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '../..');
|
||||
const e2eTestsDir = join(rootDir, '.integration-tests');
|
||||
let runDir = '';
|
||||
|
||||
export async function setup() {
|
||||
runDir = join(e2eTestsDir, `sdk-e2e-${Date.now()}`);
|
||||
await mkdir(runDir, { recursive: true });
|
||||
|
||||
// Clean up old test runs, but keep the latest few for debugging
|
||||
try {
|
||||
const testRuns = await readdir(e2eTestsDir);
|
||||
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(e2eTestsDir, oldRun), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error cleaning up old test runs:', e);
|
||||
}
|
||||
|
||||
process.env['E2E_TEST_FILE_DIR'] = runDir;
|
||||
process.env['QWEN_CLI_E2E_TEST'] = 'true';
|
||||
process.env['TEST_CLI_PATH'] = join(rootDir, '../../dist/cli.js');
|
||||
|
||||
if (process.env['KEEP_OUTPUT']) {
|
||||
console.log(`Keeping output for test run in: ${runDir}`);
|
||||
}
|
||||
process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false';
|
||||
|
||||
console.log(`\nSDK E2E test output directory: ${runDir}`);
|
||||
console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`);
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
// Cleanup the test run directory unless KEEP_OUTPUT is set
|
||||
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
|
||||
await rm(runDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for MCP (Model Context Protocol) server integration via SDK
|
||||
* Tests that the SDK can properly interact with MCP servers configured in qwen-code
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isSDKAssistantMessage,
|
||||
isSDKResultMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKUserMessage,
|
||||
type SDKMessage,
|
||||
type ToolUseBlock,
|
||||
type SDKSystemMessage,
|
||||
} from '../../src/types/protocol.js';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
createMCPServer,
|
||||
extractText,
|
||||
findToolUseBlocks,
|
||||
createSharedTestOptions,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
...createSharedTestOptions(),
|
||||
permissionMode: 'yolo' as const,
|
||||
};
|
||||
|
||||
describe('MCP Server Integration (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let serverScriptPath: string;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create isolated test environment using SDKTestHelper
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('mcp-server-integration');
|
||||
|
||||
// Create MCP server using the helper utility
|
||||
const mcpServer = await createMCPServer(helper, 'math', 'test-math-server');
|
||||
serverScriptPath = mcpServer.scriptPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup test directory
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('Basic MCP Tool Usage', () => {
|
||||
it('should use MCP add tool to add two numbers', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the add tool to calculate 5 + 10. Just give me the result.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'add');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains expected answer
|
||||
expect(assistantText).toMatch(/15/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
if (isSDKResultMessage(lastMessage)) {
|
||||
expect(lastMessage.subtype).toBe('success');
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use MCP multiply tool to multiply two numbers', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the multiply tool to calculate 6 * 7. Just give me the result.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'multiply');
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains expected answer
|
||||
expect(assistantText).toMatch(/42/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Server Discovery', () => {
|
||||
it('should list MCP servers in system init message', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let systemMessage: SDKSystemMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKSystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate MCP server is listed
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.mcp_servers).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||
|
||||
// Find our test server
|
||||
const testServer = systemMessage!.mcp_servers?.find(
|
||||
(server) => server.name === 'test-math-server',
|
||||
);
|
||||
expect(testServer).toBeDefined();
|
||||
|
||||
// Note: tools are not exposed in the mcp_servers array in system message
|
||||
// They are available through the MCP protocol but not in the init message
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex MCP Operations', () => {
|
||||
it('should chain multiple MCP tool calls', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'First use add to calculate 10 + 5, then multiply the result by 2. Give me the final answer.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message);
|
||||
toolUseBlocks.forEach((block) => {
|
||||
toolCalls.push(block.name);
|
||||
});
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate both tools were called
|
||||
expect(toolCalls).toContain('add');
|
||||
expect(toolCalls).toContain('multiply');
|
||||
|
||||
// Validate result: (10 + 5) * 2 = 30
|
||||
expect(assistantText).toMatch(/30/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple calls to the same MCP tool', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the add tool twice: first add 1 + 2, then add 3 + 4. Tell me both results.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
const addToolCalls: ToolUseBlock[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, 'add');
|
||||
addToolCalls.push(...toolUseBlocks);
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate add tool was called at least twice
|
||||
expect(addToolCalls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Validate results contain expected answers: 3 and 7
|
||||
expect(assistantText).toMatch(/3/);
|
||||
expect(assistantText).toMatch(/7/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Tool Message Flow', () => {
|
||||
it('should receive proper message sequence for MCP tool usage', async () => {
|
||||
const q = query({
|
||||
prompt: 'Use add to calculate 2 + 3',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messageTypes: string[] = [];
|
||||
let foundToolUse = false;
|
||||
let foundToolResult = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageTypes.push(message.type);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message);
|
||||
if (toolUseBlocks.length > 0) {
|
||||
foundToolUse = true;
|
||||
expect(toolUseBlocks[0].name).toBe('add');
|
||||
expect(toolUseBlocks[0].input).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
if (isSDKUserMessage(message)) {
|
||||
const content = message.message.content;
|
||||
const contentArray = Array.isArray(content)
|
||||
? content
|
||||
: [{ type: 'text', text: content }];
|
||||
const toolResultBlock = contentArray.find(
|
||||
(block) => block.type === 'tool_result',
|
||||
);
|
||||
if (toolResultBlock) {
|
||||
foundToolResult = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate message flow
|
||||
expect(foundToolUse).toBe(true);
|
||||
expect(foundToolResult).toBe(true);
|
||||
expect(messageTypes).toContain('system');
|
||||
expect(messageTypes).toContain('assistant');
|
||||
expect(messageTypes).toContain('user');
|
||||
expect(messageTypes).toContain('result');
|
||||
|
||||
// Result should be last message
|
||||
expect(messageTypes[messageTypes.length - 1]).toBe('result');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle gracefully when MCP tool is not available', async () => {
|
||||
const q = query({
|
||||
prompt: 'Use the subtract tool to calculate 10 - 5',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Should complete without crashing
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isSDKResultMessage(lastMessage)).toBe(true);
|
||||
|
||||
// Assistant should indicate tool is not available or provide alternative
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,559 +0,0 @@
|
||||
/**
|
||||
* E2E tests based on multi-turn.ts example
|
||||
* Tests multi-turn conversation functionality with real CLI
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isSDKUserMessage,
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKResultMessage,
|
||||
isSDKPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
type SDKUserMessage,
|
||||
type SDKAssistantMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
type SDKMessage,
|
||||
type ControlMessage,
|
||||
type ToolUseBlock,
|
||||
} from '../../src/types/protocol.js';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
/**
|
||||
* Determine the message type using protocol type guards
|
||||
*/
|
||||
function getMessageType(message: SDKMessage | ControlMessage): string {
|
||||
if (isSDKUserMessage(message)) {
|
||||
return '🧑 USER';
|
||||
} else if (isSDKAssistantMessage(message)) {
|
||||
return '🤖 ASSISTANT';
|
||||
} else if (isSDKSystemMessage(message)) {
|
||||
return `🖥️ SYSTEM(${message.subtype})`;
|
||||
} else if (isSDKResultMessage(message)) {
|
||||
return `✅ RESULT(${message.subtype})`;
|
||||
} else if (isSDKPartialAssistantMessage(message)) {
|
||||
return '⏳ STREAM_EVENT';
|
||||
} else if (isControlRequest(message)) {
|
||||
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
|
||||
} else if (isControlResponse(message)) {
|
||||
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
|
||||
} else if (isControlCancel(message)) {
|
||||
return '🛑 CONTROL_CANCEL';
|
||||
} else {
|
||||
return '❓ UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract text from ContentBlock array
|
||||
*/
|
||||
function extractText(content: ContentBlock[]): string {
|
||||
return content
|
||||
.filter((block): block is TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('Multi-Turn Conversations (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('multi-turn');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('AsyncIterable Prompt Support', () => {
|
||||
it('should handle multi-turn conversation using AsyncIterable prompt', async () => {
|
||||
// Create multi-turn conversation generator
|
||||
async function* createMultiTurnConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 1 + 1?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 2 + 2?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 3 + 3?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
// Create multi-turn query using AsyncIterable prompt
|
||||
const q = query({
|
||||
prompt: createMultiTurnConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
const assistantMessages: SDKAssistantMessage[] = [];
|
||||
const assistantTexts: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
const text = extractText(message.message.content);
|
||||
assistantTexts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Validate content of responses
|
||||
expect(assistantTexts[0]).toMatch(/2/);
|
||||
expect(assistantTexts[1]).toMatch(/4/);
|
||||
expect(assistantTexts[2]).toMatch(/6/);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain session context across turns', async () => {
|
||||
async function* createContextualConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'Suppose we have 3 rabbits and 4 carrots. How many animals are there?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'How many animals are there? Only output the number',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createContextualConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessages: SDKAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The second response should reference the color blue
|
||||
const secondResponse = extractText(
|
||||
assistantMessages[1].message.content,
|
||||
);
|
||||
expect(secondResponse.toLowerCase()).toContain('3');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Usage in Multi-Turn', () => {
|
||||
it('should handle tool usage across multiple turns', async () => {
|
||||
async function* createToolConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Create a file named test.txt with content "hello"',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Now read the test.txt file',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createToolConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'yolo',
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let toolUseCount = 0;
|
||||
const assistantMessages: SDKAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
const hasToolUseBlock = message.message.content.some(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (hasToolUseBlock) {
|
||||
toolUseCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(toolUseCount).toBeGreaterThan(0);
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Validate second response mentions the file content
|
||||
const secondResponse = extractText(
|
||||
assistantMessages[assistantMessages.length - 1].message.content,
|
||||
);
|
||||
expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Flow and Sequencing', () => {
|
||||
it('should process messages in correct sequence', async () => {
|
||||
async function* createSequentialConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'First question: What is 1 + 1?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Second question: What is 2 + 2?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createSequentialConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messageSequence: string[] = [];
|
||||
const assistantResponses: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
const messageType = getMessageType(message);
|
||||
messageSequence.push(messageType);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const text = extractText(message.message.content);
|
||||
assistantResponses.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageSequence.length).toBeGreaterThan(0);
|
||||
expect(assistantResponses.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Should end with result
|
||||
expect(messageSequence[messageSequence.length - 1]).toContain('RESULT');
|
||||
|
||||
// Should have assistant responses
|
||||
expect(messageSequence.some((type) => type.includes('ASSISTANT'))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle conversation completion correctly', async () => {
|
||||
async function* createSimpleConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Goodbye',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createSimpleConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let completedNaturally = false;
|
||||
let messageCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageCount++;
|
||||
|
||||
if (isSDKResultMessage(message)) {
|
||||
completedNaturally = true;
|
||||
expect(message.subtype).toBe('success');
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageCount).toBeGreaterThan(0);
|
||||
expect(completedNaturally).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling in Multi-Turn', () => {
|
||||
it('should handle empty conversation gracefully', async () => {
|
||||
async function* createEmptyConversation(): AsyncIterable<SDKUserMessage> {
|
||||
// Generator that yields nothing
|
||||
/* eslint-disable no-constant-condition */
|
||||
if (false) {
|
||||
yield {} as SDKUserMessage; // Unreachable, but satisfies TypeScript
|
||||
}
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createEmptyConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Should handle empty conversation without crashing
|
||||
expect(true).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle conversation with delays', async () => {
|
||||
async function* createDelayedConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'First message',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
// Longer delay to test patience
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Second message after delay',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createDelayedConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessages: SDKAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Partial Messages in Multi-Turn', () => {
|
||||
it('should receive partial messages when includePartialMessages is enabled', async () => {
|
||||
async function* createMultiTurnConversation(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 1 + 1?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 2 + 2?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createMultiTurnConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
includePartialMessages: true,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let partialMessageCount = 0;
|
||||
let assistantMessageCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKPartialAssistantMessage(message)) {
|
||||
partialMessageCount++;
|
||||
}
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
assistantMessageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(partialMessageCount).toBeGreaterThan(0);
|
||||
expect(assistantMessageCount).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,529 +0,0 @@
|
||||
/**
|
||||
* E2E tests for single-turn query execution
|
||||
* Tests basic query patterns with simple prompts and clear output expectations
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKResultMessage,
|
||||
isSDKPartialAssistantMessage,
|
||||
type SDKMessage,
|
||||
type SDKSystemMessage,
|
||||
type SDKAssistantMessage,
|
||||
} from '../../src/types/protocol.js';
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,614 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for subagent configuration and execution
|
||||
* Tests subagent delegation and task completion
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isSDKAssistantMessage,
|
||||
type SDKMessage,
|
||||
type SubagentConfig,
|
||||
type ContentBlock,
|
||||
type ToolUseBlock,
|
||||
} from '../../src/types/protocol.js';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
createSharedTestOptions,
|
||||
findToolUseBlocks,
|
||||
assertSuccessfulCompletion,
|
||||
findSystemMessage,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
describe('Subagents (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testWorkDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create isolated test environment using SDKTestHelper
|
||||
helper = new SDKTestHelper();
|
||||
testWorkDir = await helper.setup('subagent-tests');
|
||||
|
||||
// Create a simple test file for subagent to work with
|
||||
await helper.createFile('test.txt', 'Hello from test file\n');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup test directory
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('Subagent Configuration', () => {
|
||||
it('should accept session-level subagent configuration', async () => {
|
||||
const simpleSubagent: SubagentConfig = {
|
||||
name: 'simple-greeter',
|
||||
description: 'A simple subagent that responds to greetings',
|
||||
systemPrompt:
|
||||
'You are a friendly greeter. When given a task, respond with a cheerful greeting.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello, let simple-greeter to say hi back to me.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [simpleSubagent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate system message includes the subagent
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('simple-greeter');
|
||||
|
||||
// Validate successful completion
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept multiple subagent configurations', async () => {
|
||||
const greeterAgent: SubagentConfig = {
|
||||
name: 'greeter',
|
||||
description: 'Responds to greetings',
|
||||
systemPrompt: 'You are a friendly greeter.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const mathAgent: SubagentConfig = {
|
||||
name: 'math-helper',
|
||||
description: 'Helps with math problems',
|
||||
systemPrompt: 'You are a math expert. Solve math problems clearly.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 5 + 5?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [greeterAgent, mathAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate both subagents are registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('greeter');
|
||||
expect(systemMessage!.agents).toContain('math-helper');
|
||||
expect(systemMessage!.agents!.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle subagent with custom model config', async () => {
|
||||
const customModelAgent: SubagentConfig = {
|
||||
name: 'custom-model-agent',
|
||||
description: 'Agent with custom model configuration',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'session',
|
||||
modelConfig: {
|
||||
temp: 0.7,
|
||||
top_p: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [customModelAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate subagent is registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('custom-model-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle subagent with run config', async () => {
|
||||
const limitedAgent: SubagentConfig = {
|
||||
name: 'limited-agent',
|
||||
description: 'Agent with execution limits',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'session',
|
||||
runConfig: {
|
||||
max_turns: 5,
|
||||
max_time_minutes: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [limitedAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate subagent is registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('limited-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle subagent with specific tools', async () => {
|
||||
const toolRestrictedAgent: SubagentConfig = {
|
||||
name: 'read-only-agent',
|
||||
description: 'Agent that can only read files',
|
||||
systemPrompt:
|
||||
'You are a file reading assistant. Read files when asked.',
|
||||
level: 'session',
|
||||
tools: ['read_file', 'list_directory'],
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [toolRestrictedAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate subagent is registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('read-only-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subagent Execution', () => {
|
||||
it('should delegate task to subagent when appropriate', async () => {
|
||||
const fileReaderAgent: SubagentConfig = {
|
||||
name: 'file-reader',
|
||||
description: 'Reads and reports file contents',
|
||||
systemPrompt: `You are a file reading assistant. When given a task to read a file, use the read_file tool to read it and report its contents back. Be concise in your response.`,
|
||||
level: 'session',
|
||||
tools: ['read_file', 'list_directory'],
|
||||
};
|
||||
|
||||
const testFile = helper.getPath('test.txt');
|
||||
const q = query({
|
||||
prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [fileReaderAgent],
|
||||
debug: false,
|
||||
permissionMode: 'yolo',
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let foundTaskTool = false;
|
||||
let taskToolUseId: string | null = null;
|
||||
let foundSubagentToolCall = false;
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
// Check for task tool use in content blocks (main agent calling subagent)
|
||||
const taskToolBlocks = findToolUseBlocks(message, 'task');
|
||||
if (taskToolBlocks.length > 0) {
|
||||
foundTaskTool = true;
|
||||
taskToolUseId = taskToolBlocks[0].id;
|
||||
}
|
||||
|
||||
// Check if this message is from a subagent (has parent_tool_use_id)
|
||||
if (message.parent_tool_use_id !== null) {
|
||||
// This is a subagent message
|
||||
const subagentToolBlocks = findToolUseBlocks(message);
|
||||
if (subagentToolBlocks.length > 0) {
|
||||
foundSubagentToolCall = true;
|
||||
// Verify parent_tool_use_id matches the task tool use id
|
||||
expect(message.parent_tool_use_id).toBe(taskToolUseId);
|
||||
}
|
||||
}
|
||||
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate task tool was used (subagent delegation)
|
||||
expect(foundTaskTool).toBe(true);
|
||||
expect(taskToolUseId).not.toBeNull();
|
||||
|
||||
// Validate subagent actually made tool calls with proper parent_tool_use_id
|
||||
expect(foundSubagentToolCall).toBe(true);
|
||||
|
||||
// Validate we got a response
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate successful completion
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
}, 60000); // Increase timeout for subagent execution
|
||||
|
||||
it('should complete simple task with subagent', async () => {
|
||||
const simpleTaskAgent: SubagentConfig = {
|
||||
name: 'simple-calculator',
|
||||
description: 'Performs simple arithmetic calculations',
|
||||
systemPrompt:
|
||||
'You are a calculator. When given a math problem, solve it and provide just the answer.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Use the simple-calculator subagent to calculate 15 + 27.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [simpleTaskAgent],
|
||||
debug: false,
|
||||
permissionMode: 'yolo',
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let foundTaskTool = false;
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
// Check for task tool use (main agent delegating to subagent)
|
||||
const toolUseBlock = message.message.content.find(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use' && block.name === 'task',
|
||||
);
|
||||
if (toolUseBlock) {
|
||||
foundTaskTool = true;
|
||||
}
|
||||
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate task tool was used (subagent was called)
|
||||
expect(foundTaskTool).toBe(true);
|
||||
|
||||
// Validate we got a response
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate successful completion
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('should verify subagent execution with comprehensive parent_tool_use_id checks', async () => {
|
||||
const comprehensiveAgent: SubagentConfig = {
|
||||
name: 'comprehensive-agent',
|
||||
description: 'Agent for comprehensive testing',
|
||||
systemPrompt:
|
||||
'You are a helpful assistant. When asked to list files, use the list_directory tool.',
|
||||
level: 'session',
|
||||
tools: ['list_directory', 'read_file'],
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: `Use the comprehensive-agent subagent to list the files in ${testWorkDir}.`,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [comprehensiveAgent],
|
||||
debug: false,
|
||||
permissionMode: 'yolo',
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
let taskToolUseId: string | null = null;
|
||||
const subagentToolCalls: ToolUseBlock[] = [];
|
||||
const mainAgentToolCalls: ToolUseBlock[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
// Collect all tool use blocks
|
||||
const toolUseBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
|
||||
for (const toolUse of toolUseBlocks) {
|
||||
if (toolUse.name === 'task') {
|
||||
// This is the main agent calling the subagent
|
||||
taskToolUseId = toolUse.id;
|
||||
mainAgentToolCalls.push(toolUse);
|
||||
}
|
||||
|
||||
// If this message has parent_tool_use_id, it's from a subagent
|
||||
if (message.parent_tool_use_id !== null) {
|
||||
subagentToolCalls.push(toolUse);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Criterion 1: When a subagent is called, there must be a 'task' tool being called
|
||||
expect(taskToolUseId).not.toBeNull();
|
||||
expect(mainAgentToolCalls.length).toBeGreaterThan(0);
|
||||
expect(mainAgentToolCalls.some((tc) => tc.name === 'task')).toBe(true);
|
||||
|
||||
// Criterion 2: A tool call from a subagent is identified by a non-null parent_tool_use_id
|
||||
// All subagent tool calls should have parent_tool_use_id set to the task tool's id
|
||||
expect(subagentToolCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify all subagent messages have the correct parent_tool_use_id
|
||||
const subagentMessages = messages.filter(
|
||||
(msg): msg is SDKMessage & { parent_tool_use_id: string } =>
|
||||
isSDKAssistantMessage(msg) && msg.parent_tool_use_id !== null,
|
||||
);
|
||||
|
||||
expect(subagentMessages.length).toBeGreaterThan(0);
|
||||
for (const subagentMsg of subagentMessages) {
|
||||
expect(subagentMsg.parent_tool_use_id).toBe(taskToolUseId);
|
||||
}
|
||||
|
||||
// Verify no main agent tool calls (except task) have parent_tool_use_id
|
||||
const mainAgentMessages = messages.filter(
|
||||
(msg): msg is SDKMessage =>
|
||||
isSDKAssistantMessage(msg) && msg.parent_tool_use_id === null,
|
||||
);
|
||||
|
||||
for (const mainMsg of mainAgentMessages) {
|
||||
if (isSDKAssistantMessage(mainMsg)) {
|
||||
// Main agent messages should not have parent_tool_use_id
|
||||
expect(mainMsg.parent_tool_use_id).toBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// Validate successful completion
|
||||
assertSuccessfulCompletion(messages);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe('Subagent Error Handling', () => {
|
||||
it('should handle empty subagent array', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Should still work with empty agents array
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle subagent with minimal configuration', async () => {
|
||||
const minimalAgent: SubagentConfig = {
|
||||
name: 'minimal-agent',
|
||||
description: 'Minimal configuration agent',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [minimalAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate minimal agent is registered
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('minimal-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subagent Integration', () => {
|
||||
it('should work with other SDK options', async () => {
|
||||
const testAgent: SubagentConfig = {
|
||||
name: 'test-agent',
|
||||
description: 'Test agent for integration',
|
||||
systemPrompt: 'You are a test assistant.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [testAgent],
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
permissionMode: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate subagent works with debug mode
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(systemMessage!.agents).toContain('test-agent');
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain session consistency with subagents', async () => {
|
||||
const sessionAgent: SubagentConfig = {
|
||||
name: 'session-agent',
|
||||
description: 'Agent for session testing',
|
||||
systemPrompt: 'You are a session test assistant.',
|
||||
level: 'session',
|
||||
};
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testWorkDir,
|
||||
agents: [sessionAgent],
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate session consistency
|
||||
const systemMessage = findSystemMessage(messages, 'init');
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.session_id).toBeDefined();
|
||||
expect(systemMessage!.uuid).toBeDefined();
|
||||
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
|
||||
expect(systemMessage!.agents).toContain('session-agent');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,317 +0,0 @@
|
||||
/**
|
||||
* E2E tests for system controller features:
|
||||
* - setModel API for dynamic model switching
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
type SDKUserMessage,
|
||||
} from '../../src/types/protocol.js';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
/**
|
||||
* Factory function that creates a streaming input with a control point.
|
||||
* After the first message is yielded, the generator waits for a resume signal,
|
||||
* allowing the test code to call query instance methods like setModel.
|
||||
*
|
||||
* @param firstMessage - The first user message to send
|
||||
* @param secondMessage - The second user message to send after control operations
|
||||
* @returns Object containing the async generator and a resume function
|
||||
*/
|
||||
function createStreamingInputWithControlPoint(
|
||||
firstMessage: string,
|
||||
secondMessage: string,
|
||||
): {
|
||||
generator: AsyncIterable<SDKUserMessage>;
|
||||
resume: () => void;
|
||||
} {
|
||||
let resumeResolve: (() => void) | null = null;
|
||||
const resumePromise = new Promise<void>((resolve) => {
|
||||
resumeResolve = resolve;
|
||||
});
|
||||
|
||||
const generator = (async function* () {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: firstMessage,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
await resumePromise;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: secondMessage,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
})();
|
||||
|
||||
const resume = () => {
|
||||
if (resumeResolve) {
|
||||
resumeResolve();
|
||||
}
|
||||
};
|
||||
|
||||
return { generator, resume };
|
||||
}
|
||||
|
||||
describe('System Control (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('system-control');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('setModel API', () => {
|
||||
it('should change model dynamically during streaming input', async () => {
|
||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||
'Tell me the model name.',
|
||||
'Tell me the model name now again.',
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
model: 'qwen3-max',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resolvers: {
|
||||
first?: () => void;
|
||||
second?: () => void;
|
||||
} = {};
|
||||
const firstResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.first = resolve;
|
||||
});
|
||||
const secondResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.second = resolve;
|
||||
});
|
||||
|
||||
let firstResponseReceived = false;
|
||||
let secondResponseReceived = false;
|
||||
const systemMessages: Array<{ model?: string }> = [];
|
||||
|
||||
// Consume messages in a single loop
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isSDKSystemMessage(message)) {
|
||||
systemMessages.push({ model: message.model });
|
||||
}
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
} else if (!secondResponseReceived) {
|
||||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Wait for first response
|
||||
await Promise.race([
|
||||
firstResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for first response')),
|
||||
15000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(firstResponseReceived).toBe(true);
|
||||
|
||||
// Perform control operation: set model
|
||||
await q.setModel('qwen3-vl-plus');
|
||||
|
||||
// Resume the input stream
|
||||
resume();
|
||||
|
||||
// Wait for second response
|
||||
await Promise.race([
|
||||
secondResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for second response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(secondResponseReceived).toBe(true);
|
||||
|
||||
// Verify system messages - model should change from qwen3-max to qwen3-vl-plus
|
||||
expect(systemMessages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
|
||||
expect(systemMessages[1].model).toBe('qwen3-vl-plus');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple model changes in sequence', async () => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
let resumeResolve1: (() => void) | null = null;
|
||||
let resumeResolve2: (() => void) | null = null;
|
||||
const resumePromise1 = new Promise<void>((resolve) => {
|
||||
resumeResolve1 = resolve;
|
||||
});
|
||||
const resumePromise2 = new Promise<void>((resolve) => {
|
||||
resumeResolve2 = resolve;
|
||||
});
|
||||
|
||||
const generator = (async function* () {
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: { role: 'user', content: 'First message' },
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await resumePromise1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: { role: 'user', content: 'Second message' },
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await resumePromise2;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: { role: 'user', content: 'Third message' },
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
})();
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
model: 'qwen3-max',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const systemMessages: Array<{ model?: string }> = [];
|
||||
let responseCount = 0;
|
||||
const resolvers: Array<() => void> = [];
|
||||
const responsePromises = [
|
||||
new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||
new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||
new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||
];
|
||||
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isSDKSystemMessage(message)) {
|
||||
systemMessages.push({ model: message.model });
|
||||
}
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
if (responseCount < resolvers.length) {
|
||||
resolvers[responseCount]?.();
|
||||
responseCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Wait for first response
|
||||
await Promise.race([
|
||||
responsePromises[0],
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout 1')), 10000),
|
||||
),
|
||||
]);
|
||||
|
||||
// First model change
|
||||
await q.setModel('qwen3-turbo');
|
||||
resumeResolve1?.();
|
||||
|
||||
// Wait for second response
|
||||
await Promise.race([
|
||||
responsePromises[1],
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout 2')), 10000),
|
||||
),
|
||||
]);
|
||||
|
||||
// Second model change
|
||||
await q.setModel('qwen3-vl-plus');
|
||||
resumeResolve2?.();
|
||||
|
||||
// Wait for third response
|
||||
await Promise.race([
|
||||
responsePromises[2],
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout 3')), 10000),
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify we received system messages for each model
|
||||
expect(systemMessages.length).toBeGreaterThanOrEqual(3);
|
||||
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
|
||||
expect(systemMessages[1].model).toBe('qwen3-turbo');
|
||||
expect(systemMessages[2].model).toBe('qwen3-vl-plus');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when setModel is called on closed query', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
model: 'qwen3-max',
|
||||
},
|
||||
});
|
||||
|
||||
await q.close();
|
||||
|
||||
await expect(q.setModel('qwen3-turbo')).rejects.toThrow(
|
||||
'Query is closed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,970 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* SDK E2E Test Helper
|
||||
* Provides utilities for SDK e2e tests including test isolation,
|
||||
* file management, MCP server setup, and common test utilities.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile, readFile, rm, chmod } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKAssistantMessage,
|
||||
SDKSystemMessage,
|
||||
SDKUserMessage,
|
||||
ContentBlock,
|
||||
TextBlock,
|
||||
ToolUseBlock,
|
||||
} from '../../src/types/protocol.js';
|
||||
import {
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKResultMessage,
|
||||
} from '../../src/types/protocol.js';
|
||||
|
||||
// ============================================================================
|
||||
// Core Test Helper Class
|
||||
// ============================================================================
|
||||
|
||||
export interface SDKTestHelperOptions {
|
||||
/**
|
||||
* Optional settings for .qwen/settings.json
|
||||
*/
|
||||
settings?: Record<string, unknown>;
|
||||
/**
|
||||
* Whether to create .qwen/settings.json
|
||||
*/
|
||||
createQwenConfig?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for SDK E2E tests
|
||||
* Provides isolated test environments for each test case
|
||||
*/
|
||||
export class SDKTestHelper {
|
||||
testDir: string | null = null;
|
||||
testName?: string;
|
||||
private baseDir: string;
|
||||
|
||||
constructor() {
|
||||
this.baseDir = process.env['E2E_TEST_FILE_DIR']!;
|
||||
if (!this.baseDir) {
|
||||
throw new Error('E2E_TEST_FILE_DIR environment variable not set');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup an isolated test directory for a specific test
|
||||
*/
|
||||
async setup(
|
||||
testName: string,
|
||||
options: SDKTestHelperOptions = {},
|
||||
): Promise<string> {
|
||||
this.testName = testName;
|
||||
const sanitizedName = this.sanitizeTestName(testName);
|
||||
this.testDir = join(this.baseDir, sanitizedName);
|
||||
|
||||
await mkdir(this.testDir, { recursive: true });
|
||||
|
||||
// Optionally create .qwen/settings.json for CLI configuration
|
||||
if (options.createQwenConfig) {
|
||||
const qwenDir = join(this.testDir, '.qwen');
|
||||
await mkdir(qwenDir, { recursive: true });
|
||||
|
||||
const settings = {
|
||||
telemetry: {
|
||||
enabled: false, // SDK tests don't need telemetry
|
||||
},
|
||||
...options.settings,
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
join(qwenDir, 'settings.json'),
|
||||
JSON.stringify(settings, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
return this.testDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file in the test directory
|
||||
*/
|
||||
async createFile(fileName: string, content: string): Promise<string> {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
const filePath = join(this.testDir, fileName);
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from the test directory
|
||||
*/
|
||||
async readFile(fileName: string): Promise<string> {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
const filePath = join(this.testDir, fileName);
|
||||
return await readFile(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subdirectory in the test directory
|
||||
*/
|
||||
async mkdir(dirName: string): Promise<string> {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
const dirPath = join(this.testDir, dirName);
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists in the test directory
|
||||
*/
|
||||
fileExists(fileName: string): boolean {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
const filePath = join(this.testDir, fileName);
|
||||
return existsSync(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to a file in the test directory
|
||||
*/
|
||||
getPath(fileName: string): string {
|
||||
if (!this.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
return join(this.testDir, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup test directory
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.testDir && process.env['KEEP_OUTPUT'] !== 'true') {
|
||||
try {
|
||||
await rm(this.testDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
if (process.env['VERBOSE'] === 'true') {
|
||||
console.warn('Cleanup warning:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize test name to create valid directory name
|
||||
*/
|
||||
private sanitizeTestName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.substring(0, 100); // Limit length
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Server Utilities
|
||||
// ============================================================================
|
||||
|
||||
export interface MCPServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export interface MCPServerResult {
|
||||
scriptPath: string;
|
||||
config: MCPServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in MCP server template: Math server with add and multiply tools
|
||||
*/
|
||||
const MCP_MATH_SERVER_SCRIPT = `#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const fs = require('fs');
|
||||
|
||||
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
|
||||
const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true';
|
||||
function debug(msg) {
|
||||
if (debugEnabled) {
|
||||
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
|
||||
}
|
||||
}
|
||||
|
||||
debug('MCP server starting...');
|
||||
|
||||
// Simple JSON-RPC implementation for MCP
|
||||
class SimpleJSONRPC {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
this.rl.on('line', (line) => {
|
||||
debug(\`Received line: \${line}\`);
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
debug(\`Parsed message: \${JSON.stringify(message)}\`);
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
debug(\`Parse error: \${e.message}\`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
const msgStr = JSON.stringify(message);
|
||||
debug(\`Sending message: \${msgStr}\`);
|
||||
process.stdout.write(msgStr + '\\n');
|
||||
}
|
||||
|
||||
async handleMessage(message) {
|
||||
if (message.method && this.handlers.has(message.method)) {
|
||||
try {
|
||||
const result = await this.handlers.get(message.method)(message.params || {});
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Method not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
on(method, handler) {
|
||||
this.handlers.set(method, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP server
|
||||
const rpc = new SimpleJSONRPC();
|
||||
|
||||
// Handle initialize
|
||||
rpc.on('initialize', async (params) => {
|
||||
debug('Handling initialize request');
|
||||
return {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'test-math-server',
|
||||
version: '1.0.0'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tools/list
|
||||
rpc.on('tools/list', async () => {
|
||||
debug('Handling tools/list request');
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add two numbers together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' }
|
||||
},
|
||||
required: ['a', 'b']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'multiply',
|
||||
description: 'Multiply two numbers together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' }
|
||||
},
|
||||
required: ['a', 'b']
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tools/call
|
||||
rpc.on('tools/call', async (params) => {
|
||||
debug(\`Handling tools/call request for tool: \${params.name}\`);
|
||||
|
||||
if (params.name === 'add') {
|
||||
const { a, b } = params.arguments;
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: String(a + b)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (params.name === 'multiply') {
|
||||
const { a, b } = params.arguments;
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: String(a * b)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Unknown tool: ' + params.name);
|
||||
});
|
||||
|
||||
// Send initialization notification
|
||||
rpc.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialized'
|
||||
});
|
||||
`;
|
||||
|
||||
/**
|
||||
* Create an MCP server script in the test directory
|
||||
* @param helper - SDKTestHelper instance
|
||||
* @param type - Type of MCP server ('math' or provide custom script)
|
||||
* @param serverName - Name of the MCP server (default: 'test-math-server')
|
||||
* @param customScript - Custom MCP server script (if type is not 'math')
|
||||
* @returns Object with scriptPath and config
|
||||
*/
|
||||
export async function createMCPServer(
|
||||
helper: SDKTestHelper,
|
||||
type: 'math' | 'custom' = 'math',
|
||||
serverName: string = 'test-math-server',
|
||||
customScript?: string,
|
||||
): Promise<MCPServerResult> {
|
||||
if (!helper.testDir) {
|
||||
throw new Error('Test directory not initialized. Call setup() first.');
|
||||
}
|
||||
|
||||
const script = type === 'math' ? MCP_MATH_SERVER_SCRIPT : customScript;
|
||||
if (!script) {
|
||||
throw new Error('Custom script required when type is "custom"');
|
||||
}
|
||||
|
||||
const scriptPath = join(helper.testDir, `${serverName}.cjs`);
|
||||
await writeFile(scriptPath, script, 'utf-8');
|
||||
|
||||
// Make script executable on Unix-like systems
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(scriptPath, 0o755);
|
||||
}
|
||||
|
||||
return {
|
||||
scriptPath,
|
||||
config: {
|
||||
command: 'node',
|
||||
args: [scriptPath],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message & Content Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract text from ContentBlock array
|
||||
*/
|
||||
export function extractText(content: ContentBlock[]): string {
|
||||
return content
|
||||
.filter((block): block is TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect messages by type
|
||||
*/
|
||||
export function collectMessagesByType<T extends SDKMessage>(
|
||||
messages: SDKMessage[],
|
||||
predicate: (msg: SDKMessage) => msg is T,
|
||||
): T[] {
|
||||
return messages.filter(predicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tool use blocks in a message
|
||||
*/
|
||||
export function findToolUseBlocks(
|
||||
message: SDKAssistantMessage,
|
||||
toolName?: string,
|
||||
): ToolUseBlock[] {
|
||||
const toolUseBlocks = message.message.content.filter(
|
||||
(block): block is ToolUseBlock => block.type === 'tool_use',
|
||||
);
|
||||
|
||||
if (toolName) {
|
||||
return toolUseBlocks.filter((block) => block.name === toolName);
|
||||
}
|
||||
|
||||
return toolUseBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all assistant text from messages
|
||||
*/
|
||||
export function getAssistantText(messages: SDKMessage[]): string {
|
||||
return messages
|
||||
.filter(isSDKAssistantMessage)
|
||||
.map((msg) => extractText(msg.message.content))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find system message with optional subtype filter
|
||||
*/
|
||||
export function findSystemMessage(
|
||||
messages: SDKMessage[],
|
||||
subtype?: string,
|
||||
): SDKSystemMessage | null {
|
||||
const systemMessages = messages.filter(isSDKSystemMessage);
|
||||
|
||||
if (subtype) {
|
||||
return systemMessages.find((msg) => msg.subtype === subtype) || null;
|
||||
}
|
||||
|
||||
return systemMessages[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all tool calls in messages
|
||||
*/
|
||||
export function findToolCalls(
|
||||
messages: SDKMessage[],
|
||||
toolName?: string,
|
||||
): Array<{ message: SDKAssistantMessage; toolUse: ToolUseBlock }> {
|
||||
const results: Array<{
|
||||
message: SDKAssistantMessage;
|
||||
toolUse: ToolUseBlock;
|
||||
}> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const toolUseBlocks = findToolUseBlocks(message, toolName);
|
||||
for (const toolUse of toolUseBlocks) {
|
||||
results.push({ message, toolUse });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tool result for a specific tool use ID
|
||||
*/
|
||||
export function findToolResult(
|
||||
messages: SDKMessage[],
|
||||
toolUseId: string,
|
||||
): { content: string; isError: boolean } | null {
|
||||
for (const message of messages) {
|
||||
if (message.type === 'user' && 'message' in message) {
|
||||
const userMsg = message as SDKUserMessage;
|
||||
const content = userMsg.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (
|
||||
block.type === 'tool_result' &&
|
||||
(block as { tool_use_id?: string }).tool_use_id === toolUseId
|
||||
) {
|
||||
const resultBlock = block as {
|
||||
content?: string | ContentBlock[];
|
||||
is_error?: boolean;
|
||||
};
|
||||
let resultContent = '';
|
||||
if (typeof resultBlock.content === 'string') {
|
||||
resultContent = resultBlock.content;
|
||||
} else if (Array.isArray(resultBlock.content)) {
|
||||
resultContent = resultBlock.content
|
||||
.filter((b): b is TextBlock => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('');
|
||||
}
|
||||
return {
|
||||
content: resultContent,
|
||||
isError: resultBlock.is_error ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all tool results for a specific tool name
|
||||
*/
|
||||
export function findToolResults(
|
||||
messages: SDKMessage[],
|
||||
toolName: string,
|
||||
): Array<{ toolUseId: string; content: string; isError: boolean }> {
|
||||
const results: Array<{
|
||||
toolUseId: string;
|
||||
content: string;
|
||||
isError: boolean;
|
||||
}> = [];
|
||||
|
||||
// First find all tool calls for this tool
|
||||
const toolCalls = findToolCalls(messages, toolName);
|
||||
|
||||
// Then find the result for each tool call
|
||||
for (const { toolUse } of toolCalls) {
|
||||
const result = findToolResult(messages, toolUse.id);
|
||||
if (result) {
|
||||
results.push({
|
||||
toolUseId: toolUse.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all tool result blocks from messages (without requiring tool name)
|
||||
*/
|
||||
export function findAllToolResultBlocks(
|
||||
messages: SDKMessage[],
|
||||
): Array<{ toolUseId: string; content: string; isError: boolean }> {
|
||||
const results: Array<{
|
||||
toolUseId: string;
|
||||
content: string;
|
||||
isError: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type === 'user' && 'message' in message) {
|
||||
const userMsg = message as SDKUserMessage;
|
||||
const content = userMsg.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result' && 'tool_use_id' in block) {
|
||||
const resultBlock = block as {
|
||||
tool_use_id: string;
|
||||
content?: string | ContentBlock[];
|
||||
is_error?: boolean;
|
||||
};
|
||||
let resultContent = '';
|
||||
if (typeof resultBlock.content === 'string') {
|
||||
resultContent = resultBlock.content;
|
||||
} else if (Array.isArray(resultBlock.content)) {
|
||||
resultContent = (resultBlock.content as ContentBlock[])
|
||||
.filter((b): b is TextBlock => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('');
|
||||
}
|
||||
results.push({
|
||||
toolUseId: resultBlock.tool_use_id,
|
||||
content: resultContent,
|
||||
isError: resultBlock.is_error ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any tool results exist in messages
|
||||
*/
|
||||
export function hasAnyToolResults(messages: SDKMessage[]): boolean {
|
||||
return findAllToolResultBlocks(messages).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any successful (non-error) tool results exist
|
||||
*/
|
||||
export function hasSuccessfulToolResults(messages: SDKMessage[]): boolean {
|
||||
return findAllToolResultBlocks(messages).some((r) => !r.isError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any error tool results exist
|
||||
*/
|
||||
export function hasErrorToolResults(messages: SDKMessage[]): boolean {
|
||||
return findAllToolResultBlocks(messages).some((r) => r.isError);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Streaming Input Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a simple streaming input from an array of message contents
|
||||
*/
|
||||
export async function* createStreamingInput(
|
||||
messageContents: string[],
|
||||
sessionId?: string,
|
||||
): AsyncIterable<SDKUserMessage> {
|
||||
const sid = sessionId || crypto.randomUUID();
|
||||
|
||||
for (const content of messageContents) {
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sid,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: content,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
// Small delay between messages
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a controlled streaming input with pause/resume capability
|
||||
*/
|
||||
export function createControlledStreamingInput(
|
||||
messageContents: string[],
|
||||
sessionId?: string,
|
||||
): {
|
||||
generator: AsyncIterable<SDKUserMessage>;
|
||||
resume: () => void;
|
||||
resumeAll: () => void;
|
||||
} {
|
||||
const sid = sessionId || crypto.randomUUID();
|
||||
const resumeResolvers: Array<() => void> = [];
|
||||
const resumePromises: Array<Promise<void>> = [];
|
||||
|
||||
// Create a resume promise for each message after the first
|
||||
for (let i = 1; i < messageContents.length; i++) {
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resumeResolvers.push(resolve);
|
||||
});
|
||||
resumePromises.push(promise);
|
||||
}
|
||||
|
||||
const generator = (async function* () {
|
||||
// Yield first message immediately
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sid,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: messageContents[0],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
|
||||
// For subsequent messages, wait for resume
|
||||
for (let i = 1; i < messageContents.length; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await resumePromises[i - 1];
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sid,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: messageContents[i],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as SDKUserMessage;
|
||||
}
|
||||
})();
|
||||
|
||||
let currentResumeIndex = 0;
|
||||
|
||||
return {
|
||||
generator,
|
||||
resume: () => {
|
||||
if (currentResumeIndex < resumeResolvers.length) {
|
||||
resumeResolvers[currentResumeIndex]();
|
||||
currentResumeIndex++;
|
||||
}
|
||||
},
|
||||
resumeAll: () => {
|
||||
resumeResolvers.forEach((resolve) => resolve());
|
||||
currentResumeIndex = resumeResolvers.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Assertion Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Assert that messages follow expected type sequence
|
||||
*/
|
||||
export function assertMessageSequence(
|
||||
messages: SDKMessage[],
|
||||
expectedTypes: string[],
|
||||
): void {
|
||||
const actualTypes = messages.map((msg) => msg.type);
|
||||
|
||||
if (actualTypes.length < expectedTypes.length) {
|
||||
throw new Error(
|
||||
`Expected at least ${expectedTypes.length} messages, got ${actualTypes.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < expectedTypes.length; i++) {
|
||||
if (actualTypes[i] !== expectedTypes[i]) {
|
||||
throw new Error(
|
||||
`Expected message ${i} to be type '${expectedTypes[i]}', got '${actualTypes[i]}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a specific tool was called
|
||||
*/
|
||||
export function assertToolCalled(
|
||||
messages: SDKMessage[],
|
||||
toolName: string,
|
||||
): void {
|
||||
const toolCalls = findToolCalls(messages, toolName);
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
const allToolCalls = findToolCalls(messages);
|
||||
const allToolNames = allToolCalls.map((tc) => tc.toolUse.name);
|
||||
throw new Error(
|
||||
`Expected tool '${toolName}' to be called. Found tools: ${allToolNames.length > 0 ? allToolNames.join(', ') : 'none'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the conversation completed successfully
|
||||
*/
|
||||
export function assertSuccessfulCompletion(messages: SDKMessage[]): void {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
if (!isSDKResultMessage(lastMessage)) {
|
||||
throw new Error(
|
||||
`Expected last message to be a result message, got '${lastMessage.type}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (lastMessage.subtype !== 'success') {
|
||||
throw new Error(
|
||||
`Expected successful completion, got result subtype '${lastMessage.subtype}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a condition to be true with timeout
|
||||
*/
|
||||
export async function waitFor(
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
options: {
|
||||
timeout?: number;
|
||||
interval?: number;
|
||||
errorMessage?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const {
|
||||
timeout = 5000,
|
||||
interval = 100,
|
||||
errorMessage = 'Condition not met within timeout',
|
||||
} = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const result = await predicate();
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Debug and Validation Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate model output and warn about unexpected content
|
||||
* Inspired by integration-tests test-helper
|
||||
*/
|
||||
export function validateModelOutput(
|
||||
result: string,
|
||||
expectedContent: string | (string | RegExp)[] | null = null,
|
||||
testName = '',
|
||||
): boolean {
|
||||
// First, check if there's any output at all
|
||||
if (!result || result.trim().length === 0) {
|
||||
throw new Error('Expected model to return some output');
|
||||
}
|
||||
|
||||
// If expectedContent is provided, check for it and warn if missing
|
||||
if (expectedContent) {
|
||||
const contents = Array.isArray(expectedContent)
|
||||
? expectedContent
|
||||
: [expectedContent];
|
||||
const missingContent = contents.filter((content) => {
|
||||
if (typeof content === 'string') {
|
||||
return !result.toLowerCase().includes(content.toLowerCase());
|
||||
} else if (content instanceof RegExp) {
|
||||
return !content.test(result);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (missingContent.length > 0) {
|
||||
console.warn(
|
||||
`Warning: Model did not include expected content in response: ${missingContent.join(', ')}.`,
|
||||
'This is not ideal but not a test failure.',
|
||||
);
|
||||
console.warn(
|
||||
'The tool was called successfully, which is the main requirement.',
|
||||
);
|
||||
return false;
|
||||
} else if (process.env['VERBOSE'] === 'true') {
|
||||
console.log(`${testName}: Model output validated successfully.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print debug information when tests fail
|
||||
*/
|
||||
export function printDebugInfo(
|
||||
messages: SDKMessage[],
|
||||
context: Record<string, unknown> = {},
|
||||
): void {
|
||||
console.error('Test failed - Debug info:');
|
||||
console.error('Message count:', messages.length);
|
||||
|
||||
// Print message types
|
||||
const messageTypes = messages.map((m) => m.type);
|
||||
console.error('Message types:', messageTypes.join(', '));
|
||||
|
||||
// Print assistant text
|
||||
const assistantText = getAssistantText(messages);
|
||||
console.error(
|
||||
'Assistant text (first 500 chars):',
|
||||
assistantText.substring(0, 500),
|
||||
);
|
||||
if (assistantText.length > 500) {
|
||||
console.error(
|
||||
'Assistant text (last 500 chars):',
|
||||
assistantText.substring(assistantText.length - 500),
|
||||
);
|
||||
}
|
||||
|
||||
// Print tool calls
|
||||
const toolCalls = findToolCalls(messages);
|
||||
console.error(
|
||||
'Tool calls found:',
|
||||
toolCalls.map((tc) => tc.toolUse.name),
|
||||
);
|
||||
|
||||
// Print any additional context provided
|
||||
Object.entries(context).forEach(([key, value]) => {
|
||||
console.error(`${key}:`, value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detailed error message for tool call expectations
|
||||
*/
|
||||
export function createToolCallErrorMessage(
|
||||
expectedTools: string | string[],
|
||||
foundTools: string[],
|
||||
messages: SDKMessage[],
|
||||
): string {
|
||||
const expectedStr = Array.isArray(expectedTools)
|
||||
? expectedTools.join(' or ')
|
||||
: expectedTools;
|
||||
|
||||
const assistantText = getAssistantText(messages);
|
||||
const preview = assistantText
|
||||
? assistantText.substring(0, 200) + '...'
|
||||
: 'no output';
|
||||
|
||||
return (
|
||||
`Expected to find ${expectedStr} tool call(s). ` +
|
||||
`Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` +
|
||||
`Output preview: ${preview}`
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shared Test Options Helper
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create shared test options with CLI path
|
||||
*/
|
||||
export function createSharedTestOptions(
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const TEST_CLI_PATH = process.env['TEST_CLI_PATH'];
|
||||
if (!TEST_CLI_PATH) {
|
||||
throw new Error('TEST_CLI_PATH environment variable not set');
|
||||
}
|
||||
|
||||
return {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1,748 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for tool control parameters:
|
||||
* - coreTools: Limit available tools to a specific set
|
||||
* - excludeTools: Block specific tools from execution
|
||||
* - allowedTools: Auto-approve specific tools without confirmation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isSDKAssistantMessage,
|
||||
type SDKMessage,
|
||||
} from '../../src/types/protocol.js';
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user