diff --git a/eslint.config.js b/eslint.config.js index 7b4f502f..8a35ef6f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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: { vitest, }, @@ -158,6 +158,14 @@ export default tseslint.config( ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, // extra settings for scripts that we run directly with node diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index e96f2cd5..2279617b 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -19,6 +19,7 @@ import type { WaitingToolCall, ToolExecuteConfirmationDetails, ToolMcpConfirmationDetails, + ApprovalMode, } from '@qwen-code/qwen-code-core'; import { InputFormat, @@ -208,6 +209,7 @@ export class PermissionController extends BaseController { } this.context.permissionMode = mode; + this.context.config.setApprovalMode(mode as ApprovalMode); if (this.context.debugMode) { console.error( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 147b9abc..e98b1fcd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -293,27 +293,6 @@ export interface ConfigParameters { inputFormat?: InputFormat; outputFormat?: OutputFormat; 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( diff --git a/packages/sdk/typescript/src/query/Query.ts b/packages/sdk/typescript/src/query/Query.ts index 052702f5..10fe5c0d 100644 --- a/packages/sdk/typescript/src/query/Query.ts +++ b/packages/sdk/typescript/src/query/Query.ts @@ -5,6 +5,12 @@ * 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 type { CLIMessage, @@ -373,11 +379,10 @@ export class Query implements AsyncIterable { } try { - const timeoutMs = 30000; const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject(new Error('Permission callback timeout')), - timeoutMs, + PERMISSION_CALLBACK_TIMEOUT, ); }); @@ -484,7 +489,7 @@ export class Query implements AsyncIterable { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('MCP request timeout')); - }, 30000); + }, MCP_REQUEST_TIMEOUT); const messageId = 'id' in message ? message.id : null; @@ -603,7 +608,7 @@ export class Query implements AsyncIterable { const timeout = setTimeout(() => { this.pendingControlRequests.delete(requestId); reject(new Error(`Control request timeout: ${subtype}`)); - }, 300000); + }, CONTROL_REQUEST_TIMEOUT); this.pendingControlRequests.set(requestId, { resolve, @@ -771,8 +776,6 @@ export class Query implements AsyncIterable { this.sdkMcpTransports.size > 0 && this.firstResultReceivedPromise ) { - const STREAM_CLOSE_TIMEOUT = 10000; - await Promise.race([ this.firstResultReceivedPromise, new Promise((resolve) => { diff --git a/packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts b/packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts index 3e16b88d..0b3c83b3 100644 --- a/packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts +++ b/packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts @@ -236,29 +236,28 @@ describe('AbortController and Process Lifecycle (E2E)', () => { }); let receivedResponse = false; + let endInputCalled = false; try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isCLIAssistantMessage(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('') - .slice(0, 100); + const text = textBlocks.map((b: TextBlock) => b.text).join(''); expect(text.length).toBeGreaterThan(0); receivedResponse = true; // End input after receiving first response q.endInput(); - break; + endInputCalled = true; } } expect(receivedResponse).toBe(true); + expect(endInputCalled).toBe(true); } finally { await q.close(); } @@ -389,9 +388,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { - break; - } + // Consume all messages } } finally { await q.close(); diff --git a/packages/sdk/typescript/test/e2e/control.test.ts b/packages/sdk/typescript/test/e2e/control.test.ts index d7d5d483..7ca62014 100644 --- a/packages/sdk/typescript/test/e2e/control.test.ts +++ b/packages/sdk/typescript/test/e2e/control.test.ts @@ -179,8 +179,8 @@ describe('Control Request/Response (E2E)', () => { 'should set permission mode via control request during streaming input', async () => { const { generator, resume } = createStreamingInputWithControlPoint( - 'List files in the current directory', - 'Now read the package.json file', + 'What is 1 + 1?', + 'What is 2 + 2?', ); const q = query({ diff --git a/packages/sdk/typescript/test/e2e/multi-turn.test.ts b/packages/sdk/typescript/test/e2e/multi-turn.test.ts index 66c316eb..f36f7a83 100644 --- a/packages/sdk/typescript/test/e2e/multi-turn.test.ts +++ b/packages/sdk/typescript/test/e2e/multi-turn.test.ts @@ -83,21 +83,7 @@ describe('Multi-Turn Conversations (E2E)', () => { session_id: sessionId, message: { role: 'user', - content: - '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?', + content: 'What is 1 + 1?', }, parent_tool_use_id: null, } as CLIUserMessage; @@ -109,7 +95,19 @@ describe('Multi-Turn Conversations (E2E)', () => { session_id: sessionId, message: { 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, } as CLIUserMessage; @@ -120,14 +118,13 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createMultiTurnConversation(), options: { ...SHARED_TEST_OPTIONS, - cwd: process.cwd(), debug: false, }, }); const messages: CLIMessage[] = []; const assistantMessages: CLIAssistantMessage[] = []; - let turnCount = 0; + const assistantTexts: string[] = []; try { for await (const message of q) { @@ -135,13 +132,18 @@ describe('Multi-Turn Conversations (E2E)', () => { if (isCLIAssistantMessage(message)) { assistantMessages.push(message); - turnCount++; + const text = extractText(message.message.content); + assistantTexts.push(text); } } expect(messages.length).toBeGreaterThan(0); - expect(assistantMessages.length).toBeGreaterThanOrEqual(3); // Should have responses to all 3 questions - expect(turnCount).toBeGreaterThanOrEqual(3); + 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(); } @@ -160,7 +162,8 @@ describe('Multi-Turn Conversations (E2E)', () => { session_id: sessionId, message: { 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, } as CLIUserMessage; @@ -172,7 +175,7 @@ describe('Multi-Turn Conversations (E2E)', () => { session_id: sessionId, message: { role: 'user', - content: 'What is my name?', + content: 'How many animals are there? Only output the number', }, parent_tool_use_id: null, } as CLIUserMessage; @@ -197,11 +200,11 @@ describe('Multi-Turn Conversations (E2E)', () => { expect(assistantMessages.length).toBeGreaterThanOrEqual(2); - // The second response should reference the name Alice + // The second response should reference the color blue const secondResponse = extractText( assistantMessages[1].message.content, ); - expect(secondResponse.toLowerCase()).toContain('alice'); + expect(secondResponse.toLowerCase()).toContain('3'); } finally { await q.close(); } @@ -211,72 +214,79 @@ describe('Multi-Turn Conversations (E2E)', () => { }); describe('Tool Usage in Multi-Turn', () => { - it('should handle tool usage across multiple turns', async () => { - async function* createToolConversation(): AsyncIterable { - const sessionId = crypto.randomUUID(); + it( + 'should handle tool usage across multiple turns', + async () => { + async function* createToolConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); - yield { - type: 'user', - session_id: sessionId, - message: { - role: 'user', - content: 'List the files in the current directory', + 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 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 { - type: 'user', - session_id: sessionId, - message: { - role: 'user', - content: 'Now tell me about the package.json file specifically', - }, - parent_tool_use_id: null, - } as CLIUserMessage; - } + try { + for await (const message of q) { + messages.push(message); - const q = query({ - prompt: createToolConversation(), - options: { - ...SHARED_TEST_OPTIONS, - cwd: process.cwd(), - debug: false, - }, - }); - - 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)) { + assistantMessages.push(message); + const hasToolUseBlock = message.message.content.some( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (hasToolUseBlock) { + toolUseCount++; + } } } - if (isCLIAssistantMessage(message)) { - assistantCount++; - } - } + expect(messages.length).toBeGreaterThan(0); + expect(toolUseCount).toBeGreaterThan(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); - expect(messages.length).toBeGreaterThan(0); - expect(toolUseCount).toBeGreaterThan(0); // Should use tools - expect(assistantCount).toBeGreaterThanOrEqual(2); // Should have responses to both questions - } finally { - await q.close(); - } - }, 60000); //TEST_TIMEOUT, + // 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(); + } + }, + TEST_TIMEOUT, + ); }); describe('Message Flow and Sequencing', () => { @@ -435,10 +445,6 @@ describe('Multi-Turn Conversations (E2E)', () => { try { for await (const message of q) { messages.push(message); - - if (isCLIResultMessage(message)) { - break; - } } // Should handle empty conversation without crashing diff --git a/packages/sdk/typescript/test/e2e/permission-control.test.ts b/packages/sdk/typescript/test/e2e/permission-control.test.ts index f77e065b..f4741814 100644 --- a/packages/sdk/typescript/test/e2e/permission-control.test.ts +++ b/packages/sdk/typescript/test/e2e/permission-control.test.ts @@ -17,7 +17,7 @@ import { const TEST_CLI_PATH = '/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts'; -const TEST_TIMEOUT = 1600000; +const TEST_TIMEOUT = 60000; const SHARED_TEST_OPTIONS = { pathToQwenExecutable: TEST_CLI_PATH, @@ -156,10 +156,11 @@ describe('Permission Control (E2E)', () => { let callbackInvoked = false; const q = query({ - prompt: 'List files in the current directory', + prompt: 'Create a file named hello.txt with content "world"', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', + cwd: '/tmp', canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -183,9 +184,6 @@ describe('Permission Control (E2E)', () => { hasToolResult = true; } } - if (isCLIResultMessage(message)) { - break; - } } expect(callbackInvoked).toBe(true); @@ -203,7 +201,7 @@ describe('Permission Control (E2E)', () => { let callbackInvoked = false; const q = query({ - prompt: 'List files in the current directory', + prompt: 'Create a file named test.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', @@ -218,10 +216,8 @@ describe('Permission Control (E2E)', () => { }); try { - for await (const message of q) { - if (isCLIResultMessage(message)) { - break; - } + for await (const _message of q) { + // Consume all messages } expect(callbackInvoked).toBe(true); @@ -240,12 +236,13 @@ describe('Permission Control (E2E)', () => { let receivedSuggestions: unknown = null; const q = query({ - prompt: 'List files in the current directory', + prompt: 'Create a file named data.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', + cwd: '/tmp', canUseTool: async (toolName, input, options) => { - receivedSuggestions = options.suggestions; + receivedSuggestions = options?.suggestions; return { behavior: 'allow', updatedInput: input, @@ -255,10 +252,8 @@ describe('Permission Control (E2E)', () => { }); try { - for await (const message of q) { - if (isCLIResultMessage(message)) { - break; - } + for await (const _message of q) { + // Consume all messages } // Suggestions may be null or an array, depending on CLI implementation @@ -276,12 +271,13 @@ describe('Permission Control (E2E)', () => { let receivedSignal: AbortSignal | undefined = undefined; const q = query({ - prompt: 'List files in the current directory', + prompt: 'Create a file named signal.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', + cwd: '/tmp', canUseTool: async (toolName, input, options) => { - receivedSignal = options.signal; + receivedSignal = options?.signal; return { behavior: 'allow', updatedInput: input, @@ -291,10 +287,8 @@ describe('Permission Control (E2E)', () => { }); try { - for await (const message of q) { - if (isCLIResultMessage(message)) { - break; - } + for await (const _message of q) { + // Consume all messages } expect(receivedSignal).toBeDefined(); @@ -313,10 +307,11 @@ describe('Permission Control (E2E)', () => { const updatedInputs: Record[] = []; const q = query({ - prompt: 'List files in the current directory', + prompt: 'Create a file named modified.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', + cwd: '/tmp', canUseTool: async (toolName, input) => { originalInputs.push({ ...input }); const updatedInput = { @@ -334,10 +329,8 @@ describe('Permission Control (E2E)', () => { }); try { - for await (const message of q) { - if (isCLIResultMessage(message)) { - break; - } + for await (const _message of q) { + // Consume all messages } expect(originalInputs.length).toBeGreaterThan(0); @@ -355,10 +348,11 @@ describe('Permission Control (E2E)', () => { 'should default to deny when canUseTool is not provided', async () => { const q = query({ - prompt: 'List files in the current directory', + prompt: 'Create a file named default.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', + cwd: '/tmp', // canUseTool not provided }, }); @@ -366,10 +360,8 @@ describe('Permission Control (E2E)', () => { try { // When canUseTool is not provided, tools should be denied by default // The exact behavior depends on CLI implementation - for await (const message of q) { - if (isCLIResultMessage(message)) { - break; - } + for await (const _message of q) { + // Consume all messages } // Test passes if no errors occur expect(true).toBe(true); @@ -386,8 +378,8 @@ describe('Permission Control (E2E)', () => { 'should change permission mode from default to yolo', async () => { const { generator, resume } = createStreamingInputWithControlPoint( - 'List files in the current directory', - 'Now read the package.json file', + 'What is 1 + 1?', + 'What is 2 + 2?', ); const q = query({ @@ -468,8 +460,8 @@ describe('Permission Control (E2E)', () => { 'should change permission mode from yolo to plan', async () => { const { generator, resume } = createStreamingInputWithControlPoint( - 'List files in the current directory', - 'Now read the package.json file', + 'What is 3 + 3?', + 'What is 4 + 4?', ); const q = query({ @@ -550,8 +542,8 @@ describe('Permission Control (E2E)', () => { 'should change permission mode to auto-edit', async () => { const { generator, resume } = createStreamingInputWithControlPoint( - 'List files in the current directory', - 'Now read the package.json file', + 'What is 5 + 5?', + 'What is 6 + 6?', ); const q = query({ @@ -650,99 +642,94 @@ describe('Permission Control (E2E)', () => { }); describe('canUseTool and setPermissionMode integration', () => { - it( - 'should work together - canUseTool callback with dynamic permission mode change', - async () => { - const toolCalls: Array<{ - toolName: string; - input: Record; - }> = []; + it('should work together - canUseTool callback with dynamic permission mode change', async () => { + const toolCalls: Array<{ + toolName: string; + input: Record; + }> = []; - const { generator, resume } = createStreamingInputWithControlPoint( - 'List files in the current directory', - 'Now read the package.json file', - ); + const { generator, resume } = createStreamingInputWithControlPoint( + 'Create a file named first.txt', + 'Create a file named second.txt', + ); - const q = query({ - prompt: generator, - options: { - ...SHARED_TEST_OPTIONS, - permissionMode: 'default', - canUseTool: async (toolName, input) => { - toolCalls.push({ toolName, input }); - return { - behavior: 'allow', - updatedInput: input, - }; - }, + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + console.log('canUseTool', toolName, input); + toolCalls.push({ toolName, input }); + return { + behavior: 'allow', + updatedInput: input, + }; }, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; }); - try { - const resolvers: { - first?: () => void; - second?: () => void; - } = {}; - const firstResponsePromise = new Promise((resolve) => { - resolvers.first = resolve; - }); - const secondResponsePromise = new Promise((resolve) => { - resolvers.second = resolve; - }); + let firstResponseReceived = false; + let secondResponseReceived = false; - let firstResponseReceived = false; - let secondResponseReceived = false; - - (async () => { - for await (const message of q) { - if ( - isCLIAssistantMessage(message) || - isCLIResultMessage(message) - ) { - if (!firstResponseReceived) { - firstResponseReceived = true; - resolvers.first?.(); - } else if (!secondResponseReceived) { - secondResponseReceived = true; - resolvers.second?.(); - } + (async () => { + for await (const message of q) { + if (isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); } } - })(); + } + })(); - await Promise.race([ - firstResponsePromise, - new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timeout waiting for first response')), - TEST_TIMEOUT, - ), + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + TEST_TIMEOUT, ), - ]); + ), + ]); - expect(firstResponseReceived).toBe(true); - expect(toolCalls.length).toBeGreaterThan(0); + expect(firstResponseReceived).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); - await q.setPermissionMode('yolo'); + await q.setPermissionMode('yolo'); - resume(); + resume(); - await Promise.race([ - secondResponsePromise, - new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timeout waiting for second response')), - TEST_TIMEOUT, - ), + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + TEST_TIMEOUT, ), - ]); + ), + ]); - expect(secondResponseReceived).toBe(true); - } finally { - await q.close(); - } - }, - TEST_TIMEOUT, - ); + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }, 60000); // TEST_TIMEOUT, }); }); diff --git a/packages/sdk/typescript/test/e2e/simple-query.test.ts b/packages/sdk/typescript/test/e2e/simple-query.test.ts deleted file mode 100644 index 030bcc02..00000000 --- a/packages/sdk/typescript/test/e2e/simple-query.test.ts +++ /dev/null @@ -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, - ); - }); -}); diff --git a/packages/sdk/typescript/test/e2e/basic-usage.test.ts b/packages/sdk/typescript/test/e2e/single-turn.test.ts similarity index 52% rename from packages/sdk/typescript/test/e2e/basic-usage.test.ts rename to packages/sdk/typescript/test/e2e/single-turn.test.ts index d53d1743..e69d7909 100644 --- a/packages/sdk/typescript/test/e2e/basic-usage.test.ts +++ b/packages/sdk/typescript/test/e2e/single-turn.test.ts @@ -1,28 +1,19 @@ /** - * E2E tests based on basic-usage.ts example - * Tests message type recognition and basic query patterns + * E2E tests for single-turn query execution + * Tests basic query patterns with simple prompts and clear output expectations */ import { describe, it, expect } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIUserMessage, isCLIAssistantMessage, isCLISystemMessage, isCLIResultMessage, - isCLIPartialAssistantMessage, - isControlRequest, - isControlResponse, - isControlCancel, type TextBlock, type ContentBlock, type CLIMessage, - type ControlMessage, type CLISystemMessage, - type CLIUserMessage, type CLIAssistantMessage, - type ToolUseBlock, - type ToolResultBlock, } from '../../src/types/protocol.js'; // 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 { - if (isCLIUserMessage(message)) { - return '🧑 USER'; - } else if (isCLIAssistantMessage(message)) { - return '🤖 ASSISTANT'; - } 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'; - } +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); } -describe('Basic Usage (E2E)', () => { - describe('Message Type Recognition', () => { - 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(); - } - }); - +describe('Single-Turn Query (E2E)', () => { + describe('Simple Text Queries', () => { it( - 'should handle message content extraction', + 'should answer basic arithmetic question', async () => { const q = query({ - prompt: 'Say hello and explain what you are', + prompt: 'What is 2 + 2? Just give me the number.', options: { ...SHARED_TEST_OPTIONS, - debug: true, - }, - }); - - 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, + 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 we got messages expect(messages.length).toBeGreaterThan(0); - // Should have assistant response - const assistantMessages = messages.filter(isCLIAssistantMessage); - expect(assistantMessages.length).toBeGreaterThan(0); + // Validate assistant response content + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText).toMatch(/4/); - // Should end with result + // Validate message flow ends with success const lastMessage = messages[messages.length - 1]; expect(isCLIResultMessage(lastMessage)).toBe(true); if (isCLIResultMessage(lastMessage)) { @@ -187,63 +83,70 @@ describe('Basic Usage (E2E)', () => { ); it( - 'should handle file system query pattern', + 'should answer simple factual question', async () => { const q = query({ - prompt: - 'What files are in the current directory? List only the top-level files and folders.', + prompt: 'What is the capital of France? One word answer.', options: { ...SHARED_TEST_OPTIONS, - cwd: process.cwd(), - debug: true, + debug: false, }, }); const messages: CLIMessage[] = []; - let hasToolUse = false; - let hasToolResult = false; + let assistantText = ''; try { for await (const message of q) { messages.push(message); if (isCLIAssistantMessage(message)) { - const toolUseBlock = message.message.content.find( - (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; + assistantText += extractText(message.message.content); } } - expect(messages.length).toBeGreaterThan(0); - expect(hasToolUse).toBe(true); - expect(hasToolResult).toBe(true); + // Validate content + expect(assistantText.length).toBeGreaterThan(0); + 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); expect(assistantMessages.length).toBeGreaterThan(0); } finally { @@ -254,73 +157,9 @@ describe('Basic Usage (E2E)', () => { ); }); - describe('Configuration and Options', () => { + describe('System Initialization', () => { it( - 'should respect debug option', - 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', + 'should receive system message with initialization info', async () => { const q = query({ prompt: 'Hello', @@ -337,24 +176,15 @@ describe('Basic Usage (E2E)', () => { for await (const message of q) { messages.push(message); - // Capture system message if (isCLISystemMessage(message) && message.subtype === 'init') { 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!.type).toBe('system'); expect(systemMessage!.subtype).toBe('init'); - - // Validate system message structure matches sendSystemMessage() expect(systemMessage!.uuid).toBeDefined(); expect(systemMessage!.session_id).toBeDefined(); expect(systemMessage!.cwd).toBeDefined(); @@ -364,22 +194,14 @@ describe('Basic Usage (E2E)', () => { expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); expect(systemMessage!.model).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!.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( (msg) => isCLISystemMessage(msg) && msg.subtype === 'init', ); expect(systemMessageIndex).toBeGreaterThanOrEqual(0); - expect(systemMessageIndex).toBeLessThan(3); // Should be one of the first few messages + expect(systemMessageIndex).toBeLessThan(3); } finally { await q.close(); } @@ -388,7 +210,7 @@ describe('Basic Usage (E2E)', () => { ); it( - 'should handle initialization with session ID consistency', + 'should maintain session ID consistency', async () => { const q = query({ prompt: 'Hello', @@ -399,41 +221,21 @@ describe('Basic Usage (E2E)', () => { }); let systemMessage: CLISystemMessage | null = null; - let userMessage: CLIUserMessage | null = null; const sessionId = q.getSessionId(); try { for await (const message of q) { - // Capture system message if (isCLISystemMessage(message) && message.subtype === 'init') { 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(systemMessage).not.toBeNull(); expect(systemMessage!.session_id).toBeDefined(); expect(systemMessage!.uuid).toBeDefined(); - - // System message should have consistent session_id and 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 { await q.close(); } @@ -442,36 +244,29 @@ describe('Basic Usage (E2E)', () => { ); }); - describe('Message Flow Validation', () => { + describe('Message Flow', () => { it( 'should follow expected message sequence', async () => { const q = query({ - prompt: 'What is the current time?', + prompt: 'Say hi', options: { ...SHARED_TEST_OPTIONS, debug: false, }, }); - const messageSequence: string[] = []; + const messageTypes: string[] = []; try { for await (const message of q) { - messageSequence.push(message.type); - - if (isCLIResultMessage(message)) { - break; - } + messageTypes.push(message.type); } - expect(messageSequence.length).toBeGreaterThan(0); - - // Should end with result - expect(messageSequence[messageSequence.length - 1]).toBe('result'); - - // Should have at least one assistant message - expect(messageSequence).toContain('assistant'); + // Validate message sequence + expect(messageTypes.length).toBeGreaterThan(0); + expect(messageTypes).toContain('assistant'); + expect(messageTypes[messageTypes.length - 1]).toBe('result'); } finally { await q.close(); } @@ -480,13 +275,13 @@ describe('Basic Usage (E2E)', () => { ); it( - 'should handle graceful completion', + 'should complete iteration naturally', async () => { const q = query({ prompt: 'Say goodbye', options: { ...SHARED_TEST_OPTIONS, - debug: true, + debug: false, }, }); @@ -512,4 +307,235 @@ describe('Basic Usage (E2E)', () => { 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, + ); + }); });