diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index a33ea161..7981a67b 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -54,13 +54,13 @@ export class SystemController extends BaseController { private async handleInitialize( payload: CLIControlInitializeRequest, ): Promise> { - // Register SDK MCP servers if provided + this.context.config.setSdkMode(true); + if (payload.sdkMcpServers && typeof payload.sdkMcpServers === 'object') { for (const serverName of Object.keys(payload.sdkMcpServers)) { this.context.sdkMcpServers.add(serverName); } - // Add SDK MCP servers to config try { this.context.config.addMcpServers(payload.sdkMcpServers); if (this.context.debugMode) { @@ -78,7 +78,6 @@ export class SystemController extends BaseController { } } - // Add MCP servers to config if provided if (payload.mcpServers && typeof payload.mcpServers === 'object') { try { this.context.config.addMcpServers(payload.mcpServers); @@ -94,10 +93,9 @@ export class SystemController extends BaseController { } } - // Add session subagents to config if provided if (payload.agents && Array.isArray(payload.agents)) { try { - this.context.config.addSessionSubagents(payload.agents); + this.context.config.setSessionSubagents(payload.agents); if (this.context.debugMode) { console.error( @@ -114,9 +112,6 @@ export class SystemController extends BaseController { } } - // Set SDK mode to true after handling initialize - this.context.config.setSdkMode(true); - // Build capabilities for response const capabilities = this.buildControlCapabilities(); diff --git a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx index ab1cd2a9..ccec2ebf 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx @@ -69,7 +69,10 @@ export function EditOptionsStep({ if (selectedValue === 'editor') { // Launch editor directly try { - await launchEditor(selectedAgent?.filePath); + if (!selectedAgent.filePath) { + throw new Error('Agent has no file path'); + } + await launchEditor(selectedAgent.filePath); } catch (err) { setError( t('Failed to launch editor: {{error}}', { diff --git a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx index a186374d..add3dcb5 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx @@ -267,7 +267,7 @@ export const AgentSelectionStep = ({ {t('Project Level ({{path}})', { - path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: projectAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} @@ -289,7 +289,7 @@ export const AgentSelectionStep = ({ > {t('User Level ({{path}})', { - path: userAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: userAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 29757ff6..65c39d8e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -613,6 +613,12 @@ export class Config { } this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); + + // Load session subagents if they were provided before initialization + if (this.sessionSubagents.length > 0) { + this.subagentManager.loadSessionSubagents(this.sessionSubagents); + } + this.toolRegistry = await this.createToolRegistry(); await this.geminiClient.initialize(); @@ -874,13 +880,6 @@ export class Config { this.sessionSubagents = subagents; } - addSessionSubagents(subagents: SubagentConfig[]): void { - if (this.initialized) { - throw new Error('Cannot modify sessionSubagents after initialization'); - } - this.sessionSubagents = [...this.sessionSubagents, ...subagents]; - } - getSdkMode(): boolean { return this.sdkMode; } diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 26436c88..e04964ea 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -182,7 +182,7 @@ You are a helpful assistant. it('should parse valid markdown content', () => { const config = manager.parseSubagentContent( validMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -207,7 +207,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithTools, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -228,7 +228,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithModel, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -249,7 +249,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithRun, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -267,7 +267,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithNumeric, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -288,7 +288,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithBoolean, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -324,7 +324,7 @@ Just content`; expect(() => manager.parseSubagentContent( invalidMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -341,7 +341,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutName, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -358,7 +358,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutDescription, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -438,7 +438,7 @@ You are a helpful assistant. await manager.createSubagent(validConfig, { level: 'project' }); expect(fs.mkdir).toHaveBeenCalledWith( - path.normalize(path.dirname(validConfig.filePath)), + path.normalize(path.dirname(validConfig.filePath!)), { recursive: true }, ); expect(fs.writeFile).toHaveBeenCalledWith( diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index d83e3e7a..baf49fa9 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -159,7 +159,14 @@ export class SubagentManager { return this.findSubagentByNameAtLevel(name, level); } - // Try project level first + // Try session level first (highest priority for runtime) + const sessionSubagents = this.subagentsCache?.get('session') || []; + const sessionConfig = sessionSubagents.find((agent) => agent.name === name); + if (sessionConfig) { + return sessionConfig; + } + + // Try project level const projectConfig = await this.findSubagentByNameAtLevel(name, 'project'); if (projectConfig) { return projectConfig; @@ -220,6 +227,15 @@ export class SubagentManager { // Validate the updated configuration this.validator.validateOrThrow(updatedConfig); + // Ensure filePath exists for file-based agents + if (!existing.filePath) { + throw new SubagentError( + `Cannot update subagent "${name}": no file path available`, + SubagentErrorCode.FILE_ERROR, + name, + ); + } + // Write the updated configuration const content = this.serializeSubagent(updatedConfig); @@ -302,11 +318,6 @@ export class SubagentManager { // In SDK mode, only load session-level subagents if (this.config.getSdkMode()) { - const sessionSubagents = this.config.getSessionSubagents(); - if (sessionSubagents && sessionSubagents.length > 0) { - this.loadSessionSubagents(sessionSubagents); - } - const levelsToCheck: SubagentLevel[] = options.level ? [options.level] : ['session']; diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 0f83e3f1..accfb18f 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -42,8 +42,8 @@ export interface SubagentConfig { /** Storage level - determines where the configuration file is stored */ level: SubagentLevel; - /** Absolute path to the configuration file */ - filePath: string; + /** Absolute path to the configuration file. Optional for session subagents. */ + filePath?: string; /** * Optional model configuration. If not provided, uses defaults. diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 55d767c5..c8039d4c 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -129,6 +129,7 @@ export class Query implements AsyncIterable { sdkMcpServers: sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined, mcpServers: this.options.mcpServers, + agents: this.options.agents, }); } catch (error) { console.error('[Query] Initialization error:', error); diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 399221e0..2f1f9fe9 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -517,7 +517,7 @@ export interface SubagentConfig { tools?: string[]; systemPrompt: string; level: SubagentLevel; - filePath: string; + filePath?: string; modelConfig?: Partial; runConfig?: Partial; color?: string; diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index d3a548af..7573abef 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -31,7 +31,6 @@ export const SubagentConfigSchema = z.object({ description: z.string().min(1, 'Description must be a non-empty string'), tools: z.array(z.string()).optional(), systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'), - filePath: z.string().min(1, 'File path must be a non-empty string'), modelConfig: ModelConfigSchema.partial().optional(), runConfig: RunConfigSchema.partial().optional(), color: z.string().optional(), @@ -71,9 +70,9 @@ export const QueryOptionsSchema = z typeof val === 'object' && 'name' in val && 'description' in val && - 'systemPrompt' in val && - 'filePath' in val, - { message: 'agents must be an array of SubagentConfig objects' }, + 'systemPrompt' in val && { + message: 'agents must be an array of SubagentConfig objects', + }, ), ) .optional(), diff --git a/packages/sdk-typescript/test/e2e/subagents.test.ts b/packages/sdk-typescript/test/e2e/subagents.test.ts new file mode 100644 index 00000000..fcceebb5 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/subagents.test.ts @@ -0,0 +1,656 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for subagent configuration and execution + * Tests subagent delegation and task completion + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + type TextBlock, + type ContentBlock, + type CLIMessage, + type CLISystemMessage, + type SubagentConfig, + type ToolUseBlock, +} from '../../src/types/protocol.js'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +describe('Subagents (E2E)', () => { + let testWorkDir: string; + + beforeAll(async () => { + // Create a test working directory + testWorkDir = join(E2E_TEST_FILE_DIR, 'subagent-tests'); + await mkdir(testWorkDir, { recursive: true }); + + // Create a simple test file for subagent to work with + const testFilePath = join(testWorkDir, 'test.txt'); + await writeFile(testFilePath, 'Hello from test file\n', 'utf-8'); + }); + + describe('Subagent Configuration', () => { + it('should accept session-level subagent configuration', async () => { + const simpleSubagent: SubagentConfig = { + name: 'simple-greeter', + description: 'A simple subagent that responds to greetings', + systemPrompt: + 'You are a friendly greeter. When given a task, respond with a cheerful greeting.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello, let simple-greeter to say hi back to me.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleSubagent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate system message includes the subagent + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('simple-greeter'); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should accept multiple subagent configurations', async () => { + const greeterAgent: SubagentConfig = { + name: 'greeter', + description: 'Responds to greetings', + systemPrompt: 'You are a friendly greeter.', + level: 'session', + }; + + const mathAgent: SubagentConfig = { + name: 'math-helper', + description: 'Helps with math problems', + systemPrompt: 'You are a math expert. Solve math problems clearly.', + level: 'session', + }; + + const q = query({ + prompt: 'What is 5 + 5?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [greeterAgent, mathAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate both subagents are registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('greeter'); + expect(systemMessage!.agents).toContain('math-helper'); + expect(systemMessage!.agents!.length).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + + it('should handle subagent with custom model config', async () => { + const customModelAgent: SubagentConfig = { + name: 'custom-model-agent', + description: 'Agent with custom model configuration', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + modelConfig: { + temp: 0.7, + top_p: 0.9, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [customModelAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('custom-model-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with run config', async () => { + const limitedAgent: SubagentConfig = { + name: 'limited-agent', + description: 'Agent with execution limits', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + runConfig: { + max_turns: 5, + max_time_minutes: 1, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [limitedAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('limited-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with specific tools', async () => { + const toolRestrictedAgent: SubagentConfig = { + name: 'read-only-agent', + description: 'Agent that can only read files', + systemPrompt: + 'You are a file reading assistant. Read files when asked.', + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [toolRestrictedAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('read-only-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Execution', () => { + it('should delegate task to subagent when appropriate', async () => { + const fileReaderAgent: SubagentConfig = { + name: 'file-reader', + description: 'Reads and reports file contents', + systemPrompt: `You are a file reading assistant. When given a task to read a file, use the read_file tool to read it and report its contents back. Be concise in your response.`, + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const testFile = join(testWorkDir, 'test.txt'); + const q = query({ + prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [fileReaderAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: CLIMessage[] = []; + let foundTaskTool = false; + let taskToolUseId: string | null = null; + let foundSubagentToolCall = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + // Check for task tool use in content blocks (main agent calling subagent) + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use' && block.name === 'task', + ); + if (toolUseBlock) { + foundTaskTool = true; + taskToolUseId = toolUseBlock.id; + } + + // Check if this message is from a subagent (has parent_tool_use_id) + if (message.parent_tool_use_id !== null) { + // This is a subagent message + const subagentToolUse = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (subagentToolUse) { + foundSubagentToolCall = true; + // Verify parent_tool_use_id matches the task tool use id + expect(message.parent_tool_use_id).toBe(taskToolUseId); + } + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent delegation) + expect(foundTaskTool).toBe(true); + expect(taskToolUseId).not.toBeNull(); + + // Validate subagent actually made tool calls with proper parent_tool_use_id + expect(foundSubagentToolCall).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, 60000); // Increase timeout for subagent execution + + it('should complete simple task with subagent', async () => { + const simpleTaskAgent: SubagentConfig = { + name: 'simple-calculator', + description: 'Performs simple arithmetic calculations', + systemPrompt: + 'You are a calculator. When given a math problem, solve it and provide just the answer.', + level: 'session', + }; + + const q = query({ + prompt: 'Use the simple-calculator subagent to calculate 15 + 27.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleTaskAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: CLIMessage[] = []; + let foundTaskTool = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + // Check for task tool use (main agent delegating to subagent) + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use' && block.name === 'task', + ); + if (toolUseBlock) { + foundTaskTool = true; + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent was called) + expect(foundTaskTool).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, 60000); + + it('should verify subagent execution with comprehensive parent_tool_use_id checks', async () => { + const comprehensiveAgent: SubagentConfig = { + name: 'comprehensive-agent', + description: 'Agent for comprehensive testing', + systemPrompt: + 'You are a helpful assistant. When asked to list files, use the list_directory tool.', + level: 'session', + tools: ['list_directory', 'read_file'], + }; + + const q = query({ + prompt: `Use the comprehensive-agent subagent to list the files in ${testWorkDir}.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [comprehensiveAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: CLIMessage[] = []; + let taskToolUseId: string | null = null; + const subagentToolCalls: ToolUseBlock[] = []; + const mainAgentToolCalls: ToolUseBlock[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + // Collect all tool use blocks + const toolUseBlocks = message.message.content.filter( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + + for (const toolUse of toolUseBlocks) { + if (toolUse.name === 'task') { + // This is the main agent calling the subagent + taskToolUseId = toolUse.id; + mainAgentToolCalls.push(toolUse); + } + + // If this message has parent_tool_use_id, it's from a subagent + if (message.parent_tool_use_id !== null) { + subagentToolCalls.push(toolUse); + } + } + } + } + + // Criterion 1: When a subagent is called, there must be a 'task' tool being called + expect(taskToolUseId).not.toBeNull(); + expect(mainAgentToolCalls.length).toBeGreaterThan(0); + expect(mainAgentToolCalls.some((tc) => tc.name === 'task')).toBe(true); + + // Criterion 2: A tool call from a subagent is identified by a non-null parent_tool_use_id + // All subagent tool calls should have parent_tool_use_id set to the task tool's id + expect(subagentToolCalls.length).toBeGreaterThan(0); + + // Verify all subagent messages have the correct parent_tool_use_id + const subagentMessages = messages.filter( + (msg): msg is CLIMessage & { parent_tool_use_id: string } => + isCLIAssistantMessage(msg) && msg.parent_tool_use_id !== null, + ); + + expect(subagentMessages.length).toBeGreaterThan(0); + for (const subagentMsg of subagentMessages) { + expect(subagentMsg.parent_tool_use_id).toBe(taskToolUseId); + } + + // Verify no main agent tool calls (except task) have parent_tool_use_id + const mainAgentMessages = messages.filter( + (msg): msg is CLIMessage => + isCLIAssistantMessage(msg) && msg.parent_tool_use_id === null, + ); + + for (const mainMsg of mainAgentMessages) { + if (isCLIAssistantMessage(mainMsg)) { + // Main agent messages should not have parent_tool_use_id + expect(mainMsg.parent_tool_use_id).toBeNull(); + } + } + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, 60000); + }); + + describe('Subagent Error Handling', () => { + it('should handle empty subagent array', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Should still work with empty agents array + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should handle subagent with minimal configuration', async () => { + const minimalAgent: SubagentConfig = { + name: 'minimal-agent', + description: 'Minimal configuration agent', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [minimalAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate minimal agent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('minimal-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Integration', () => { + it('should work with other SDK options', async () => { + const testAgent: SubagentConfig = { + name: 'test-agent', + description: 'Test agent for integration', + systemPrompt: 'You are a test assistant.', + level: 'session', + }; + + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [testAgent], + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + permissionMode: 'default', + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent works with debug mode + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('test-agent'); + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should maintain session consistency with subagents', async () => { + const sessionAgent: SubagentConfig = { + name: 'session-agent', + description: 'Agent for session testing', + systemPrompt: 'You are a session test assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [sessionAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate session consistency + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + expect(systemMessage!.agents).toContain('session-agent'); + } finally { + await q.close(); + } + }); + }); +});