mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat: sdk subagent support
This commit is contained in:
@@ -54,13 +54,13 @@ export class SystemController extends BaseController {
|
|||||||
private async handleInitialize(
|
private async handleInitialize(
|
||||||
payload: CLIControlInitializeRequest,
|
payload: CLIControlInitializeRequest,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
// Register SDK MCP servers if provided
|
this.context.config.setSdkMode(true);
|
||||||
|
|
||||||
if (payload.sdkMcpServers && typeof payload.sdkMcpServers === 'object') {
|
if (payload.sdkMcpServers && typeof payload.sdkMcpServers === 'object') {
|
||||||
for (const serverName of Object.keys(payload.sdkMcpServers)) {
|
for (const serverName of Object.keys(payload.sdkMcpServers)) {
|
||||||
this.context.sdkMcpServers.add(serverName);
|
this.context.sdkMcpServers.add(serverName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add SDK MCP servers to config
|
|
||||||
try {
|
try {
|
||||||
this.context.config.addMcpServers(payload.sdkMcpServers);
|
this.context.config.addMcpServers(payload.sdkMcpServers);
|
||||||
if (this.context.debugMode) {
|
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') {
|
if (payload.mcpServers && typeof payload.mcpServers === 'object') {
|
||||||
try {
|
try {
|
||||||
this.context.config.addMcpServers(payload.mcpServers);
|
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)) {
|
if (payload.agents && Array.isArray(payload.agents)) {
|
||||||
try {
|
try {
|
||||||
this.context.config.addSessionSubagents(payload.agents);
|
this.context.config.setSessionSubagents(payload.agents);
|
||||||
|
|
||||||
if (this.context.debugMode) {
|
if (this.context.debugMode) {
|
||||||
console.error(
|
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
|
// Build capabilities for response
|
||||||
const capabilities = this.buildControlCapabilities();
|
const capabilities = this.buildControlCapabilities();
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ export function EditOptionsStep({
|
|||||||
if (selectedValue === 'editor') {
|
if (selectedValue === 'editor') {
|
||||||
// Launch editor directly
|
// Launch editor directly
|
||||||
try {
|
try {
|
||||||
await launchEditor(selectedAgent?.filePath);
|
if (!selectedAgent.filePath) {
|
||||||
|
throw new Error('Agent has no file path');
|
||||||
|
}
|
||||||
|
await launchEditor(selectedAgent.filePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
t('Failed to launch editor: {{error}}', {
|
t('Failed to launch editor: {{error}}', {
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export const AgentSelectionStep = ({
|
|||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Text color={theme.text.primary} bold>
|
<Text color={theme.text.primary} bold>
|
||||||
{t('Project Level ({{path}})', {
|
{t('Project Level ({{path}})', {
|
||||||
path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''),
|
path: projectAgents[0].filePath?.replace(/\/[^/]+$/, '') || '',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
@@ -289,7 +289,7 @@ export const AgentSelectionStep = ({
|
|||||||
>
|
>
|
||||||
<Text color={theme.text.primary} bold>
|
<Text color={theme.text.primary} bold>
|
||||||
{t('User Level ({{path}})', {
|
{t('User Level ({{path}})', {
|
||||||
path: userAgents[0].filePath.replace(/\/[^/]+$/, ''),
|
path: userAgents[0].filePath?.replace(/\/[^/]+$/, '') || '',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
|||||||
@@ -613,6 +613,12 @@ export class Config {
|
|||||||
}
|
}
|
||||||
this.promptRegistry = new PromptRegistry();
|
this.promptRegistry = new PromptRegistry();
|
||||||
this.subagentManager = new SubagentManager(this);
|
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();
|
this.toolRegistry = await this.createToolRegistry();
|
||||||
|
|
||||||
await this.geminiClient.initialize();
|
await this.geminiClient.initialize();
|
||||||
@@ -874,13 +880,6 @@ export class Config {
|
|||||||
this.sessionSubagents = subagents;
|
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 {
|
getSdkMode(): boolean {
|
||||||
return this.sdkMode;
|
return this.sdkMode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ You are a helpful assistant.
|
|||||||
it('should parse valid markdown content', () => {
|
it('should parse valid markdown content', () => {
|
||||||
const config = manager.parseSubagentContent(
|
const config = manager.parseSubagentContent(
|
||||||
validMarkdown,
|
validMarkdown,
|
||||||
validConfig.filePath,
|
validConfig.filePath!,
|
||||||
'project',
|
'project',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ You are a helpful assistant.
|
|||||||
|
|
||||||
const config = manager.parseSubagentContent(
|
const config = manager.parseSubagentContent(
|
||||||
markdownWithTools,
|
markdownWithTools,
|
||||||
validConfig.filePath,
|
validConfig.filePath!,
|
||||||
'project',
|
'project',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ You are a helpful assistant.
|
|||||||
|
|
||||||
const config = manager.parseSubagentContent(
|
const config = manager.parseSubagentContent(
|
||||||
markdownWithModel,
|
markdownWithModel,
|
||||||
validConfig.filePath,
|
validConfig.filePath!,
|
||||||
'project',
|
'project',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -249,7 +249,7 @@ You are a helpful assistant.
|
|||||||
|
|
||||||
const config = manager.parseSubagentContent(
|
const config = manager.parseSubagentContent(
|
||||||
markdownWithRun,
|
markdownWithRun,
|
||||||
validConfig.filePath,
|
validConfig.filePath!,
|
||||||
'project',
|
'project',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ You are a helpful assistant.
|
|||||||
|
|
||||||
const config = manager.parseSubagentContent(
|
const config = manager.parseSubagentContent(
|
||||||
markdownWithNumeric,
|
markdownWithNumeric,
|
||||||
validConfig.filePath,
|
validConfig.filePath!,
|
||||||
'project',
|
'project',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ You are a helpful assistant.
|
|||||||
|
|
||||||
const config = manager.parseSubagentContent(
|
const config = manager.parseSubagentContent(
|
||||||
markdownWithBoolean,
|
markdownWithBoolean,
|
||||||
validConfig.filePath,
|
validConfig.filePath!,
|
||||||
'project',
|
'project',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ Just content`;
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
manager.parseSubagentContent(
|
manager.parseSubagentContent(
|
||||||
invalidMarkdown,
|
invalidMarkdown,
|
||||||
validConfig.filePath,
|
validConfig.filePath!,
|
||||||
'project',
|
'project',
|
||||||
),
|
),
|
||||||
).toThrow(SubagentError);
|
).toThrow(SubagentError);
|
||||||
@@ -341,7 +341,7 @@ You are a helpful assistant.
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
manager.parseSubagentContent(
|
manager.parseSubagentContent(
|
||||||
markdownWithoutName,
|
markdownWithoutName,
|
||||||
validConfig.filePath,
|
validConfig.filePath!,
|
||||||
'project',
|
'project',
|
||||||
),
|
),
|
||||||
).toThrow(SubagentError);
|
).toThrow(SubagentError);
|
||||||
@@ -358,7 +358,7 @@ You are a helpful assistant.
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
manager.parseSubagentContent(
|
manager.parseSubagentContent(
|
||||||
markdownWithoutDescription,
|
markdownWithoutDescription,
|
||||||
validConfig.filePath,
|
validConfig.filePath!,
|
||||||
'project',
|
'project',
|
||||||
),
|
),
|
||||||
).toThrow(SubagentError);
|
).toThrow(SubagentError);
|
||||||
@@ -438,7 +438,7 @@ You are a helpful assistant.
|
|||||||
await manager.createSubagent(validConfig, { level: 'project' });
|
await manager.createSubagent(validConfig, { level: 'project' });
|
||||||
|
|
||||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||||
path.normalize(path.dirname(validConfig.filePath)),
|
path.normalize(path.dirname(validConfig.filePath!)),
|
||||||
{ recursive: true },
|
{ recursive: true },
|
||||||
);
|
);
|
||||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -159,7 +159,14 @@ export class SubagentManager {
|
|||||||
return this.findSubagentByNameAtLevel(name, level);
|
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');
|
const projectConfig = await this.findSubagentByNameAtLevel(name, 'project');
|
||||||
if (projectConfig) {
|
if (projectConfig) {
|
||||||
return projectConfig;
|
return projectConfig;
|
||||||
@@ -220,6 +227,15 @@ export class SubagentManager {
|
|||||||
// Validate the updated configuration
|
// Validate the updated configuration
|
||||||
this.validator.validateOrThrow(updatedConfig);
|
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
|
// Write the updated configuration
|
||||||
const content = this.serializeSubagent(updatedConfig);
|
const content = this.serializeSubagent(updatedConfig);
|
||||||
|
|
||||||
@@ -302,11 +318,6 @@ export class SubagentManager {
|
|||||||
|
|
||||||
// In SDK mode, only load session-level subagents
|
// In SDK mode, only load session-level subagents
|
||||||
if (this.config.getSdkMode()) {
|
if (this.config.getSdkMode()) {
|
||||||
const sessionSubagents = this.config.getSessionSubagents();
|
|
||||||
if (sessionSubagents && sessionSubagents.length > 0) {
|
|
||||||
this.loadSessionSubagents(sessionSubagents);
|
|
||||||
}
|
|
||||||
|
|
||||||
const levelsToCheck: SubagentLevel[] = options.level
|
const levelsToCheck: SubagentLevel[] = options.level
|
||||||
? [options.level]
|
? [options.level]
|
||||||
: ['session'];
|
: ['session'];
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export interface SubagentConfig {
|
|||||||
/** Storage level - determines where the configuration file is stored */
|
/** Storage level - determines where the configuration file is stored */
|
||||||
level: SubagentLevel;
|
level: SubagentLevel;
|
||||||
|
|
||||||
/** Absolute path to the configuration file */
|
/** Absolute path to the configuration file. Optional for session subagents. */
|
||||||
filePath: string;
|
filePath?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional model configuration. If not provided, uses defaults.
|
* Optional model configuration. If not provided, uses defaults.
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export class Query implements AsyncIterable<CLIMessage> {
|
|||||||
sdkMcpServers:
|
sdkMcpServers:
|
||||||
sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined,
|
sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined,
|
||||||
mcpServers: this.options.mcpServers,
|
mcpServers: this.options.mcpServers,
|
||||||
|
agents: this.options.agents,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Query] Initialization error:', error);
|
console.error('[Query] Initialization error:', error);
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ export interface SubagentConfig {
|
|||||||
tools?: string[];
|
tools?: string[];
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
level: SubagentLevel;
|
level: SubagentLevel;
|
||||||
filePath: string;
|
filePath?: string;
|
||||||
modelConfig?: Partial<ModelConfig>;
|
modelConfig?: Partial<ModelConfig>;
|
||||||
runConfig?: Partial<RunConfig>;
|
runConfig?: Partial<RunConfig>;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export const SubagentConfigSchema = z.object({
|
|||||||
description: z.string().min(1, 'Description must be a non-empty string'),
|
description: z.string().min(1, 'Description must be a non-empty string'),
|
||||||
tools: z.array(z.string()).optional(),
|
tools: z.array(z.string()).optional(),
|
||||||
systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'),
|
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(),
|
modelConfig: ModelConfigSchema.partial().optional(),
|
||||||
runConfig: RunConfigSchema.partial().optional(),
|
runConfig: RunConfigSchema.partial().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
@@ -71,9 +70,9 @@ export const QueryOptionsSchema = z
|
|||||||
typeof val === 'object' &&
|
typeof val === 'object' &&
|
||||||
'name' in val &&
|
'name' in val &&
|
||||||
'description' in val &&
|
'description' in val &&
|
||||||
'systemPrompt' in val &&
|
'systemPrompt' in val && {
|
||||||
'filePath' in val,
|
message: 'agents must be an array of SubagentConfig objects',
|
||||||
{ message: 'agents must be an array of SubagentConfig objects' },
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
656
packages/sdk-typescript/test/e2e/subagents.test.ts
Normal file
656
packages/sdk-typescript/test/e2e/subagents.test.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user