mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: implement spawn1337 autonomous agent protocol
Add full lifecycle management for spawning autonomous Claude sessions as screen-based agents. Agents communicate via filesystem message bus, signal completion via RalphTracker <promise> mechanism, and enforce resource budgets (tokens, cost, timeout, depth limits). New files: - spawn-types.ts: Types, YAML parser, factory functions, serialization - spawn-detector.ts: Terminal pattern detection for spawn1337 tags - spawn-orchestrator.ts: Agent lifecycle (spawn, monitor, queue, cleanup) - spawn-claude-md.ts: CLAUDE.md generator for agent sessions Modified: - session.ts: SpawnDetector integration, parent/child tracking - server.ts: Orchestrator wiring, 11 API endpoints, SSE events - types.ts: Re-exports, SessionState additions Tests: 80 new tests across 3 test files (all passing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { SpawnDetector } from '../src/spawn-detector.js';
|
||||
|
||||
describe('SpawnDetector', () => {
|
||||
let detector: SpawnDetector;
|
||||
|
||||
beforeEach(() => {
|
||||
detector = new SpawnDetector();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should start disabled', () => {
|
||||
expect(detector.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should start with initial state', () => {
|
||||
const state = detector.state;
|
||||
expect(state.enabled).toBe(false);
|
||||
expect(state.activeCount).toBe(0);
|
||||
expect(state.totalSpawned).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-Enable', () => {
|
||||
it('should auto-enable on spawn tag detection', () => {
|
||||
detector.processTerminalData('<spawn1337>task.md</spawn1337>\n');
|
||||
expect(detector.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should not enable on unrelated data', () => {
|
||||
detector.processTerminalData('Hello world\nsome output\n');
|
||||
expect(detector.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should auto-enable on status tag', () => {
|
||||
detector.processTerminalData('<spawn1337-status agentId="test-001"/>\n');
|
||||
expect(detector.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should auto-enable on cancel tag', () => {
|
||||
detector.processTerminalData('<spawn1337-cancel agentId="test-001"/>\n');
|
||||
expect(detector.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spawn Request Detection', () => {
|
||||
it('should detect spawn tag and emit event', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337>tasks/auth-explore.md</spawn1337>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('tasks/auth-explore.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should detect spawn tag with path containing slashes', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337>src/tasks/deep/nested.md</spawn1337>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('src/tasks/deep/nested.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should handle spawn tag with surrounding text', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('Starting agent: <spawn1337>task.md</spawn1337> done\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should increment totalSpawned count', () => {
|
||||
detector.processTerminalData('<spawn1337>task1.md</spawn1337>\n');
|
||||
detector.processTerminalData('<spawn1337>task2.md</spawn1337>\n');
|
||||
detector.flushPendingEvents();
|
||||
|
||||
expect(detector.state.totalSpawned).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle multiple spawn tags in one chunk', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData(
|
||||
'<spawn1337>task1.md</spawn1337>\n<spawn1337>task2.md</spawn1337>\n'
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should strip ANSI codes before parsing', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('\x1b[32m<spawn1337>task.md</spawn1337>\x1b[0m\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should trim whitespace from file path', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337> task.md </spawn1337>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Request Detection', () => {
|
||||
it('should detect status query and emit event', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('statusRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337-status agentId="auth-001"/>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('auth-001');
|
||||
});
|
||||
|
||||
it('should handle status with complex agent ID', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('statusRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337-status agentId="my-complex-agent-123"/>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('my-complex-agent-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cancel Request Detection', () => {
|
||||
it('should detect cancel request and emit event', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('cancelRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337-cancel agentId="test-agent"/>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('test-agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Detection', () => {
|
||||
it('should detect single-line message', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('messageToChild', handler);
|
||||
|
||||
detector.processTerminalData(
|
||||
'<spawn1337-message agentId="agent-001">Focus on the JWT flow</spawn1337-message>\n'
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('agent-001', 'Focus on the JWT flow');
|
||||
});
|
||||
|
||||
it('should detect multi-line message via checkMultiLinePatterns', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('messageToChild', handler);
|
||||
|
||||
const multiline = '<spawn1337-message agentId="agent-001">Line 1\nLine 2\nLine 3</spawn1337-message>';
|
||||
detector.processTerminalData(multiline + '\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('agent-001', 'Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enable/Disable', () => {
|
||||
it('should emit stateUpdate when enabled', async () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('stateUpdate', handler);
|
||||
|
||||
detector.enable();
|
||||
detector.flushPendingEvents();
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(handler.mock.calls[0][0].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should emit stateUpdate when disabled', () => {
|
||||
detector.enable();
|
||||
detector.flushPendingEvents();
|
||||
|
||||
const handler = vi.fn();
|
||||
detector.on('stateUpdate', handler);
|
||||
|
||||
detector.disable();
|
||||
detector.flushPendingEvents();
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(handler.mock.calls[0][0].enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset', () => {
|
||||
it('should reset all state', () => {
|
||||
detector.processTerminalData('<spawn1337>task.md</spawn1337>\n');
|
||||
detector.flushPendingEvents();
|
||||
expect(detector.enabled).toBe(true);
|
||||
|
||||
detector.reset();
|
||||
|
||||
expect(detector.enabled).toBe(false);
|
||||
expect(detector.state.totalSpawned).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Update', () => {
|
||||
it('should allow external state updates', () => {
|
||||
detector.updateState({ activeCount: 3, queuedCount: 2 });
|
||||
detector.flushPendingEvents();
|
||||
|
||||
expect(detector.state.activeCount).toBe(3);
|
||||
expect(detector.state.queuedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Line Buffer', () => {
|
||||
it('should handle data split across chunks', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
// Send in two chunks (split in the middle of the tag)
|
||||
detector.processTerminalData('<spawn13');
|
||||
detector.processTerminalData('37>task.md</spawn1337>\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
|
||||
it('should handle partial lines without emitting', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
// No newline - data stays in buffer
|
||||
detector.processTerminalData('<spawn1337>task.md</spawn1337>');
|
||||
|
||||
// Should not emit until newline
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit on subsequent newline', () => {
|
||||
const handler = vi.fn();
|
||||
detector.on('spawnRequested', handler);
|
||||
|
||||
detector.processTerminalData('<spawn1337>task.md</spawn1337>');
|
||||
detector.processTerminalData('\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('task.md', expect.any(String));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,496 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
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