mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
chore: bump version to 0.1431
This commit is contained in:
@@ -1,224 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildTaskSpec } from '../src/mcp-server.js';
|
||||
|
||||
describe('mcp-server', () => {
|
||||
describe('buildTaskSpec', () => {
|
||||
it('should build minimal task spec with required fields only', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'test-001',
|
||||
name: 'Test Agent',
|
||||
instructions: 'Do something useful.',
|
||||
});
|
||||
|
||||
expect(result).toContain('---');
|
||||
expect(result).toContain('agentId: test-001');
|
||||
expect(result).toContain('name: Test Agent');
|
||||
expect(result).toContain('Do something useful.');
|
||||
|
||||
// Should not contain optional fields
|
||||
expect(result).not.toContain('type:');
|
||||
expect(result).not.toContain('priority:');
|
||||
expect(result).not.toContain('maxTokens:');
|
||||
expect(result).not.toContain('maxCost:');
|
||||
expect(result).not.toContain('timeoutMinutes:');
|
||||
expect(result).not.toContain('contextFiles:');
|
||||
expect(result).not.toContain('dependsOn:');
|
||||
});
|
||||
|
||||
it('should include all optional fields when provided', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'full-agent',
|
||||
name: 'Full Agent',
|
||||
instructions: 'Complete task.',
|
||||
type: 'implement',
|
||||
priority: 'high',
|
||||
maxTokens: 200000,
|
||||
maxCost: 1.50,
|
||||
timeoutMinutes: 60,
|
||||
canModifyParentFiles: true,
|
||||
contextFiles: ['src/auth.ts', 'src/types.ts'],
|
||||
dependsOn: ['agent-a', 'agent-b'],
|
||||
completionPhrase: 'FULL_DONE',
|
||||
outputFormat: 'structured',
|
||||
successCriteria: 'All tests pass',
|
||||
workingDir: '/tmp/workspace',
|
||||
});
|
||||
|
||||
expect(result).toContain('agentId: full-agent');
|
||||
expect(result).toContain('name: Full Agent');
|
||||
expect(result).toContain('type: implement');
|
||||
expect(result).toContain('priority: high');
|
||||
expect(result).toContain('maxTokens: 200000');
|
||||
expect(result).toContain('maxCost: 1.5');
|
||||
expect(result).toContain('timeoutMinutes: 60');
|
||||
expect(result).toContain('canModifyParentFiles: true');
|
||||
expect(result).toContain('contextFiles: [src/auth.ts, src/types.ts]');
|
||||
expect(result).toContain('dependsOn: [agent-a, agent-b]');
|
||||
expect(result).toContain('completionPhrase: FULL_DONE');
|
||||
expect(result).toContain('outputFormat: structured');
|
||||
expect(result).toContain('successCriteria: "All tests pass"');
|
||||
expect(result).toContain('workingDir: /tmp/workspace');
|
||||
expect(result).toContain('Complete task.');
|
||||
});
|
||||
|
||||
it('should produce valid YAML frontmatter structure', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'yaml-test',
|
||||
name: 'YAML Test',
|
||||
instructions: 'Body content here.',
|
||||
});
|
||||
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('---');
|
||||
|
||||
// Find closing ---
|
||||
const closingIndex = lines.indexOf('---', 1);
|
||||
expect(closingIndex).toBeGreaterThan(0);
|
||||
|
||||
// Body should come after closing ---
|
||||
const body = lines.slice(closingIndex + 1).join('\n').trim();
|
||||
expect(body).toBe('Body content here.');
|
||||
});
|
||||
|
||||
it('should escape double quotes in successCriteria', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'escape-test',
|
||||
name: 'Escape Test',
|
||||
instructions: 'test',
|
||||
successCriteria: 'Output "hello" correctly',
|
||||
});
|
||||
|
||||
expect(result).toContain('successCriteria: "Output \\"hello\\" correctly"');
|
||||
});
|
||||
|
||||
it('should handle empty context files array', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'empty-ctx',
|
||||
name: 'Empty Context',
|
||||
instructions: 'test',
|
||||
contextFiles: [],
|
||||
});
|
||||
|
||||
expect(result).not.toContain('contextFiles:');
|
||||
});
|
||||
|
||||
it('should handle empty dependsOn array', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'empty-deps',
|
||||
name: 'Empty Deps',
|
||||
instructions: 'test',
|
||||
dependsOn: [],
|
||||
});
|
||||
|
||||
expect(result).not.toContain('dependsOn:');
|
||||
});
|
||||
|
||||
it('should handle canModifyParentFiles: false', () => {
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'no-modify',
|
||||
name: 'No Modify',
|
||||
instructions: 'test',
|
||||
canModifyParentFiles: false,
|
||||
});
|
||||
|
||||
expect(result).toContain('canModifyParentFiles: false');
|
||||
});
|
||||
|
||||
it('should handle multiline instructions', () => {
|
||||
const instructions = `# Step 1
|
||||
Do this first.
|
||||
|
||||
# Step 2
|
||||
Then do this.
|
||||
|
||||
## Notes
|
||||
- Important detail
|
||||
- Another detail`;
|
||||
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'multiline',
|
||||
name: 'Multiline',
|
||||
instructions,
|
||||
});
|
||||
|
||||
expect(result).toContain('# Step 1');
|
||||
expect(result).toContain('Do this first.');
|
||||
expect(result).toContain('# Step 2');
|
||||
expect(result).toContain('- Important detail');
|
||||
});
|
||||
|
||||
it('should be parseable by parseYamlFrontmatter', async () => {
|
||||
const { parseYamlFrontmatter } = await import('../src/spawn-types.js');
|
||||
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'parse-test',
|
||||
name: 'Parse Test',
|
||||
instructions: 'Verify parsing works.',
|
||||
type: 'explore',
|
||||
priority: 'high',
|
||||
maxTokens: 100000,
|
||||
timeoutMinutes: 15,
|
||||
});
|
||||
|
||||
const parsed = parseYamlFrontmatter(result);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.frontmatter.agentId).toBe('parse-test');
|
||||
expect(parsed!.frontmatter.name).toBe('Parse Test');
|
||||
expect(parsed!.frontmatter.type).toBe('explore');
|
||||
expect(parsed!.frontmatter.priority).toBe('high');
|
||||
expect(parsed!.frontmatter.maxTokens).toBe(100000);
|
||||
expect(parsed!.frontmatter.timeoutMinutes).toBe(15);
|
||||
expect(parsed!.body).toBe('Verify parsing works.');
|
||||
});
|
||||
|
||||
it('should produce spec parseable by parseTaskSpecFile', async () => {
|
||||
const { parseTaskSpecFile } = await import('../src/spawn-types.js');
|
||||
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'full-spec',
|
||||
name: 'Full Spec Agent',
|
||||
instructions: 'Do the work.',
|
||||
type: 'implement',
|
||||
priority: 'critical',
|
||||
maxTokens: 250000,
|
||||
maxCost: 2.0,
|
||||
timeoutMinutes: 45,
|
||||
canModifyParentFiles: true,
|
||||
completionPhrase: 'SPEC_DONE',
|
||||
outputFormat: 'json',
|
||||
});
|
||||
|
||||
const parsed = parseTaskSpecFile(result, 'fallback-id');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.spec.agentId).toBe('full-spec');
|
||||
expect(parsed!.spec.name).toBe('Full Spec Agent');
|
||||
expect(parsed!.spec.type).toBe('implement');
|
||||
expect(parsed!.spec.priority).toBe('critical');
|
||||
expect(parsed!.spec.maxTokens).toBe(250000);
|
||||
expect(parsed!.spec.maxCost).toBe(2.0);
|
||||
expect(parsed!.spec.timeoutMinutes).toBe(45);
|
||||
expect(parsed!.spec.canModifyParentFiles).toBe(true);
|
||||
expect(parsed!.spec.completionPhrase).toBe('SPEC_DONE');
|
||||
expect(parsed!.spec.outputFormat).toBe('json');
|
||||
expect(parsed!.instructions).toBe('Do the work.');
|
||||
});
|
||||
|
||||
it('should handle contextFiles in parseable format', async () => {
|
||||
const { parseYamlFrontmatter } = await import('../src/spawn-types.js');
|
||||
|
||||
const result = buildTaskSpec({
|
||||
agentId: 'ctx-parse',
|
||||
name: 'Context Parse',
|
||||
instructions: 'test',
|
||||
contextFiles: ['src/foo.ts', 'src/bar.ts'],
|
||||
});
|
||||
|
||||
const parsed = parseYamlFrontmatter(result);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(Array.isArray(parsed!.frontmatter.contextFiles)).toBe(true);
|
||||
const files = parsed!.frontmatter.contextFiles as string[];
|
||||
expect(files).toContain('src/foo.ts');
|
||||
expect(files).toContain('src/bar.ts');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,968 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { SpawnOrchestrator, type SessionCreator } from '../src/spawn-orchestrator.js';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
/**
|
||||
* SpawnOrchestrator Tests
|
||||
*
|
||||
* Tests the full lifecycle management of spawned agents.
|
||||
* Uses a temporary directory and mock session creator.
|
||||
*/
|
||||
|
||||
describe('SpawnOrchestrator', () => {
|
||||
let orchestrator: SpawnOrchestrator;
|
||||
let testDir: string;
|
||||
let mockSessionCreator: SessionCreator;
|
||||
let completionHandlers: Map<string, (phrase: string) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `spawn-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
||||
completionHandlers = new Map();
|
||||
|
||||
mockSessionCreator = {
|
||||
createAgentSession: vi.fn().mockResolvedValue({ sessionId: `session-${Date.now()}` }),
|
||||
writeToSession: vi.fn(),
|
||||
getSessionTokens: vi.fn().mockReturnValue(0),
|
||||
getSessionCost: vi.fn().mockReturnValue(0),
|
||||
stopSession: vi.fn().mockResolvedValue(undefined),
|
||||
onSessionCompletion: vi.fn().mockImplementation((sessionId, handler) => {
|
||||
completionHandlers.set(sessionId, handler);
|
||||
}),
|
||||
removeSessionCompletionHandler: vi.fn().mockImplementation((sessionId) => {
|
||||
completionHandlers.delete(sessionId);
|
||||
}),
|
||||
};
|
||||
|
||||
orchestrator = new SpawnOrchestrator({
|
||||
casesDir: testDir,
|
||||
maxConcurrentAgents: 3,
|
||||
maxSpawnDepth: 2,
|
||||
defaultTimeoutMinutes: 5,
|
||||
maxTimeoutMinutes: 10,
|
||||
progressPollIntervalMs: 60000, // Long interval to avoid interference
|
||||
});
|
||||
|
||||
orchestrator.setSessionCreator(mockSessionCreator);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Stop all agents and clear timers
|
||||
orchestrator.stopAll().catch(() => {});
|
||||
orchestrator.removeAllListeners();
|
||||
// Clean up test directory
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createTaskFile(dir: string, filename: string, content: string): string {
|
||||
const filePath = join(dir, filename);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const basicTaskContent = `---
|
||||
agentId: test-agent-001
|
||||
name: Test Agent
|
||||
type: explore
|
||||
priority: normal
|
||||
timeoutMinutes: 5
|
||||
completionPhrase: TEST_DONE
|
||||
canModifyParentFiles: false
|
||||
---
|
||||
|
||||
# Test Task
|
||||
|
||||
Do a simple test.`;
|
||||
|
||||
describe('Configuration', () => {
|
||||
it('should use provided config', () => {
|
||||
expect(orchestrator.config.maxConcurrentAgents).toBe(3);
|
||||
expect(orchestrator.config.maxSpawnDepth).toBe(2);
|
||||
});
|
||||
|
||||
it('should update config', () => {
|
||||
orchestrator.updateConfig({ maxConcurrentAgents: 10 });
|
||||
expect(orchestrator.config.maxConcurrentAgents).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSpawnRequest', () => {
|
||||
it('should reject when no session creator is set', async () => {
|
||||
const noCreator = new SpawnOrchestrator({ casesDir: testDir });
|
||||
const failHandler = vi.fn();
|
||||
noCreator.on('failed', failHandler);
|
||||
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await noCreator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
// Should not crash, just log error
|
||||
expect(failHandler).not.toHaveBeenCalled(); // Silent failure with console.error
|
||||
});
|
||||
|
||||
it('should fail when task file does not exist', async () => {
|
||||
const failHandler = vi.fn();
|
||||
orchestrator.on('failed', failHandler);
|
||||
|
||||
await orchestrator.handleSpawnRequest('nonexistent.md', 'parent-session', testDir);
|
||||
|
||||
expect(failHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.stringContaining('not found') })
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail when task file cannot be parsed', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'bad.md', 'No frontmatter here');
|
||||
const failHandler = vi.fn();
|
||||
orchestrator.on('failed', failHandler);
|
||||
|
||||
await orchestrator.handleSpawnRequest('bad.md', 'parent-session', parentDir);
|
||||
|
||||
expect(failHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.stringContaining('parse') })
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when max depth exceeded', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
const failHandler = vi.fn();
|
||||
orchestrator.on('failed', failHandler);
|
||||
|
||||
// Max depth is 2, so parentDepth=2 means child would be 3 (exceeds)
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir, 2);
|
||||
|
||||
expect(failHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.stringContaining('depth') })
|
||||
);
|
||||
});
|
||||
|
||||
it('should spawn agent and create directory structure', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
// Wait for async initialization
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSessionCreator.createAgentSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check directory was created
|
||||
const agentDir = join(testDir, 'spawn-test-agent-001');
|
||||
expect(existsSync(agentDir)).toBe(true);
|
||||
expect(existsSync(join(agentDir, 'CLAUDE.md'))).toBe(true);
|
||||
expect(existsSync(join(agentDir, 'spawn-comms'))).toBe(true);
|
||||
expect(existsSync(join(agentDir, 'spawn-comms', 'task.md'))).toBe(true);
|
||||
expect(existsSync(join(agentDir, 'spawn-comms', 'progress.json'))).toBe(true);
|
||||
expect(existsSync(join(agentDir, 'spawn-comms', 'messages'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate proper CLAUDE.md for agent', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSessionCreator.createAgentSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const agentDir = join(testDir, 'spawn-test-agent-001');
|
||||
const claudeMd = readFileSync(join(agentDir, 'CLAUDE.md'), 'utf-8');
|
||||
expect(claudeMd).toContain('Agent: Test Agent');
|
||||
expect(claudeMd).toContain('test-agent-001');
|
||||
expect(claudeMd).toContain('TEST_DONE');
|
||||
expect(claudeMd).toContain('# Test Task');
|
||||
});
|
||||
|
||||
it('should emit initializing and started events', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
const initHandler = vi.fn();
|
||||
const startHandler = vi.fn();
|
||||
orchestrator.on('initializing', initHandler);
|
||||
orchestrator.on('started', startHandler);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(startHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(initHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'test-agent-001', name: 'Test Agent' })
|
||||
);
|
||||
expect(startHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'test-agent-001', name: 'Test Agent' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should enforce timeout limits', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
const content = basicTaskContent.replace('timeoutMinutes: 5', 'timeoutMinutes: 999');
|
||||
createTaskFile(parentDir, 'task.md', content);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const status = orchestrator.getAgentStatus('test-agent-001');
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.timeoutMinutes).toBe(10); // Capped at maxTimeoutMinutes
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queue Management', () => {
|
||||
it('should queue agents when concurrency limit reached', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
|
||||
// Create 4 tasks (limit is 3)
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const content = basicTaskContent
|
||||
.replace('test-agent-001', `agent-${i}`)
|
||||
.replace('Test Agent', `Agent ${i}`);
|
||||
createTaskFile(parentDir, `task${i}.md`, content);
|
||||
}
|
||||
|
||||
const queueHandler = vi.fn();
|
||||
orchestrator.on('queued', queueHandler);
|
||||
|
||||
// Spawn 4 agents
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await orchestrator.handleSpawnRequest(`task${i}.md`, 'parent', parentDir);
|
||||
}
|
||||
|
||||
// Wait for first 3 to start
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSessionCreator.createAgentSession).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// 4th should be queued
|
||||
expect(queueHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agent-4' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should order queue by priority', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
|
||||
// Fill concurrency first
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const content = basicTaskContent
|
||||
.replace('test-agent-001', `filler-${i}`)
|
||||
.replace('Test Agent', `Filler ${i}`);
|
||||
createTaskFile(parentDir, `filler${i}.md`, content);
|
||||
await orchestrator.handleSpawnRequest(`filler${i}.md`, 'parent', parentDir);
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSessionCreator.createAgentSession).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// Now add low and high priority
|
||||
const lowContent = basicTaskContent
|
||||
.replace('test-agent-001', 'low-agent')
|
||||
.replace('priority: normal', 'priority: low');
|
||||
createTaskFile(parentDir, 'low.md', lowContent);
|
||||
|
||||
const highContent = basicTaskContent
|
||||
.replace('test-agent-001', 'high-agent')
|
||||
.replace('priority: normal', 'priority: critical');
|
||||
createTaskFile(parentDir, 'high.md', highContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('low.md', 'parent', parentDir);
|
||||
await orchestrator.handleSpawnRequest('high.md', 'parent', parentDir);
|
||||
|
||||
// State should show high priority first in queue
|
||||
const state = orchestrator.getState();
|
||||
expect(state.queuedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelAgent', () => {
|
||||
it('should cancel a running agent', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull();
|
||||
});
|
||||
|
||||
const cancelHandler = vi.fn();
|
||||
orchestrator.on('cancelled', cancelHandler);
|
||||
|
||||
await orchestrator.cancelAgent('test-agent-001', 'User cancelled');
|
||||
|
||||
expect(cancelHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'test-agent-001', reason: 'User cancelled' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should cancel a queued agent', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
|
||||
// Fill concurrency
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const content = basicTaskContent
|
||||
.replace('test-agent-001', `filler-${i}`)
|
||||
.replace('Test Agent', `Filler ${i}`);
|
||||
createTaskFile(parentDir, `filler${i}.md`, content);
|
||||
await orchestrator.handleSpawnRequest(`filler${i}.md`, 'parent', parentDir);
|
||||
}
|
||||
|
||||
// Add one more (queued)
|
||||
const queuedContent = basicTaskContent.replace('test-agent-001', 'queued-agent');
|
||||
createTaskFile(parentDir, 'queued.md', queuedContent);
|
||||
await orchestrator.handleSpawnRequest('queued.md', 'parent', parentDir);
|
||||
|
||||
const cancelHandler = vi.fn();
|
||||
orchestrator.on('cancelled', cancelHandler);
|
||||
|
||||
await orchestrator.cancelAgent('queued-agent');
|
||||
|
||||
expect(cancelHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'queued-agent' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessageToAgent', () => {
|
||||
it('should write message file to comms directory', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull();
|
||||
});
|
||||
|
||||
await orchestrator.sendMessageToAgent('test-agent-001', 'Focus on JWT');
|
||||
|
||||
const messagesDir = join(testDir, 'spawn-test-agent-001', 'spawn-comms', 'messages');
|
||||
expect(existsSync(join(messagesDir, '001-parent.md'))).toBe(true);
|
||||
|
||||
const content = readFileSync(join(messagesDir, '001-parent.md'), 'utf-8');
|
||||
expect(content).toBe('Focus on JWT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentStatus', () => {
|
||||
it('should return null for unknown agent', () => {
|
||||
expect(orchestrator.getAgentStatus('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return status for active agent', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const status = orchestrator.getAgentStatus('test-agent-001');
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.status).toBe('running');
|
||||
expect(status!.name).toBe('Test Agent');
|
||||
expect(status!.completionPhrase).toBe('TEST_DONE');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getState', () => {
|
||||
it('should return complete orchestrator state', () => {
|
||||
const state = orchestrator.getState();
|
||||
expect(state.enabled).toBe(true);
|
||||
expect(state.activeCount).toBe(0);
|
||||
expect(state.queuedCount).toBe(0);
|
||||
expect(state.totalSpawned).toBe(0);
|
||||
expect(state.agents).toEqual([]);
|
||||
});
|
||||
|
||||
it('should update counts after spawn', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const state = orchestrator.getState();
|
||||
expect(state.activeCount).toBe(1);
|
||||
expect(state.totalSpawned).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAgentMessages', () => {
|
||||
it('should return empty for unknown agent', () => {
|
||||
expect(orchestrator.readAgentMessages('nonexistent')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should read messages from comms directory', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Write a message
|
||||
await orchestrator.sendMessageToAgent('test-agent-001', 'Hello agent');
|
||||
|
||||
const messages = orchestrator.readAgentMessages('test-agent-001');
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].sender).toBe('parent');
|
||||
expect(messages[0].content).toBe('Hello agent');
|
||||
expect(messages[0].sequence).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerSpawn', () => {
|
||||
it('should spawn from content string', async () => {
|
||||
const agentId = await orchestrator.triggerSpawn(
|
||||
basicTaskContent,
|
||||
'parent-session',
|
||||
testDir
|
||||
);
|
||||
|
||||
expect(agentId).toBe('test-agent-001');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSessionCreator.createAgentSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for unparseable content', async () => {
|
||||
const agentId = await orchestrator.triggerSpawn(
|
||||
'Not valid YAML frontmatter',
|
||||
'parent-session',
|
||||
testDir
|
||||
);
|
||||
|
||||
expect(agentId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopAll', () => {
|
||||
it('should stop all active agents', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(orchestrator.getState().activeCount).toBe(1);
|
||||
});
|
||||
|
||||
await orchestrator.stopAll();
|
||||
|
||||
expect(orchestrator.getState().activeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPersistedState', () => {
|
||||
it('should return serializable state', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(orchestrator.getState().activeCount).toBe(1);
|
||||
});
|
||||
|
||||
const persisted = orchestrator.getPersistedState();
|
||||
expect(persisted.config).toBeDefined();
|
||||
expect(persisted.agents['test-agent-001']).toBeDefined();
|
||||
expect(persisted.agents['test-agent-001'].completionPhrase).toBe('TEST_DONE');
|
||||
|
||||
// Should be JSON-serializable
|
||||
expect(() => JSON.stringify(persisted)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Issue Coverage Tests ==========
|
||||
|
||||
describe('Cascading Cancellation', () => {
|
||||
it('should cancel child agents when parent is cancelled', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
|
||||
// Create parent agent
|
||||
const parentContent = basicTaskContent
|
||||
.replace('test-agent-001', 'parent-agent')
|
||||
.replace('Test Agent', 'Parent Agent');
|
||||
createTaskFile(parentDir, 'parent.md', parentContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('parent.md', 'user-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(orchestrator.getAgentStatus('parent-agent')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Create child agent that depends on parent
|
||||
const childContent = `---
|
||||
agentId: child-agent
|
||||
name: Child Agent
|
||||
type: explore
|
||||
priority: normal
|
||||
timeoutMinutes: 5
|
||||
completionPhrase: CHILD_DONE
|
||||
canModifyParentFiles: false
|
||||
---
|
||||
|
||||
# Child Task
|
||||
|
||||
Child agent work.`;
|
||||
createTaskFile(parentDir, 'child.md', childContent);
|
||||
|
||||
// Spawn child with parent-agent's session as parent
|
||||
// Note: In current implementation, we simulate the parent relationship via parentSessionId
|
||||
await orchestrator.handleSpawnRequest('child.md', 'parent-agent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(orchestrator.getAgentStatus('child-agent')).not.toBeNull();
|
||||
});
|
||||
|
||||
const cancelHandler = vi.fn();
|
||||
orchestrator.on('cancelled', cancelHandler);
|
||||
|
||||
// Cancel parent - this SHOULD also cancel child (if cascading is implemented)
|
||||
await orchestrator.cancelAgent('parent-agent', 'User cancelled parent');
|
||||
|
||||
// Currently, this test documents the EXPECTED behavior.
|
||||
// The current implementation does NOT cascade cancellations.
|
||||
// If cascading is implemented, uncomment the assertion below:
|
||||
// expect(cancelHandler).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Current behavior: only parent is cancelled
|
||||
expect(cancelHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'parent-agent', reason: 'User cancelled parent' })
|
||||
);
|
||||
|
||||
// Verify child is still running (documents current buggy behavior)
|
||||
const childStatus = orchestrator.getAgentStatus('child-agent');
|
||||
// When cascading is fixed, this should be 'cancelled' instead of 'running'
|
||||
expect(childStatus?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should handle cancellation when no children exist', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull();
|
||||
});
|
||||
|
||||
const cancelHandler = vi.fn();
|
||||
orchestrator.on('cancelled', cancelHandler);
|
||||
|
||||
// Cancel agent with no children - should work normally
|
||||
await orchestrator.cancelAgent('test-agent-001', 'Normal cancellation');
|
||||
|
||||
expect(cancelHandler).toHaveBeenCalledTimes(1);
|
||||
expect(cancelHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'test-agent-001', reason: 'Normal cancellation' })
|
||||
);
|
||||
|
||||
// Agent should be cleaned up
|
||||
expect(orchestrator.getState().activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should recursively cancel grandchildren when parent is cancelled', async () => {
|
||||
const parentDir = join(testDir, 'parent-grandchild');
|
||||
|
||||
// Create orchestrator with higher concurrency and depth for this test
|
||||
const deepOrchestrator = new SpawnOrchestrator({
|
||||
casesDir: testDir,
|
||||
maxConcurrentAgents: 5,
|
||||
maxSpawnDepth: 3,
|
||||
defaultTimeoutMinutes: 5,
|
||||
maxTimeoutMinutes: 10,
|
||||
progressPollIntervalMs: 60000,
|
||||
});
|
||||
deepOrchestrator.setSessionCreator(mockSessionCreator);
|
||||
|
||||
// Create grandparent agent
|
||||
const grandparentContent = basicTaskContent
|
||||
.replace('test-agent-001', 'grandparent-agent')
|
||||
.replace('Test Agent', 'Grandparent Agent');
|
||||
createTaskFile(parentDir, 'grandparent.md', grandparentContent);
|
||||
|
||||
await deepOrchestrator.handleSpawnRequest('grandparent.md', 'user-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(deepOrchestrator.getAgentStatus('grandparent-agent')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Create parent agent (child of grandparent)
|
||||
const parentContent = basicTaskContent
|
||||
.replace('test-agent-001', 'parent-agent')
|
||||
.replace('Test Agent', 'Parent Agent');
|
||||
createTaskFile(parentDir, 'parent.md', parentContent);
|
||||
|
||||
await deepOrchestrator.handleSpawnRequest('parent.md', 'grandparent-agent-session', parentDir, 1);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(deepOrchestrator.getAgentStatus('parent-agent')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Create child agent (grandchild of grandparent)
|
||||
const childContent = basicTaskContent
|
||||
.replace('test-agent-001', 'child-agent')
|
||||
.replace('Test Agent', 'Child Agent');
|
||||
createTaskFile(parentDir, 'child.md', childContent);
|
||||
|
||||
await deepOrchestrator.handleSpawnRequest('child.md', 'parent-agent-session', parentDir, 2);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(deepOrchestrator.getAgentStatus('child-agent')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Verify all three agents are running
|
||||
expect(deepOrchestrator.getState().activeCount).toBe(3);
|
||||
|
||||
const cancelHandler = vi.fn();
|
||||
deepOrchestrator.on('cancelled', cancelHandler);
|
||||
|
||||
// Cancel grandparent - this SHOULD cascade to parent and child
|
||||
await deepOrchestrator.cancelAgent('grandparent-agent', 'User cancelled grandparent');
|
||||
|
||||
// Document current behavior: only grandparent is cancelled (no cascade)
|
||||
expect(cancelHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'grandparent-agent' })
|
||||
);
|
||||
|
||||
// Current behavior: parent and child are still running (documents the bug)
|
||||
const parentStatus = deepOrchestrator.getAgentStatus('parent-agent');
|
||||
const childStatus = deepOrchestrator.getAgentStatus('child-agent');
|
||||
|
||||
// When cascading is implemented:
|
||||
// expect(parentStatus?.status).toBe('cancelled');
|
||||
// expect(childStatus?.status).toBe('cancelled');
|
||||
// expect(cancelHandler).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Current buggy behavior:
|
||||
expect(parentStatus?.status).toBe('running');
|
||||
expect(childStatus?.status).toBe('running');
|
||||
expect(cancelHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Cleanup
|
||||
await deepOrchestrator.stopAll();
|
||||
deepOrchestrator.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Budget Validation', () => {
|
||||
it('should handle negative maxTokens in task spec', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
|
||||
// Task with negative maxTokens
|
||||
const content = `---
|
||||
agentId: negative-tokens-agent
|
||||
name: Negative Tokens Agent
|
||||
type: explore
|
||||
priority: normal
|
||||
timeoutMinutes: 5
|
||||
completionPhrase: NEG_DONE
|
||||
canModifyParentFiles: false
|
||||
maxTokens: -1000
|
||||
---
|
||||
|
||||
# Test negative tokens`;
|
||||
|
||||
createTaskFile(parentDir, 'negative.md', content);
|
||||
|
||||
await orchestrator.handleSpawnRequest('negative.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const status = orchestrator.getAgentStatus('negative-tokens-agent');
|
||||
// Current behavior: negative values are accepted (documents the issue)
|
||||
// When validation is added, this should either fail or clamp to 0/null
|
||||
expect(status).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zero maxCost in task spec', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
|
||||
// Task with zero maxCost
|
||||
const content = `---
|
||||
agentId: zero-cost-agent
|
||||
name: Zero Cost Agent
|
||||
type: explore
|
||||
priority: normal
|
||||
timeoutMinutes: 5
|
||||
completionPhrase: ZERO_DONE
|
||||
canModifyParentFiles: false
|
||||
maxCost: 0
|
||||
---
|
||||
|
||||
# Test zero cost`;
|
||||
|
||||
createTaskFile(parentDir, 'zero.md', content);
|
||||
|
||||
await orchestrator.handleSpawnRequest('zero.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const status = orchestrator.getAgentStatus('zero-cost-agent');
|
||||
expect(status).not.toBeNull();
|
||||
// Zero cost budget would immediately trigger 110% threshold check
|
||||
// on first budget check, causing immediate termination
|
||||
// This documents potentially problematic behavior
|
||||
expect(status!.costBudget).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept valid budget values', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
|
||||
const content = `---
|
||||
agentId: valid-budget-agent
|
||||
name: Valid Budget Agent
|
||||
type: explore
|
||||
priority: normal
|
||||
timeoutMinutes: 5
|
||||
completionPhrase: VALID_DONE
|
||||
canModifyParentFiles: false
|
||||
maxTokens: 100000
|
||||
maxCost: 1.50
|
||||
---
|
||||
|
||||
# Test valid budget`;
|
||||
|
||||
createTaskFile(parentDir, 'valid.md', content);
|
||||
|
||||
await orchestrator.handleSpawnRequest('valid.md', 'parent-session', parentDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const status = orchestrator.getAgentStatus('valid-budget-agent');
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.tokenBudget).toBe(100000);
|
||||
expect(status!.costBudget).toBe(1.50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queue Dependency Handling', () => {
|
||||
it('should not block independent tasks when one has unmet deps', async () => {
|
||||
const parentDir = join(testDir, 'parent');
|
||||
|
||||
// Fill concurrency first
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const content = basicTaskContent
|
||||
.replace('test-agent-001', `filler-${i}`)
|
||||
.replace('Test Agent', `Filler ${i}`);
|
||||
createTaskFile(parentDir, `filler${i}.md`, content);
|
||||
await orchestrator.handleSpawnRequest(`filler${i}.md`, 'parent', parentDir);
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSessionCreator.createAgentSession).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// Add task A that depends on non-existent Task X
|
||||
const dependentContent = `---
|
||||
agentId: dependent-agent
|
||||
name: Dependent Agent
|
||||
type: explore
|
||||
priority: normal
|
||||
timeoutMinutes: 5
|
||||
completionPhrase: DEP_DONE
|
||||
canModifyParentFiles: false
|
||||
dependsOn:
|
||||
- nonexistent-task-x
|
||||
---
|
||||
|
||||
# Dependent task`;
|
||||
|
||||
createTaskFile(parentDir, 'dependent.md', dependentContent);
|
||||
await orchestrator.handleSpawnRequest('dependent.md', 'parent', parentDir);
|
||||
|
||||
// Add Task B with no dependencies
|
||||
const independentContent = basicTaskContent
|
||||
.replace('test-agent-001', 'independent-agent')
|
||||
.replace('Test Agent', 'Independent Agent');
|
||||
createTaskFile(parentDir, 'independent.md', independentContent);
|
||||
await orchestrator.handleSpawnRequest('independent.md', 'parent', parentDir);
|
||||
|
||||
// Both should be queued
|
||||
expect(orchestrator.getState().queuedCount).toBe(2);
|
||||
|
||||
// Complete one of the filler agents to free up a slot
|
||||
const completionHandler = completionHandlers.get(
|
||||
(mockSessionCreator.createAgentSession as ReturnType<typeof vi.fn>).mock.results[0].value.sessionId
|
||||
);
|
||||
|
||||
// Simulate completion by triggering cleanup directly
|
||||
await orchestrator.cancelAgent('filler-1', 'Test cleanup');
|
||||
|
||||
// Wait for queue processing
|
||||
await vi.waitFor(() => {
|
||||
// Check if independent-agent started
|
||||
// Current buggy behavior: dependent-agent blocks the queue
|
||||
// The test documents this - when fixed, independent-agent should run
|
||||
const state = orchestrator.getState();
|
||||
// With the bug: queuedCount stays at 2 or decreases but independent doesn't start
|
||||
// When fixed: independent-agent should be running
|
||||
expect(state.activeCount).toBeGreaterThanOrEqual(2);
|
||||
}, { timeout: 1000 }).catch(() => {
|
||||
// Expected to fail with current implementation - documents the bug
|
||||
const state = orchestrator.getState();
|
||||
// Document current behavior: queue might be stuck
|
||||
console.log('Queue state (documents starvation bug):', {
|
||||
activeCount: state.activeCount,
|
||||
queuedCount: state.queuedCount,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timer Cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should clear timeout timer on agent completion', async () => {
|
||||
const parentDir = join(testDir, 'parent-timer1');
|
||||
mkdirSync(parentDir, { recursive: true });
|
||||
|
||||
// Use short timeout for testing
|
||||
const content = basicTaskContent.replace('timeoutMinutes: 5', 'timeoutMinutes: 1');
|
||||
createTaskFile(parentDir, 'task.md', content);
|
||||
|
||||
// Create a new orchestrator for this test to avoid timer conflicts
|
||||
const timerOrchestrator = new SpawnOrchestrator({
|
||||
casesDir: testDir,
|
||||
maxConcurrentAgents: 3,
|
||||
maxSpawnDepth: 2,
|
||||
defaultTimeoutMinutes: 5,
|
||||
maxTimeoutMinutes: 10,
|
||||
progressPollIntervalMs: 60000,
|
||||
});
|
||||
timerOrchestrator.setSessionCreator(mockSessionCreator);
|
||||
|
||||
// Start the spawn request (this sets up timers)
|
||||
const spawnPromise = timerOrchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
|
||||
// Run pending timers and promises
|
||||
await vi.runAllTimersAsync();
|
||||
await spawnPromise;
|
||||
|
||||
// Track that timeout event does NOT fire after cancellation
|
||||
const timeoutHandler = vi.fn();
|
||||
timerOrchestrator.on('timeout', timeoutHandler);
|
||||
|
||||
// Cancel the agent (which triggers cleanup)
|
||||
await timerOrchestrator.cancelAgent('test-agent-001', 'Test cleanup');
|
||||
|
||||
// Advance timers past the timeout period
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 1000); // 2 minutes
|
||||
|
||||
// Timeout should NOT have fired because timer was cleared
|
||||
expect(timeoutHandler).not.toHaveBeenCalled();
|
||||
|
||||
timerOrchestrator.removeAllListeners();
|
||||
});
|
||||
|
||||
it('should clear progress timer on cancellation', async () => {
|
||||
const parentDir = join(testDir, 'parent-timer2');
|
||||
mkdirSync(parentDir, { recursive: true });
|
||||
|
||||
// Create orchestrator with fast progress polling
|
||||
const fastPollOrchestrator = new SpawnOrchestrator({
|
||||
casesDir: testDir,
|
||||
maxConcurrentAgents: 3,
|
||||
maxSpawnDepth: 2,
|
||||
defaultTimeoutMinutes: 5,
|
||||
maxTimeoutMinutes: 10,
|
||||
progressPollIntervalMs: 100, // Fast polling
|
||||
});
|
||||
fastPollOrchestrator.setSessionCreator(mockSessionCreator);
|
||||
|
||||
createTaskFile(parentDir, 'task.md', basicTaskContent);
|
||||
|
||||
const spawnPromise = fastPollOrchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
await vi.runAllTimersAsync();
|
||||
await spawnPromise;
|
||||
|
||||
const progressHandler = vi.fn();
|
||||
fastPollOrchestrator.on('progress', progressHandler);
|
||||
|
||||
// Cancel the agent
|
||||
await fastPollOrchestrator.cancelAgent('test-agent-001', 'Test cleanup');
|
||||
|
||||
// Clear current call count
|
||||
progressHandler.mockClear();
|
||||
|
||||
// Advance time past several poll intervals
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
// Progress events should NOT fire after cancellation
|
||||
expect(progressHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Cleanup
|
||||
fastPollOrchestrator.removeAllListeners();
|
||||
});
|
||||
|
||||
it('should clear warning timer on early completion', async () => {
|
||||
const parentDir = join(testDir, 'parent-timer3');
|
||||
mkdirSync(parentDir, { recursive: true });
|
||||
|
||||
// Short timeout so warning would fire at ~54 seconds (90% of 1 min)
|
||||
const content = basicTaskContent.replace('timeoutMinutes: 5', 'timeoutMinutes: 1');
|
||||
createTaskFile(parentDir, 'task.md', content);
|
||||
|
||||
// Create a fresh orchestrator for this test
|
||||
const warningOrchestrator = new SpawnOrchestrator({
|
||||
casesDir: testDir,
|
||||
maxConcurrentAgents: 3,
|
||||
maxSpawnDepth: 2,
|
||||
defaultTimeoutMinutes: 5,
|
||||
maxTimeoutMinutes: 10,
|
||||
progressPollIntervalMs: 60000,
|
||||
});
|
||||
warningOrchestrator.setSessionCreator(mockSessionCreator);
|
||||
|
||||
const spawnPromise = warningOrchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir);
|
||||
await vi.runAllTimersAsync();
|
||||
await spawnPromise;
|
||||
|
||||
// Cancel before warning would fire
|
||||
await warningOrchestrator.cancelAgent('test-agent-001', 'Early completion');
|
||||
|
||||
// Clear the mock
|
||||
(mockSessionCreator.writeToSession as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Advance past warning time (54 seconds)
|
||||
await vi.advanceTimersByTimeAsync(60 * 1000);
|
||||
|
||||
// Warning message should NOT have been sent
|
||||
const writeToSessionCalls = (mockSessionCreator.writeToSession as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const warningCalls = writeToSessionCalls.filter(
|
||||
(call: [string, string]) => call[1]?.includes('WARNING') && call[1]?.includes('timeout')
|
||||
);
|
||||
expect(warningCalls.length).toBe(0);
|
||||
|
||||
warningOrchestrator.removeAllListeners();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,395 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseYamlFrontmatter,
|
||||
parseTaskSpecFile,
|
||||
createDefaultSpawnTaskSpec,
|
||||
createEmptyAgentProgress,
|
||||
createInitialSpawnTrackerState,
|
||||
createDefaultOrchestratorConfig,
|
||||
serializeSpawnResult,
|
||||
parseSpawnResult,
|
||||
AGENT_NAME_MAX_LENGTH,
|
||||
} from '../src/spawn-types.js';
|
||||
|
||||
describe('spawn-types', () => {
|
||||
describe('parseYamlFrontmatter', () => {
|
||||
it('should parse basic frontmatter', () => {
|
||||
const content = `---
|
||||
name: Test Agent
|
||||
type: explore
|
||||
priority: high
|
||||
---
|
||||
|
||||
# Task Body
|
||||
|
||||
Do something useful.`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.frontmatter.name).toBe('Test Agent');
|
||||
expect(result!.frontmatter.type).toBe('explore');
|
||||
expect(result!.frontmatter.priority).toBe('high');
|
||||
expect(result!.body).toContain('# Task Body');
|
||||
expect(result!.body).toContain('Do something useful.');
|
||||
});
|
||||
|
||||
it('should parse numbers', () => {
|
||||
const content = `---
|
||||
timeoutMinutes: 30
|
||||
maxCost: 0.50
|
||||
maxTokens: 150000
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result!.frontmatter.timeoutMinutes).toBe(30);
|
||||
expect(result!.frontmatter.maxCost).toBe(0.5);
|
||||
expect(result!.frontmatter.maxTokens).toBe(150000);
|
||||
});
|
||||
|
||||
it('should parse booleans', () => {
|
||||
const content = `---
|
||||
canModifyParentFiles: true
|
||||
enabled: false
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result!.frontmatter.canModifyParentFiles).toBe(true);
|
||||
expect(result!.frontmatter.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse inline arrays', () => {
|
||||
const content = `---
|
||||
dependsOn: [agent-1, agent-2]
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result!.frontmatter.dependsOn).toEqual(['agent-1', 'agent-2']);
|
||||
});
|
||||
|
||||
it('should parse block arrays', () => {
|
||||
const content = `---
|
||||
contextFiles:
|
||||
- src/auth.ts
|
||||
- src/middleware.ts
|
||||
- src/types.ts
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result!.frontmatter.contextFiles).toEqual(['src/auth.ts', 'src/middleware.ts', 'src/types.ts']);
|
||||
});
|
||||
|
||||
it('should parse empty arrays', () => {
|
||||
const content = `---
|
||||
dependsOn: []
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result!.frontmatter.dependsOn).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse quoted strings', () => {
|
||||
const content = `---
|
||||
name: "Test Agent"
|
||||
completionPhrase: 'AUTH_DONE'
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result!.frontmatter.name).toBe('Test Agent');
|
||||
expect(result!.frontmatter.completionPhrase).toBe('AUTH_DONE');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
const content = `---
|
||||
value1: null
|
||||
value2: ~
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result!.frontmatter.value1).toBeNull();
|
||||
expect(result!.frontmatter.value2).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle comments', () => {
|
||||
const content = `---
|
||||
# This is a comment
|
||||
name: Test
|
||||
# Another comment
|
||||
type: explore
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result!.frontmatter.name).toBe('Test');
|
||||
expect(result!.frontmatter.type).toBe('explore');
|
||||
});
|
||||
|
||||
it('should return null if no frontmatter delimiters', () => {
|
||||
const content = `No frontmatter here\nJust plain text`;
|
||||
expect(parseYamlFrontmatter(content)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if missing closing delimiter', () => {
|
||||
const content = `---\nname: Test\nNo closing delimiter`;
|
||||
expect(parseYamlFrontmatter(content)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle nested objects', () => {
|
||||
const content = `---
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DEBUG: true
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseYamlFrontmatter(content);
|
||||
expect(result!.frontmatter.env).toEqual({ NODE_ENV: 'production', DEBUG: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTaskSpecFile', () => {
|
||||
it('should parse a complete task spec', () => {
|
||||
const content = `---
|
||||
agentId: auth-explorer-001
|
||||
name: Authentication Explorer
|
||||
type: explore
|
||||
priority: high
|
||||
canModifyParentFiles: false
|
||||
maxTokens: 150000
|
||||
maxCost: 0.50
|
||||
timeoutMinutes: 15
|
||||
resultDelivery: both
|
||||
completionPhrase: AUTH_EXPLORE_DONE
|
||||
progressIntervalSeconds: 30
|
||||
outputFormat: structured
|
||||
successCriteria: "Document all auth patterns"
|
||||
---
|
||||
|
||||
# Task: Explore Authentication
|
||||
|
||||
Analyze the auth system.`;
|
||||
|
||||
const result = parseTaskSpecFile(content, 'fallback-id');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.spec.agentId).toBe('auth-explorer-001');
|
||||
expect(result!.spec.name).toBe('Authentication Explorer');
|
||||
expect(result!.spec.type).toBe('explore');
|
||||
expect(result!.spec.priority).toBe('high');
|
||||
expect(result!.spec.canModifyParentFiles).toBe(false);
|
||||
expect(result!.spec.maxTokens).toBe(150000);
|
||||
expect(result!.spec.maxCost).toBe(0.5);
|
||||
expect(result!.spec.timeoutMinutes).toBe(15);
|
||||
expect(result!.spec.completionPhrase).toBe('AUTH_EXPLORE_DONE');
|
||||
expect(result!.spec.outputFormat).toBe('structured');
|
||||
expect(result!.instructions).toContain('# Task: Explore Authentication');
|
||||
});
|
||||
|
||||
it('should use defaults for missing fields', () => {
|
||||
const content = `---
|
||||
name: Simple Agent
|
||||
---
|
||||
Do something.`;
|
||||
|
||||
const result = parseTaskSpecFile(content, 'my-fallback');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.spec.agentId).toBe('my-fallback');
|
||||
expect(result!.spec.type).toBe('general');
|
||||
expect(result!.spec.priority).toBe('normal');
|
||||
expect(result!.spec.timeoutMinutes).toBe(30);
|
||||
expect(result!.spec.resultDelivery).toBe('both');
|
||||
expect(result!.spec.outputFormat).toBe('markdown');
|
||||
expect(result!.spec.canModifyParentFiles).toBe(false);
|
||||
});
|
||||
|
||||
it('should truncate long names', () => {
|
||||
const longName = 'A'.repeat(100);
|
||||
const content = `---
|
||||
name: ${longName}
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseTaskSpecFile(content, 'id');
|
||||
expect(result!.spec.name.length).toBe(AGENT_NAME_MAX_LENGTH);
|
||||
});
|
||||
|
||||
it('should validate type values', () => {
|
||||
const content = `---
|
||||
type: invalid_type
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseTaskSpecFile(content, 'id');
|
||||
expect(result!.spec.type).toBe('general'); // Falls back to default
|
||||
});
|
||||
|
||||
it('should validate priority values', () => {
|
||||
const content = `---
|
||||
priority: super_high
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseTaskSpecFile(content, 'id');
|
||||
expect(result!.spec.priority).toBe('normal'); // Falls back to default
|
||||
});
|
||||
|
||||
it('should return null for content without frontmatter', () => {
|
||||
const content = 'No frontmatter at all';
|
||||
expect(parseTaskSpecFile(content, 'id')).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse contextFiles array', () => {
|
||||
const content = `---
|
||||
contextFiles:
|
||||
- src/auth.ts
|
||||
- src/types.ts
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseTaskSpecFile(content, 'id');
|
||||
expect(result!.spec.contextFiles).toEqual(['src/auth.ts', 'src/types.ts']);
|
||||
});
|
||||
|
||||
it('should parse dependsOn array', () => {
|
||||
const content = `---
|
||||
dependsOn:
|
||||
- agent-1
|
||||
- agent-2
|
||||
---
|
||||
body`;
|
||||
|
||||
const result = parseTaskSpecFile(content, 'id');
|
||||
expect(result!.spec.dependsOn).toEqual(['agent-1', 'agent-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Factory Functions', () => {
|
||||
it('createDefaultSpawnTaskSpec should generate valid defaults', () => {
|
||||
const spec = createDefaultSpawnTaskSpec('my-agent');
|
||||
expect(spec.agentId).toBe('my-agent');
|
||||
expect(spec.name).toBe('my-agent');
|
||||
expect(spec.type).toBe('general');
|
||||
expect(spec.priority).toBe('normal');
|
||||
expect(spec.timeoutMinutes).toBe(30);
|
||||
expect(spec.completionPhrase).toContain('MY_AGENT');
|
||||
expect(spec.completionPhrase).toContain('DONE');
|
||||
});
|
||||
|
||||
it('createDefaultSpawnTaskSpec should sanitize agent ID for completion phrase', () => {
|
||||
const spec = createDefaultSpawnTaskSpec('my-agent-123');
|
||||
expect(spec.completionPhrase).toBe('AGENT_MY_AGENT_123_DONE');
|
||||
});
|
||||
|
||||
it('createEmptyAgentProgress should create valid progress', () => {
|
||||
const progress = createEmptyAgentProgress();
|
||||
expect(progress.phase).toBe('initializing');
|
||||
expect(progress.percentComplete).toBe(0);
|
||||
expect(progress.filesModified).toEqual([]);
|
||||
expect(progress.tokensUsed).toBe(0);
|
||||
});
|
||||
|
||||
it('createInitialSpawnTrackerState should create valid state', () => {
|
||||
const state = createInitialSpawnTrackerState();
|
||||
expect(state.enabled).toBe(false);
|
||||
expect(state.activeCount).toBe(0);
|
||||
expect(state.agents).toEqual([]);
|
||||
});
|
||||
|
||||
it('createDefaultOrchestratorConfig should create valid config', () => {
|
||||
const config = createDefaultOrchestratorConfig();
|
||||
expect(config.maxConcurrentAgents).toBe(5);
|
||||
expect(config.maxSpawnDepth).toBe(3);
|
||||
expect(config.defaultTimeoutMinutes).toBe(30);
|
||||
expect(config.maxTimeoutMinutes).toBe(120);
|
||||
expect(config.progressPollIntervalMs).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeSpawnResult', () => {
|
||||
it('should serialize a completed result', () => {
|
||||
const result = {
|
||||
status: 'completed' as const,
|
||||
durationMs: 60000,
|
||||
tokens: { input: 1000, output: 500, total: 1500 },
|
||||
cost: 0.05,
|
||||
summary: 'Task completed successfully',
|
||||
output: '## Result\n\nDetailed output here.',
|
||||
filesChanged: [
|
||||
{ path: 'src/auth.ts', action: 'modified' as const, summary: 'Added validation' },
|
||||
],
|
||||
agentId: 'test-agent',
|
||||
completedAt: 1700000000000,
|
||||
};
|
||||
|
||||
const serialized = serializeSpawnResult(result);
|
||||
expect(serialized).toContain('status: completed');
|
||||
expect(serialized).toContain('summary: "Task completed successfully"');
|
||||
expect(serialized).toContain('agentId: test-agent');
|
||||
expect(serialized).toContain('path: src/auth.ts');
|
||||
expect(serialized).toContain('## Result');
|
||||
});
|
||||
|
||||
it('should handle empty filesChanged', () => {
|
||||
const result = {
|
||||
status: 'failed' as const,
|
||||
error: 'Something went wrong',
|
||||
durationMs: 5000,
|
||||
tokens: { input: 100, output: 50, total: 150 },
|
||||
cost: 0.01,
|
||||
summary: 'Failed',
|
||||
output: 'Error details',
|
||||
filesChanged: [],
|
||||
agentId: 'test',
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
|
||||
const serialized = serializeSpawnResult(result);
|
||||
expect(serialized).toContain('status: failed');
|
||||
expect(serialized).toContain('filesChanged: []');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSpawnResult', () => {
|
||||
it('should parse a result file', () => {
|
||||
const content = `---
|
||||
status: completed
|
||||
summary: "Found 3 auth patterns"
|
||||
cost: 0.25
|
||||
---
|
||||
|
||||
## Analysis
|
||||
|
||||
Detailed findings here.`;
|
||||
|
||||
const result = parseSpawnResult(content, 'agent-001', 60000);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.status).toBe('completed');
|
||||
expect(result!.summary).toBe('Found 3 auth patterns');
|
||||
expect(result!.cost).toBe(0.25);
|
||||
expect(result!.output).toContain('## Analysis');
|
||||
expect(result!.agentId).toBe('agent-001');
|
||||
});
|
||||
|
||||
it('should handle missing fields with defaults', () => {
|
||||
const content = `---
|
||||
status: completed
|
||||
---
|
||||
output`;
|
||||
|
||||
const result = parseSpawnResult(content, 'agent', 30000);
|
||||
expect(result!.durationMs).toBe(30000);
|
||||
expect(result!.cost).toBe(0);
|
||||
expect(result!.summary).toBe('No summary provided');
|
||||
expect(result!.filesChanged).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return null for invalid content', () => {
|
||||
expect(parseSpawnResult('no frontmatter', 'id', 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user