mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat: replace SpawnDetector with MCP server for spawn1337 protocol
Instead of parsing terminal output for <spawn1337> tags, spawn capabilities are now exposed as native MCP tools that Claude Code can call directly. The MCP server (stdio transport) proxies requests to the existing REST API. - Add src/mcp-server.ts with 6 tools: spawn_agent, list_agents, get_agent_status, get_agent_result, send_agent_message, cancel_agent - Remove src/spawn-detector.ts and all references in session.ts/server.ts - Add CLAUDEMAN_API_URL env var propagation to sessions and screens - Write .mcp.json to case directories during creation - Remove spawn1337 tag documentation from case-template.md - Add claudeman-mcp bin entry to package.json Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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,249 +0,0 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user