mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
396 lines
11 KiB
TypeScript
396 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|