mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
chore: update ESLint configuration and improve TypeScript type handling
- Added additional test file patterns to ESLint configuration for better coverage. - Introduced new rule to ignore unused TypeScript variables starting with an underscore. - Updated `PermissionController` to set approval mode correctly. - Refactored timeout constants in `Query` class for better maintainability. - Enhanced E2E tests for permission control and multi-turn conversations with clearer prompts and expectations. - Removed outdated simple query test file and added new single-turn test suite for better organization.
This commit is contained in:
@@ -150,7 +150,7 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'],
|
||||||
plugins: {
|
plugins: {
|
||||||
vitest,
|
vitest,
|
||||||
},
|
},
|
||||||
@@ -158,6 +158,14 @@ export default tseslint.config(
|
|||||||
...vitest.configs.recommended.rules,
|
...vitest.configs.recommended.rules,
|
||||||
'vitest/expect-expect': 'off',
|
'vitest/expect-expect': 'off',
|
||||||
'vitest/no-commented-out-tests': 'off',
|
'vitest/no-commented-out-tests': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// extra settings for scripts that we run directly with node
|
// extra settings for scripts that we run directly with node
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
WaitingToolCall,
|
WaitingToolCall,
|
||||||
ToolExecuteConfirmationDetails,
|
ToolExecuteConfirmationDetails,
|
||||||
ToolMcpConfirmationDetails,
|
ToolMcpConfirmationDetails,
|
||||||
|
ApprovalMode,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
InputFormat,
|
InputFormat,
|
||||||
@@ -208,6 +209,7 @@ export class PermissionController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.context.permissionMode = mode;
|
this.context.permissionMode = mode;
|
||||||
|
this.context.config.setApprovalMode(mode as ApprovalMode);
|
||||||
|
|
||||||
if (this.context.debugMode) {
|
if (this.context.debugMode) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -293,27 +293,6 @@ export interface ConfigParameters {
|
|||||||
inputFormat?: InputFormat;
|
inputFormat?: InputFormat;
|
||||||
outputFormat?: OutputFormat;
|
outputFormat?: OutputFormat;
|
||||||
skipStartupContext?: boolean;
|
skipStartupContext?: boolean;
|
||||||
inputFormat?: InputFormat;
|
|
||||||
outputFormat?: OutputFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeConfigOutputFormat(
|
|
||||||
format: OutputFormat | undefined,
|
|
||||||
): OutputFormat | undefined {
|
|
||||||
if (!format) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
switch (format) {
|
|
||||||
case 'stream-json':
|
|
||||||
return OutputFormat.STREAM_JSON;
|
|
||||||
case 'json':
|
|
||||||
case OutputFormat.JSON:
|
|
||||||
return OutputFormat.JSON;
|
|
||||||
case 'text':
|
|
||||||
case OutputFormat.TEXT:
|
|
||||||
default:
|
|
||||||
return OutputFormat.TEXT;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConfigOutputFormat(
|
function normalizeConfigOutputFormat(
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
* Implements AsyncIterator protocol for message consumption.
|
* Implements AsyncIterator protocol for message consumption.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Timeout constants (in milliseconds)
|
||||||
|
const PERMISSION_CALLBACK_TIMEOUT = 30000; // 30 seconds
|
||||||
|
const MCP_REQUEST_TIMEOUT = 30000; // 30 seconds
|
||||||
|
const CONTROL_REQUEST_TIMEOUT = 30000; // 30 seconds
|
||||||
|
const STREAM_CLOSE_TIMEOUT = 10000; // 10 seconds
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type {
|
import type {
|
||||||
CLIMessage,
|
CLIMessage,
|
||||||
@@ -373,11 +379,10 @@ export class Query implements AsyncIterable<CLIMessage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timeoutMs = 30000;
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => reject(new Error('Permission callback timeout')),
|
() => reject(new Error('Permission callback timeout')),
|
||||||
timeoutMs,
|
PERMISSION_CALLBACK_TIMEOUT,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -484,7 +489,7 @@ export class Query implements AsyncIterable<CLIMessage> {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
reject(new Error('MCP request timeout'));
|
reject(new Error('MCP request timeout'));
|
||||||
}, 30000);
|
}, MCP_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
const messageId = 'id' in message ? message.id : null;
|
const messageId = 'id' in message ? message.id : null;
|
||||||
|
|
||||||
@@ -603,7 +608,7 @@ export class Query implements AsyncIterable<CLIMessage> {
|
|||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
this.pendingControlRequests.delete(requestId);
|
this.pendingControlRequests.delete(requestId);
|
||||||
reject(new Error(`Control request timeout: ${subtype}`));
|
reject(new Error(`Control request timeout: ${subtype}`));
|
||||||
}, 300000);
|
}, CONTROL_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
this.pendingControlRequests.set(requestId, {
|
this.pendingControlRequests.set(requestId, {
|
||||||
resolve,
|
resolve,
|
||||||
@@ -771,8 +776,6 @@ export class Query implements AsyncIterable<CLIMessage> {
|
|||||||
this.sdkMcpTransports.size > 0 &&
|
this.sdkMcpTransports.size > 0 &&
|
||||||
this.firstResultReceivedPromise
|
this.firstResultReceivedPromise
|
||||||
) {
|
) {
|
||||||
const STREAM_CLOSE_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.firstResultReceivedPromise,
|
this.firstResultReceivedPromise,
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
|
|||||||
@@ -236,29 +236,28 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let receivedResponse = false;
|
let receivedResponse = false;
|
||||||
|
let endInputCalled = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const message of q) {
|
||||||
if (isCLIAssistantMessage(message)) {
|
if (isCLIAssistantMessage(message) && !endInputCalled) {
|
||||||
const textBlocks = message.message.content.filter(
|
const textBlocks = message.message.content.filter(
|
||||||
(block: ContentBlock): block is TextBlock =>
|
(block: ContentBlock): block is TextBlock =>
|
||||||
block.type === 'text',
|
block.type === 'text',
|
||||||
);
|
);
|
||||||
const text = textBlocks
|
const text = textBlocks.map((b: TextBlock) => b.text).join('');
|
||||||
.map((b: TextBlock) => b.text)
|
|
||||||
.join('')
|
|
||||||
.slice(0, 100);
|
|
||||||
|
|
||||||
expect(text.length).toBeGreaterThan(0);
|
expect(text.length).toBeGreaterThan(0);
|
||||||
receivedResponse = true;
|
receivedResponse = true;
|
||||||
|
|
||||||
// End input after receiving first response
|
// End input after receiving first response
|
||||||
q.endInput();
|
q.endInput();
|
||||||
break;
|
endInputCalled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(receivedResponse).toBe(true);
|
expect(receivedResponse).toBe(true);
|
||||||
|
expect(endInputCalled).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
await q.close();
|
await q.close();
|
||||||
}
|
}
|
||||||
@@ -389,9 +388,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const message of q) {
|
||||||
if (isCLIAssistantMessage(message)) {
|
// Consume all messages
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await q.close();
|
await q.close();
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ describe('Control Request/Response (E2E)', () => {
|
|||||||
'should set permission mode via control request during streaming input',
|
'should set permission mode via control request during streaming input',
|
||||||
async () => {
|
async () => {
|
||||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||||
'List files in the current directory',
|
'What is 1 + 1?',
|
||||||
'Now read the package.json file',
|
'What is 2 + 2?',
|
||||||
);
|
);
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
|
|||||||
@@ -83,21 +83,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
message: {
|
message: {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content:
|
content: 'What is 1 + 1?',
|
||||||
'What is the name of this project? Check the package.json file.',
|
|
||||||
},
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
} as CLIUserMessage;
|
|
||||||
|
|
||||||
// Wait a bit to simulate user thinking time
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'user',
|
|
||||||
session_id: sessionId,
|
|
||||||
message: {
|
|
||||||
role: 'user',
|
|
||||||
content: 'What version is it currently on?',
|
|
||||||
},
|
},
|
||||||
parent_tool_use_id: null,
|
parent_tool_use_id: null,
|
||||||
} as CLIUserMessage;
|
} as CLIUserMessage;
|
||||||
@@ -109,7 +95,19 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
message: {
|
message: {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: 'What are the main dependencies?',
|
content: 'What is 2 + 2?',
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
} as CLIUserMessage;
|
||||||
|
|
||||||
|
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,
|
parent_tool_use_id: null,
|
||||||
} as CLIUserMessage;
|
} as CLIUserMessage;
|
||||||
@@ -120,14 +118,13 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||||||
prompt: createMultiTurnConversation(),
|
prompt: createMultiTurnConversation(),
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: false,
|
debug: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
const messages: CLIMessage[] = [];
|
||||||
const assistantMessages: CLIAssistantMessage[] = [];
|
const assistantMessages: CLIAssistantMessage[] = [];
|
||||||
let turnCount = 0;
|
const assistantTexts: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const message of q) {
|
||||||
@@ -135,13 +132,18 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||||||
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
if (isCLIAssistantMessage(message)) {
|
||||||
assistantMessages.push(message);
|
assistantMessages.push(message);
|
||||||
turnCount++;
|
const text = extractText(message.message.content);
|
||||||
|
assistantTexts.push(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(3); // Should have responses to all 3 questions
|
expect(assistantMessages.length).toBeGreaterThanOrEqual(3);
|
||||||
expect(turnCount).toBeGreaterThanOrEqual(3);
|
|
||||||
|
// Validate content of responses
|
||||||
|
expect(assistantTexts[0]).toMatch(/2/);
|
||||||
|
expect(assistantTexts[1]).toMatch(/4/);
|
||||||
|
expect(assistantTexts[2]).toMatch(/6/);
|
||||||
} finally {
|
} finally {
|
||||||
await q.close();
|
await q.close();
|
||||||
}
|
}
|
||||||
@@ -160,7 +162,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
message: {
|
message: {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: 'My name is Alice. Hello!',
|
content:
|
||||||
|
'Suppose we have 3 rabbits and 4 carrots. How many animals are there?',
|
||||||
},
|
},
|
||||||
parent_tool_use_id: null,
|
parent_tool_use_id: null,
|
||||||
} as CLIUserMessage;
|
} as CLIUserMessage;
|
||||||
@@ -172,7 +175,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
message: {
|
message: {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: 'What is my name?',
|
content: 'How many animals are there? Only output the number',
|
||||||
},
|
},
|
||||||
parent_tool_use_id: null,
|
parent_tool_use_id: null,
|
||||||
} as CLIUserMessage;
|
} as CLIUserMessage;
|
||||||
@@ -197,11 +200,11 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||||||
|
|
||||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
// The second response should reference the name Alice
|
// The second response should reference the color blue
|
||||||
const secondResponse = extractText(
|
const secondResponse = extractText(
|
||||||
assistantMessages[1].message.content,
|
assistantMessages[1].message.content,
|
||||||
);
|
);
|
||||||
expect(secondResponse.toLowerCase()).toContain('alice');
|
expect(secondResponse.toLowerCase()).toContain('3');
|
||||||
} finally {
|
} finally {
|
||||||
await q.close();
|
await q.close();
|
||||||
}
|
}
|
||||||
@@ -211,72 +214,79 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Tool Usage in Multi-Turn', () => {
|
describe('Tool Usage in Multi-Turn', () => {
|
||||||
it('should handle tool usage across multiple turns', async () => {
|
it(
|
||||||
async function* createToolConversation(): AsyncIterable<CLIUserMessage> {
|
'should handle tool usage across multiple turns',
|
||||||
const sessionId = crypto.randomUUID();
|
async () => {
|
||||||
|
async function* createToolConversation(): AsyncIterable<CLIUserMessage> {
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
message: {
|
message: {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: 'List the files in the current directory',
|
content: 'Create a file named test.txt with content "hello"',
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
} as CLIUserMessage;
|
||||||
|
|
||||||
|
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 CLIUserMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: createToolConversation(),
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: '/tmp',
|
||||||
|
debug: false,
|
||||||
},
|
},
|
||||||
parent_tool_use_id: null,
|
});
|
||||||
} as CLIUserMessage;
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
const messages: CLIMessage[] = [];
|
||||||
|
let toolUseCount = 0;
|
||||||
|
const assistantMessages: CLIAssistantMessage[] = [];
|
||||||
|
|
||||||
yield {
|
try {
|
||||||
type: 'user',
|
for await (const message of q) {
|
||||||
session_id: sessionId,
|
messages.push(message);
|
||||||
message: {
|
|
||||||
role: 'user',
|
|
||||||
content: 'Now tell me about the package.json file specifically',
|
|
||||||
},
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
} as CLIUserMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = query({
|
if (isCLIAssistantMessage(message)) {
|
||||||
prompt: createToolConversation(),
|
assistantMessages.push(message);
|
||||||
options: {
|
const hasToolUseBlock = message.message.content.some(
|
||||||
...SHARED_TEST_OPTIONS,
|
(block: ContentBlock): block is ToolUseBlock =>
|
||||||
cwd: process.cwd(),
|
block.type === 'tool_use',
|
||||||
debug: false,
|
);
|
||||||
},
|
if (hasToolUseBlock) {
|
||||||
});
|
toolUseCount++;
|
||||||
|
}
|
||||||
const messages: CLIMessage[] = [];
|
|
||||||
let toolUseCount = 0;
|
|
||||||
let assistantCount = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
messages.push(message);
|
|
||||||
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
const hasToolUseBlock = message.message.content.some(
|
|
||||||
(block: ContentBlock): block is ToolUseBlock =>
|
|
||||||
block.type === 'tool_use',
|
|
||||||
);
|
|
||||||
if (hasToolUseBlock) {
|
|
||||||
toolUseCount++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
assistantCount++;
|
expect(toolUseCount).toBeGreaterThan(0);
|
||||||
}
|
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||||
}
|
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
// Validate second response mentions the file content
|
||||||
expect(toolUseCount).toBeGreaterThan(0); // Should use tools
|
const secondResponse = extractText(
|
||||||
expect(assistantCount).toBeGreaterThanOrEqual(2); // Should have responses to both questions
|
assistantMessages[assistantMessages.length - 1].message.content,
|
||||||
} finally {
|
);
|
||||||
await q.close();
|
expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/);
|
||||||
}
|
} finally {
|
||||||
}, 60000); //TEST_TIMEOUT,
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Message Flow and Sequencing', () => {
|
describe('Message Flow and Sequencing', () => {
|
||||||
@@ -435,10 +445,6 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const message of q) {
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
|
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should handle empty conversation without crashing
|
// Should handle empty conversation without crashing
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
|
|
||||||
const TEST_CLI_PATH =
|
const TEST_CLI_PATH =
|
||||||
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
||||||
const TEST_TIMEOUT = 1600000;
|
const TEST_TIMEOUT = 60000;
|
||||||
|
|
||||||
const SHARED_TEST_OPTIONS = {
|
const SHARED_TEST_OPTIONS = {
|
||||||
pathToQwenExecutable: TEST_CLI_PATH,
|
pathToQwenExecutable: TEST_CLI_PATH,
|
||||||
@@ -156,10 +156,11 @@ describe('Permission Control (E2E)', () => {
|
|||||||
let callbackInvoked = false;
|
let callbackInvoked = false;
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'List files in the current directory',
|
prompt: 'Create a file named hello.txt with content "world"',
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
|
cwd: '/tmp',
|
||||||
canUseTool: async (toolName, input) => {
|
canUseTool: async (toolName, input) => {
|
||||||
callbackInvoked = true;
|
callbackInvoked = true;
|
||||||
return {
|
return {
|
||||||
@@ -183,9 +184,6 @@ describe('Permission Control (E2E)', () => {
|
|||||||
hasToolResult = true;
|
hasToolResult = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(callbackInvoked).toBe(true);
|
expect(callbackInvoked).toBe(true);
|
||||||
@@ -203,7 +201,7 @@ describe('Permission Control (E2E)', () => {
|
|||||||
let callbackInvoked = false;
|
let callbackInvoked = false;
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'List files in the current directory',
|
prompt: 'Create a file named test.txt',
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
@@ -218,10 +216,8 @@ describe('Permission Control (E2E)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const _message of q) {
|
||||||
if (isCLIResultMessage(message)) {
|
// Consume all messages
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(callbackInvoked).toBe(true);
|
expect(callbackInvoked).toBe(true);
|
||||||
@@ -240,12 +236,13 @@ describe('Permission Control (E2E)', () => {
|
|||||||
let receivedSuggestions: unknown = null;
|
let receivedSuggestions: unknown = null;
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'List files in the current directory',
|
prompt: 'Create a file named data.txt',
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
|
cwd: '/tmp',
|
||||||
canUseTool: async (toolName, input, options) => {
|
canUseTool: async (toolName, input, options) => {
|
||||||
receivedSuggestions = options.suggestions;
|
receivedSuggestions = options?.suggestions;
|
||||||
return {
|
return {
|
||||||
behavior: 'allow',
|
behavior: 'allow',
|
||||||
updatedInput: input,
|
updatedInput: input,
|
||||||
@@ -255,10 +252,8 @@ describe('Permission Control (E2E)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const _message of q) {
|
||||||
if (isCLIResultMessage(message)) {
|
// Consume all messages
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggestions may be null or an array, depending on CLI implementation
|
// Suggestions may be null or an array, depending on CLI implementation
|
||||||
@@ -276,12 +271,13 @@ describe('Permission Control (E2E)', () => {
|
|||||||
let receivedSignal: AbortSignal | undefined = undefined;
|
let receivedSignal: AbortSignal | undefined = undefined;
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'List files in the current directory',
|
prompt: 'Create a file named signal.txt',
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
|
cwd: '/tmp',
|
||||||
canUseTool: async (toolName, input, options) => {
|
canUseTool: async (toolName, input, options) => {
|
||||||
receivedSignal = options.signal;
|
receivedSignal = options?.signal;
|
||||||
return {
|
return {
|
||||||
behavior: 'allow',
|
behavior: 'allow',
|
||||||
updatedInput: input,
|
updatedInput: input,
|
||||||
@@ -291,10 +287,8 @@ describe('Permission Control (E2E)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const _message of q) {
|
||||||
if (isCLIResultMessage(message)) {
|
// Consume all messages
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(receivedSignal).toBeDefined();
|
expect(receivedSignal).toBeDefined();
|
||||||
@@ -313,10 +307,11 @@ describe('Permission Control (E2E)', () => {
|
|||||||
const updatedInputs: Record<string, unknown>[] = [];
|
const updatedInputs: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'List files in the current directory',
|
prompt: 'Create a file named modified.txt',
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
|
cwd: '/tmp',
|
||||||
canUseTool: async (toolName, input) => {
|
canUseTool: async (toolName, input) => {
|
||||||
originalInputs.push({ ...input });
|
originalInputs.push({ ...input });
|
||||||
const updatedInput = {
|
const updatedInput = {
|
||||||
@@ -334,10 +329,8 @@ describe('Permission Control (E2E)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const _message of q) {
|
||||||
if (isCLIResultMessage(message)) {
|
// Consume all messages
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(originalInputs.length).toBeGreaterThan(0);
|
expect(originalInputs.length).toBeGreaterThan(0);
|
||||||
@@ -355,10 +348,11 @@ describe('Permission Control (E2E)', () => {
|
|||||||
'should default to deny when canUseTool is not provided',
|
'should default to deny when canUseTool is not provided',
|
||||||
async () => {
|
async () => {
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'List files in the current directory',
|
prompt: 'Create a file named default.txt',
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
|
cwd: '/tmp',
|
||||||
// canUseTool not provided
|
// canUseTool not provided
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -366,10 +360,8 @@ describe('Permission Control (E2E)', () => {
|
|||||||
try {
|
try {
|
||||||
// When canUseTool is not provided, tools should be denied by default
|
// When canUseTool is not provided, tools should be denied by default
|
||||||
// The exact behavior depends on CLI implementation
|
// The exact behavior depends on CLI implementation
|
||||||
for await (const message of q) {
|
for await (const _message of q) {
|
||||||
if (isCLIResultMessage(message)) {
|
// Consume all messages
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Test passes if no errors occur
|
// Test passes if no errors occur
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
@@ -386,8 +378,8 @@ describe('Permission Control (E2E)', () => {
|
|||||||
'should change permission mode from default to yolo',
|
'should change permission mode from default to yolo',
|
||||||
async () => {
|
async () => {
|
||||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||||
'List files in the current directory',
|
'What is 1 + 1?',
|
||||||
'Now read the package.json file',
|
'What is 2 + 2?',
|
||||||
);
|
);
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
@@ -468,8 +460,8 @@ describe('Permission Control (E2E)', () => {
|
|||||||
'should change permission mode from yolo to plan',
|
'should change permission mode from yolo to plan',
|
||||||
async () => {
|
async () => {
|
||||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||||
'List files in the current directory',
|
'What is 3 + 3?',
|
||||||
'Now read the package.json file',
|
'What is 4 + 4?',
|
||||||
);
|
);
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
@@ -550,8 +542,8 @@ describe('Permission Control (E2E)', () => {
|
|||||||
'should change permission mode to auto-edit',
|
'should change permission mode to auto-edit',
|
||||||
async () => {
|
async () => {
|
||||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||||
'List files in the current directory',
|
'What is 5 + 5?',
|
||||||
'Now read the package.json file',
|
'What is 6 + 6?',
|
||||||
);
|
);
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
@@ -650,99 +642,94 @@ describe('Permission Control (E2E)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('canUseTool and setPermissionMode integration', () => {
|
describe('canUseTool and setPermissionMode integration', () => {
|
||||||
it(
|
it('should work together - canUseTool callback with dynamic permission mode change', async () => {
|
||||||
'should work together - canUseTool callback with dynamic permission mode change',
|
const toolCalls: Array<{
|
||||||
async () => {
|
toolName: string;
|
||||||
const toolCalls: Array<{
|
input: Record<string, unknown>;
|
||||||
toolName: string;
|
}> = [];
|
||||||
input: Record<string, unknown>;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||||
'List files in the current directory',
|
'Create a file named first.txt',
|
||||||
'Now read the package.json file',
|
'Create a file named second.txt',
|
||||||
);
|
);
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: generator,
|
prompt: generator,
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
canUseTool: async (toolName, input) => {
|
cwd: '/tmp',
|
||||||
toolCalls.push({ toolName, input });
|
canUseTool: async (toolName, input) => {
|
||||||
return {
|
console.log('canUseTool', toolName, input);
|
||||||
behavior: 'allow',
|
toolCalls.push({ toolName, input });
|
||||||
updatedInput: input,
|
return {
|
||||||
};
|
behavior: 'allow',
|
||||||
},
|
updatedInput: input,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
let firstResponseReceived = false;
|
||||||
const resolvers: {
|
let secondResponseReceived = false;
|
||||||
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;
|
(async () => {
|
||||||
let secondResponseReceived = false;
|
for await (const message of q) {
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
(async () => {
|
if (!firstResponseReceived) {
|
||||||
for await (const message of q) {
|
firstResponseReceived = true;
|
||||||
if (
|
resolvers.first?.();
|
||||||
isCLIAssistantMessage(message) ||
|
} else if (!secondResponseReceived) {
|
||||||
isCLIResultMessage(message)
|
secondResponseReceived = true;
|
||||||
) {
|
resolvers.second?.();
|
||||||
if (!firstResponseReceived) {
|
|
||||||
firstResponseReceived = true;
|
|
||||||
resolvers.first?.();
|
|
||||||
} else if (!secondResponseReceived) {
|
|
||||||
secondResponseReceived = true;
|
|
||||||
resolvers.second?.();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
firstResponsePromise,
|
firstResponsePromise,
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) =>
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => reject(new Error('Timeout waiting for first response')),
|
() => reject(new Error('Timeout waiting for first response')),
|
||||||
TEST_TIMEOUT,
|
TEST_TIMEOUT,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
expect(firstResponseReceived).toBe(true);
|
expect(firstResponseReceived).toBe(true);
|
||||||
expect(toolCalls.length).toBeGreaterThan(0);
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
await q.setPermissionMode('yolo');
|
await q.setPermissionMode('yolo');
|
||||||
|
|
||||||
resume();
|
resume();
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
secondResponsePromise,
|
secondResponsePromise,
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) =>
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => reject(new Error('Timeout waiting for second response')),
|
() => reject(new Error('Timeout waiting for second response')),
|
||||||
TEST_TIMEOUT,
|
TEST_TIMEOUT,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
expect(secondResponseReceived).toBe(true);
|
expect(secondResponseReceived).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
await q.close();
|
await q.close();
|
||||||
}
|
}
|
||||||
},
|
}, 60000); // TEST_TIMEOUT,
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,747 +0,0 @@
|
|||||||
/**
|
|
||||||
* End-to-End tests for simple query execution with real CLI
|
|
||||||
* Tests the complete SDK workflow with actual CLI subprocess
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
query,
|
|
||||||
AbortError,
|
|
||||||
isAbortError,
|
|
||||||
isCLIAssistantMessage,
|
|
||||||
isCLIUserMessage,
|
|
||||||
isCLIResultMessage,
|
|
||||||
type TextBlock,
|
|
||||||
type ToolUseBlock,
|
|
||||||
type ToolResultBlock,
|
|
||||||
type ContentBlock,
|
|
||||||
type CLIMessage,
|
|
||||||
type CLIAssistantMessage,
|
|
||||||
} from '../../src/index.js';
|
|
||||||
|
|
||||||
// Test configuration
|
|
||||||
const TEST_CLI_PATH =
|
|
||||||
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
|
||||||
const TEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
// Shared test options with permissionMode to allow all tools
|
|
||||||
const SHARED_TEST_OPTIONS = {
|
|
||||||
pathToQwenExecutable: TEST_CLI_PATH,
|
|
||||||
permissionMode: 'yolo' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Simple Query Execution (E2E)', () => {
|
|
||||||
describe('Basic Query Flow', () => {
|
|
||||||
it(
|
|
||||||
'should execute simple text query',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'What is 2 + 2?',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
debug: true,
|
|
||||||
env: {
|
|
||||||
DEBUG: '1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
messages.push(message);
|
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Should have at least one assistant message
|
|
||||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
|
||||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Should end with result message
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should receive assistant response',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'Say hello',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasAssistantMessage = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
hasAssistantMessage = true;
|
|
||||||
const textBlocks = message.message.content.filter(
|
|
||||||
(block): block is TextBlock => block.type === 'text',
|
|
||||||
);
|
|
||||||
expect(textBlocks.length).toBeGreaterThan(0);
|
|
||||||
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(hasAssistantMessage).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should receive result message at end',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'Simple test',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
messages.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
|
||||||
if (isCLIResultMessage(lastMessage)) {
|
|
||||||
expect(lastMessage.subtype).toBe('success');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should complete iteration after result',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'Hello, who are you?',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let messageCount = 0;
|
|
||||||
let completedNaturally = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
messageCount++;
|
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
// Should be the last message
|
|
||||||
completedNaturally = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(messageCount).toBeGreaterThan(0);
|
|
||||||
expect(completedNaturally).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Query with Tool Usage', () => {
|
|
||||||
it(
|
|
||||||
'should handle query requiring tool execution',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt:
|
|
||||||
'What files are in the current directory? List only the top-level files and folders.',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
|
||||||
let hasToolUse = false;
|
|
||||||
let hasAssistantResponse = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
messages.push(message);
|
|
||||||
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
hasAssistantResponse = true;
|
|
||||||
const hasToolUseBlock = message.message.content.some(
|
|
||||||
(block: ContentBlock) => block.type === 'tool_use',
|
|
||||||
);
|
|
||||||
if (hasToolUseBlock) {
|
|
||||||
hasToolUse = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
|
||||||
expect(hasToolUse).toBe(true);
|
|
||||||
expect(hasAssistantResponse).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should yield tool_use messages',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'List files in current directory',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let toolUseMessage: ToolUseBlock | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
const toolUseBlock = message.message.content.find(
|
|
||||||
(block: ContentBlock): block is ToolUseBlock =>
|
|
||||||
block.type === 'tool_use',
|
|
||||||
);
|
|
||||||
if (toolUseBlock) {
|
|
||||||
toolUseMessage = toolUseBlock;
|
|
||||||
expect(toolUseBlock.name).toBeDefined();
|
|
||||||
expect(toolUseBlock.id).toBeDefined();
|
|
||||||
expect(toolUseBlock.input).toBeDefined();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(toolUseMessage).not.toBeNull();
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should yield tool_result messages',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'List files in current directory',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let toolResultMessage: ToolResultBlock | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIUserMessage(message)) {
|
|
||||||
// Tool results are sent as user messages with ToolResultBlock[] content
|
|
||||||
if (Array.isArray(message.message.content)) {
|
|
||||||
const toolResultBlock = message.message.content.find(
|
|
||||||
(block: ContentBlock): block is ToolResultBlock =>
|
|
||||||
block.type === 'tool_result',
|
|
||||||
);
|
|
||||||
if (toolResultBlock) {
|
|
||||||
toolResultMessage = toolResultBlock;
|
|
||||||
expect(toolResultBlock.tool_use_id).toBeDefined();
|
|
||||||
expect(toolResultBlock.content).toBeDefined();
|
|
||||||
// Content should not be a simple string but structured data
|
|
||||||
expect(typeof toolResultBlock.content).not.toBe('undefined');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(toolResultMessage).not.toBeNull();
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should yield final assistant response',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'List files in current directory and tell me what you found',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const assistantMessages: CLIAssistantMessage[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
assistantMessages.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Final assistant message should contain summary
|
|
||||||
const finalAssistant =
|
|
||||||
assistantMessages[assistantMessages.length - 1];
|
|
||||||
const textBlocks = finalAssistant.message.content.filter(
|
|
||||||
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
|
||||||
);
|
|
||||||
expect(textBlocks.length).toBeGreaterThan(0);
|
|
||||||
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Configuration Options', () => {
|
|
||||||
it(
|
|
||||||
'should respect cwd option',
|
|
||||||
async () => {
|
|
||||||
const testDir = '/tmp';
|
|
||||||
|
|
||||||
const q = query({
|
|
||||||
prompt: 'What is the current working directory?',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: testDir,
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasResponse = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
hasResponse = true;
|
|
||||||
// Should execute in specified directory
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(hasResponse).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should use explicit CLI path when provided',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'Hello',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasResponse = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
hasResponse = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(hasResponse).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Resource Management', () => {
|
|
||||||
it(
|
|
||||||
'should cleanup subprocess on close()',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'Hello',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
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
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should not reach here - query() should throw immediately
|
|
||||||
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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Timeout and Cancellation', () => {
|
|
||||||
it(
|
|
||||||
'should support AbortSignal cancellation',
|
|
||||||
async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
// Abort after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
controller.abort();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
const q = query({
|
|
||||||
prompt: 'Write a very long story about TypeScript',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
abortController: controller,
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const _message of q) {
|
|
||||||
// Should be interrupted by abort
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should not reach here
|
|
||||||
expect(false).toBe(true);
|
|
||||||
} catch (error) {
|
|
||||||
expect(isAbortError(error)).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should cleanup on cancellation',
|
|
||||||
async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const q = query({
|
|
||||||
prompt: 'Write a very long essay',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
abortController: controller,
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Abort immediately
|
|
||||||
setTimeout(() => controller.abort(), 100);
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const _message of q) {
|
|
||||||
// Should be interrupted
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
expect(error instanceof AbortError).toBe(true);
|
|
||||||
} finally {
|
|
||||||
// Should cleanup successfully even after abort
|
|
||||||
await q.close();
|
|
||||||
expect(true).toBe(true); // Cleanup completed
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Message Collection Patterns', () => {
|
|
||||||
it(
|
|
||||||
'should collect all messages in array',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'What is 2 + 2?',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
messages.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Should have various message types
|
|
||||||
const messageTypes = messages.map((m) => m.type);
|
|
||||||
expect(messageTypes).toContain('assistant');
|
|
||||||
expect(messageTypes).toContain('result');
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should extract final answer',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'What is the capital of France?',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
messages.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get last assistant message content
|
|
||||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
|
||||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
|
||||||
const textBlocks = lastAssistant.message.content.filter(
|
|
||||||
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(textBlocks.length).toBeGreaterThan(0);
|
|
||||||
expect(textBlocks[0].text).toContain('Paris');
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should track tool usage',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'List files in current directory',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
messages.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count tool_use blocks in assistant messages and tool_result blocks in user messages
|
|
||||||
let toolUseCount = 0;
|
|
||||||
let toolResultCount = 0;
|
|
||||||
|
|
||||||
messages.forEach((message) => {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
message.message.content.forEach((block: ContentBlock) => {
|
|
||||||
if (block.type === 'tool_use') {
|
|
||||||
toolUseCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (isCLIUserMessage(message)) {
|
|
||||||
// Tool results are in user messages
|
|
||||||
if (Array.isArray(message.message.content)) {
|
|
||||||
message.message.content.forEach((block: ContentBlock) => {
|
|
||||||
if (block.type === 'tool_result') {
|
|
||||||
toolResultCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(toolUseCount).toBeGreaterThan(0);
|
|
||||||
expect(toolResultCount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Each tool_use should have a corresponding tool_result
|
|
||||||
expect(toolResultCount).toBeGreaterThanOrEqual(toolUseCount);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Real-World Scenarios', () => {
|
|
||||||
it(
|
|
||||||
'should handle code analysis query',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt:
|
|
||||||
'What is the main export of the package.json file in this directory?',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasAnalysis = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
const textBlocks = message.message.content.filter(
|
|
||||||
(block: ContentBlock): block is TextBlock =>
|
|
||||||
block.type === 'text',
|
|
||||||
);
|
|
||||||
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
|
|
||||||
hasAnalysis = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(hasAnalysis).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should handle multi-step query',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt:
|
|
||||||
'List the files in this directory and tell me what type of project this is',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasToolUse = false;
|
|
||||||
let hasAnalysis = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
const hasToolUseBlock = message.message.content.some(
|
|
||||||
(block: ContentBlock) => block.type === 'tool_use',
|
|
||||||
);
|
|
||||||
if (hasToolUseBlock) {
|
|
||||||
hasToolUse = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
const textBlocks = message.message.content.filter(
|
|
||||||
(block: ContentBlock): block is TextBlock =>
|
|
||||||
block.type === 'text',
|
|
||||||
);
|
|
||||||
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
|
|
||||||
hasAnalysis = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(hasToolUse).toBe(true);
|
|
||||||
expect(hasAnalysis).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* E2E tests based on basic-usage.ts example
|
* E2E tests for single-turn query execution
|
||||||
* Tests message type recognition and basic query patterns
|
* Tests basic query patterns with simple prompts and clear output expectations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { query } from '../../src/index.js';
|
import { query } from '../../src/index.js';
|
||||||
import {
|
import {
|
||||||
isCLIUserMessage,
|
|
||||||
isCLIAssistantMessage,
|
isCLIAssistantMessage,
|
||||||
isCLISystemMessage,
|
isCLISystemMessage,
|
||||||
isCLIResultMessage,
|
isCLIResultMessage,
|
||||||
isCLIPartialAssistantMessage,
|
|
||||||
isControlRequest,
|
|
||||||
isControlResponse,
|
|
||||||
isControlCancel,
|
|
||||||
type TextBlock,
|
type TextBlock,
|
||||||
type ContentBlock,
|
type ContentBlock,
|
||||||
type CLIMessage,
|
type CLIMessage,
|
||||||
type ControlMessage,
|
|
||||||
type CLISystemMessage,
|
type CLISystemMessage,
|
||||||
type CLIUserMessage,
|
|
||||||
type CLIAssistantMessage,
|
type CLIAssistantMessage,
|
||||||
type ToolUseBlock,
|
|
||||||
type ToolResultBlock,
|
|
||||||
} from '../../src/types/protocol.js';
|
} from '../../src/types/protocol.js';
|
||||||
|
|
||||||
// Test configuration
|
// Test configuration
|
||||||
@@ -37,143 +28,48 @@ const SHARED_TEST_OPTIONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the message type using protocol type guards
|
* Helper to extract text from ContentBlock array
|
||||||
*/
|
*/
|
||||||
function getMessageType(message: CLIMessage | ControlMessage): string {
|
function extractText(content: ContentBlock[]): string {
|
||||||
if (isCLIUserMessage(message)) {
|
return content
|
||||||
return '🧑 USER';
|
.filter((block): block is TextBlock => block.type === 'text')
|
||||||
} else if (isCLIAssistantMessage(message)) {
|
.map((block) => block.text)
|
||||||
return '🤖 ASSISTANT';
|
.join('');
|
||||||
} else if (isCLISystemMessage(message)) {
|
|
||||||
return `🖥️ SYSTEM(${message.subtype})`;
|
|
||||||
} else if (isCLIResultMessage(message)) {
|
|
||||||
return `✅ RESULT(${message.subtype})`;
|
|
||||||
} else if (isCLIPartialAssistantMessage(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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Basic Usage (E2E)', () => {
|
describe('Single-Turn Query (E2E)', () => {
|
||||||
describe('Message Type Recognition', () => {
|
describe('Simple Text Queries', () => {
|
||||||
it('should correctly identify message types using type guards', async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt:
|
|
||||||
'What files are in the current directory? List only the top-level files and folders.',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
|
||||||
const messageTypes: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
messages.push(message);
|
|
||||||
const messageType = getMessageType(message);
|
|
||||||
messageTypes.push(messageType);
|
|
||||||
|
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
|
||||||
expect(messageTypes.length).toBe(messages.length);
|
|
||||||
|
|
||||||
// Should have at least assistant and result messages
|
|
||||||
expect(messageTypes.some((type) => type.includes('ASSISTANT'))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(messageTypes.some((type) => type.includes('RESULT'))).toBe(true);
|
|
||||||
|
|
||||||
// Verify type guards work correctly
|
|
||||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
|
||||||
const resultMessages = messages.filter(isCLIResultMessage);
|
|
||||||
|
|
||||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
|
||||||
expect(resultMessages.length).toBeGreaterThan(0);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it(
|
it(
|
||||||
'should handle message content extraction',
|
'should answer basic arithmetic question',
|
||||||
async () => {
|
async () => {
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'Say hello and explain what you are',
|
prompt: 'What is 2 + 2? Just give me the number.',
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
debug: true,
|
debug: false,
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let assistantMessage: CLIAssistantMessage | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
assistantMessage = message;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(assistantMessage).not.toBeNull();
|
|
||||||
expect(assistantMessage!.message.content).toBeDefined();
|
|
||||||
|
|
||||||
// Extract text blocks
|
|
||||||
const textBlocks = assistantMessage!.message.content.filter(
|
|
||||||
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(textBlocks.length).toBeGreaterThan(0);
|
|
||||||
expect(textBlocks[0].text).toBeDefined();
|
|
||||||
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Basic Query Patterns', () => {
|
|
||||||
it(
|
|
||||||
'should handle simple question-answer pattern',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'What is 2 + 2?',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
debug: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
const messages: CLIMessage[] = [];
|
||||||
|
let assistantText = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const message of q) {
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
|
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
assistantText += extractText(message.message.content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate we got messages
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Should have assistant response
|
// Validate assistant response content
|
||||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
expect(assistantText.length).toBeGreaterThan(0);
|
||||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
expect(assistantText).toMatch(/4/);
|
||||||
|
|
||||||
// Should end with result
|
// Validate message flow ends with success
|
||||||
const lastMessage = messages[messages.length - 1];
|
const lastMessage = messages[messages.length - 1];
|
||||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||||
if (isCLIResultMessage(lastMessage)) {
|
if (isCLIResultMessage(lastMessage)) {
|
||||||
@@ -187,63 +83,70 @@ describe('Basic Usage (E2E)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it(
|
it(
|
||||||
'should handle file system query pattern',
|
'should answer simple factual question',
|
||||||
async () => {
|
async () => {
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt:
|
prompt: 'What is the capital of France? One word answer.',
|
||||||
'What files are in the current directory? List only the top-level files and folders.',
|
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
cwd: process.cwd(),
|
debug: false,
|
||||||
debug: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages: CLIMessage[] = [];
|
const messages: CLIMessage[] = [];
|
||||||
let hasToolUse = false;
|
let assistantText = '';
|
||||||
let hasToolResult = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const message of q) {
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
if (isCLIAssistantMessage(message)) {
|
||||||
const toolUseBlock = message.message.content.find(
|
assistantText += extractText(message.message.content);
|
||||||
(block: ContentBlock): block is ToolUseBlock =>
|
|
||||||
block.type === 'tool_use',
|
|
||||||
);
|
|
||||||
if (toolUseBlock) {
|
|
||||||
hasToolUse = true;
|
|
||||||
expect(toolUseBlock.name).toBeDefined();
|
|
||||||
expect(toolUseBlock.id).toBeDefined();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCLIUserMessage(message)) {
|
|
||||||
// Tool results are sent as user messages with ToolResultBlock[] content
|
|
||||||
if (Array.isArray(message.message.content)) {
|
|
||||||
const toolResultBlock = message.message.content.find(
|
|
||||||
(block: ToolResultBlock): block is ToolResultBlock =>
|
|
||||||
block.type === 'tool_result',
|
|
||||||
);
|
|
||||||
if (toolResultBlock) {
|
|
||||||
hasToolResult = true;
|
|
||||||
expect(toolResultBlock.tool_use_id).toBeDefined();
|
|
||||||
expect(toolResultBlock.content).toBeDefined();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
// Validate content
|
||||||
expect(hasToolUse).toBe(true);
|
expect(assistantText.length).toBeGreaterThan(0);
|
||||||
expect(hasToolResult).toBe(true);
|
expect(assistantText.toLowerCase()).toContain('paris');
|
||||||
|
|
||||||
// Should have assistant response after tool execution
|
// Validate completion
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
let assistantText = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
|
||||||
|
if (isCLIAssistantMessage(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 = messages.filter(isCLIAssistantMessage);
|
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -254,73 +157,9 @@ describe('Basic Usage (E2E)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Configuration and Options', () => {
|
describe('System Initialization', () => {
|
||||||
it(
|
it(
|
||||||
'should respect debug option',
|
'should receive system message with initialization info',
|
||||||
async () => {
|
|
||||||
const stderrMessages: string[] = [];
|
|
||||||
|
|
||||||
const q = query({
|
|
||||||
prompt: 'Hello',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
debug: true,
|
|
||||||
stderr: (message: string) => {
|
|
||||||
stderrMessages.push(message);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug mode should produce stderr output
|
|
||||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'should respect cwd option',
|
|
||||||
async () => {
|
|
||||||
const q = query({
|
|
||||||
prompt: 'List files in current directory',
|
|
||||||
options: {
|
|
||||||
...SHARED_TEST_OPTIONS,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasResponse = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of q) {
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
hasResponse = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(hasResponse).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await q.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SDK-CLI Handshaking Process', () => {
|
|
||||||
it(
|
|
||||||
'should receive system message after initialization',
|
|
||||||
async () => {
|
async () => {
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'Hello',
|
prompt: 'Hello',
|
||||||
@@ -337,24 +176,15 @@ describe('Basic Usage (E2E)', () => {
|
|||||||
for await (const message of q) {
|
for await (const message of q) {
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
|
|
||||||
// Capture system message
|
|
||||||
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
||||||
systemMessage = message;
|
systemMessage = message;
|
||||||
break; // Exit early once we get the system message
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop after getting assistant response to avoid long execution
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify system message was received after initialization
|
// Validate system message exists and has required fields
|
||||||
expect(systemMessage).not.toBeNull();
|
expect(systemMessage).not.toBeNull();
|
||||||
expect(systemMessage!.type).toBe('system');
|
expect(systemMessage!.type).toBe('system');
|
||||||
expect(systemMessage!.subtype).toBe('init');
|
expect(systemMessage!.subtype).toBe('init');
|
||||||
|
|
||||||
// Validate system message structure matches sendSystemMessage()
|
|
||||||
expect(systemMessage!.uuid).toBeDefined();
|
expect(systemMessage!.uuid).toBeDefined();
|
||||||
expect(systemMessage!.session_id).toBeDefined();
|
expect(systemMessage!.session_id).toBeDefined();
|
||||||
expect(systemMessage!.cwd).toBeDefined();
|
expect(systemMessage!.cwd).toBeDefined();
|
||||||
@@ -364,22 +194,14 @@ describe('Basic Usage (E2E)', () => {
|
|||||||
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||||
expect(systemMessage!.model).toBeDefined();
|
expect(systemMessage!.model).toBeDefined();
|
||||||
expect(systemMessage!.permissionMode).toBeDefined();
|
expect(systemMessage!.permissionMode).toBeDefined();
|
||||||
expect(systemMessage!.slash_commands).toBeDefined();
|
|
||||||
expect(Array.isArray(systemMessage!.slash_commands)).toBe(true);
|
|
||||||
// expect(systemMessage!.apiKeySource).toBeDefined();
|
|
||||||
expect(systemMessage!.qwen_code_version).toBeDefined();
|
expect(systemMessage!.qwen_code_version).toBeDefined();
|
||||||
// expect(systemMessage!.output_style).toBeDefined();
|
|
||||||
expect(systemMessage!.agents).toBeDefined();
|
|
||||||
expect(Array.isArray(systemMessage!.agents)).toBe(true);
|
|
||||||
// expect(systemMessage!.skills).toBeDefined();
|
|
||||||
// expect(Array.isArray(systemMessage!.skills)).toBe(true);
|
|
||||||
|
|
||||||
// Verify system message appears early in the message sequence
|
// Validate system message appears early in sequence
|
||||||
const systemMessageIndex = messages.findIndex(
|
const systemMessageIndex = messages.findIndex(
|
||||||
(msg) => isCLISystemMessage(msg) && msg.subtype === 'init',
|
(msg) => isCLISystemMessage(msg) && msg.subtype === 'init',
|
||||||
);
|
);
|
||||||
expect(systemMessageIndex).toBeGreaterThanOrEqual(0);
|
expect(systemMessageIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(systemMessageIndex).toBeLessThan(3); // Should be one of the first few messages
|
expect(systemMessageIndex).toBeLessThan(3);
|
||||||
} finally {
|
} finally {
|
||||||
await q.close();
|
await q.close();
|
||||||
}
|
}
|
||||||
@@ -388,7 +210,7 @@ describe('Basic Usage (E2E)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it(
|
it(
|
||||||
'should handle initialization with session ID consistency',
|
'should maintain session ID consistency',
|
||||||
async () => {
|
async () => {
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'Hello',
|
prompt: 'Hello',
|
||||||
@@ -399,41 +221,21 @@ describe('Basic Usage (E2E)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let systemMessage: CLISystemMessage | null = null;
|
let systemMessage: CLISystemMessage | null = null;
|
||||||
let userMessage: CLIUserMessage | null = null;
|
|
||||||
const sessionId = q.getSessionId();
|
const sessionId = q.getSessionId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const message of q) {
|
||||||
// Capture system message
|
|
||||||
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
||||||
systemMessage = message;
|
systemMessage = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture user message
|
|
||||||
if (isCLIUserMessage(message)) {
|
|
||||||
userMessage = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop after getting assistant response to avoid long execution
|
|
||||||
if (isCLIAssistantMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify session IDs are consistent within the system
|
// Validate session IDs are consistent
|
||||||
expect(sessionId).toBeDefined();
|
expect(sessionId).toBeDefined();
|
||||||
expect(systemMessage).not.toBeNull();
|
expect(systemMessage).not.toBeNull();
|
||||||
expect(systemMessage!.session_id).toBeDefined();
|
expect(systemMessage!.session_id).toBeDefined();
|
||||||
expect(systemMessage!.uuid).toBeDefined();
|
expect(systemMessage!.uuid).toBeDefined();
|
||||||
|
|
||||||
// System message should have consistent session_id and uuid
|
|
||||||
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
|
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
|
||||||
|
|
||||||
if (userMessage) {
|
|
||||||
expect(userMessage.session_id).toBeDefined();
|
|
||||||
// User message should have the same session_id as system message
|
|
||||||
expect(userMessage.session_id).toBe(systemMessage!.session_id);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await q.close();
|
await q.close();
|
||||||
}
|
}
|
||||||
@@ -442,36 +244,29 @@ describe('Basic Usage (E2E)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Message Flow Validation', () => {
|
describe('Message Flow', () => {
|
||||||
it(
|
it(
|
||||||
'should follow expected message sequence',
|
'should follow expected message sequence',
|
||||||
async () => {
|
async () => {
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'What is the current time?',
|
prompt: 'Say hi',
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
debug: false,
|
debug: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageSequence: string[] = [];
|
const messageTypes: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of q) {
|
for await (const message of q) {
|
||||||
messageSequence.push(message.type);
|
messageTypes.push(message.type);
|
||||||
|
|
||||||
if (isCLIResultMessage(message)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(messageSequence.length).toBeGreaterThan(0);
|
// Validate message sequence
|
||||||
|
expect(messageTypes.length).toBeGreaterThan(0);
|
||||||
// Should end with result
|
expect(messageTypes).toContain('assistant');
|
||||||
expect(messageSequence[messageSequence.length - 1]).toBe('result');
|
expect(messageTypes[messageTypes.length - 1]).toBe('result');
|
||||||
|
|
||||||
// Should have at least one assistant message
|
|
||||||
expect(messageSequence).toContain('assistant');
|
|
||||||
} finally {
|
} finally {
|
||||||
await q.close();
|
await q.close();
|
||||||
}
|
}
|
||||||
@@ -480,13 +275,13 @@ describe('Basic Usage (E2E)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it(
|
it(
|
||||||
'should handle graceful completion',
|
'should complete iteration naturally',
|
||||||
async () => {
|
async () => {
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: 'Say goodbye',
|
prompt: 'Say goodbye',
|
||||||
options: {
|
options: {
|
||||||
...SHARED_TEST_OPTIONS,
|
...SHARED_TEST_OPTIONS,
|
||||||
debug: true,
|
debug: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -512,4 +307,235 @@ describe('Basic Usage (E2E)', () => {
|
|||||||
TEST_TIMEOUT,
|
TEST_TIMEOUT,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Configuration Options', () => {
|
||||||
|
it(
|
||||||
|
'should respect debug option and capture stderr',
|
||||||
|
async () => {
|
||||||
|
const stderrMessages: string[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: true,
|
||||||
|
stderr: (message: string) => {
|
||||||
|
stderrMessages.push(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should respect cwd option',
|
||||||
|
async () => {
|
||||||
|
const testDir = process.cwd();
|
||||||
|
|
||||||
|
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 (isCLIAssistantMessage(message)) {
|
||||||
|
hasResponse = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasResponse).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Message Type Recognition', () => {
|
||||||
|
it(
|
||||||
|
'should correctly identify all message types',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'What is 5 + 5?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type guards work correctly
|
||||||
|
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||||
|
const resultMessages = messages.filter(isCLIResultMessage);
|
||||||
|
const systemMessages = messages.filter(isCLISystemMessage);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should extract text content from assistant messages',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Count from 1 to 3',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let assistantMessage: CLIAssistantMessage | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
assistantMessage = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(assistantMessage).not.toBeNull();
|
||||||
|
expect(assistantMessage!.message.content).toBeDefined();
|
||||||
|
|
||||||
|
// Extract text blocks
|
||||||
|
const textBlocks = assistantMessage!.message.content.filter(
|
||||||
|
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(textBlocks.length).toBeGreaterThan(0);
|
||||||
|
expect(textBlocks[0].text).toBeDefined();
|
||||||
|
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Validate content contains expected numbers
|
||||||
|
const text = extractText(assistantMessage!.message.content);
|
||||||
|
expect(text).toMatch(/1/);
|
||||||
|
expect(text).toMatch(/2/);
|
||||||
|
expect(text).toMatch(/3/);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Management', () => {
|
||||||
|
it(
|
||||||
|
'should cleanup subprocess on close()',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle close() called multiple times',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user