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:
arkon
2026-01-23 10:20:37 +01:00
co-authored by Claude Opus 4.5
parent 33a8febf30
commit 1705b7a67d
11 changed files with 3591 additions and 2 deletions
+249
View File
@@ -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));
});
});
});
+496
View File
@@ -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();
});
});
});
+395
View File
@@ -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();
});
});
});