Skip to content

Commit 7c444ed

Browse files
author
StackMemory Bot (CLI)
committed
feat(claude-code): wire real CLI invocation in task-coordinator
Replace simulateClaudeCodeExecution mock with spawnClaudeCode that invokes `claude --print` as a subprocess. Maps oracle agents to opus model, scopes tool access by capabilities. Update tests to mock child_process.spawn with 2 new assertion tests for spawn args.
1 parent caa8cfa commit 7c444ed

4 files changed

Lines changed: 326 additions & 192 deletions

File tree

src/integrations/claude-code/__tests__/task-coordinator.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@
55
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
66
import { ClaudeCodeTaskCoordinator } from '../task-coordinator.js';
77
import { ClaudeCodeAgent } from '../agent-bridge.js';
8+
import { EventEmitter } from 'events';
9+
10+
// Mock child_process.spawn to avoid invoking real claude CLI
11+
vi.mock('child_process', () => ({
12+
spawn: vi.fn(() => {
13+
const proc = new EventEmitter() as any;
14+
15+
// Create mock readable streams
16+
proc.stdout = new EventEmitter();
17+
proc.stderr = new EventEmitter();
18+
proc.stdin = { write: vi.fn(), end: vi.fn() };
19+
20+
// Simulate successful completion after a short delay
21+
setTimeout(() => {
22+
proc.stdout.emit('data', Buffer.from('Mock agent response'));
23+
proc.emit('close', 0);
24+
}, 50);
25+
26+
return proc;
27+
}),
28+
}));
829

930
describe('ClaudeCodeTaskCoordinator', () => {
1031
let coordinator: ClaudeCodeTaskCoordinator;
@@ -57,8 +78,7 @@ describe('ClaudeCodeTaskCoordinator', () => {
5778
{ maxRetries: 0, timeout: 10000 }
5879
);
5980

60-
expect(result).toBeDefined();
61-
expect(typeof result).toBe('string');
81+
expect(result).toBe('Mock agent response');
6282
}, 15000);
6383

6484
it('should track task in metrics', async () => {
@@ -102,6 +122,45 @@ describe('ClaudeCodeTaskCoordinator', () => {
102122

103123
expect(metrics.totalCost).toBeGreaterThanOrEqual(0);
104124
}, 15000);
125+
126+
it('should pass --model opus for oracle agents', async () => {
127+
const { spawn } = await import('child_process');
128+
129+
await coordinator.executeTask(
130+
'test-oracle',
131+
mockOracleAgent,
132+
'Strategic task',
133+
{ maxRetries: 0, timeout: 10000 }
134+
);
135+
136+
expect(spawn).toHaveBeenCalledWith(
137+
'claude',
138+
expect.arrayContaining(['--model', 'opus']),
139+
expect.any(Object)
140+
);
141+
}, 15000);
142+
143+
it('should pass code tools for code_implementation capability', async () => {
144+
const { spawn } = await import('child_process');
145+
const codeAgent: ClaudeCodeAgent = {
146+
...mockWorkerAgent,
147+
capabilities: ['code_implementation'],
148+
};
149+
150+
await coordinator.executeTask('code-worker', codeAgent, 'Write code', {
151+
maxRetries: 0,
152+
timeout: 10000,
153+
});
154+
155+
expect(spawn).toHaveBeenCalledWith(
156+
'claude',
157+
expect.arrayContaining([
158+
'--allowedTools',
159+
'Edit,Write,Bash,Read,Glob,Grep',
160+
]),
161+
expect.any(Object)
162+
);
163+
}, 15000);
105164
});
106165

107166
describe('getCoordinationMetrics', () => {

src/integrations/claude-code/subagent-client.ts

Lines changed: 120 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77

88
import { logger } from '../../core/monitoring/logger.js';
99
import { STRUCTURED_RESPONSE_SUFFIX } from '../../orchestrators/multimodal/constants.js';
10-
import { exec } from 'child_process';
11-
import { promisify } from 'util';
10+
import { spawn } from 'child_process';
1211
import * as fs from 'fs';
1312
import * as path from 'path';
1413
import * as os from 'os';
@@ -26,8 +25,6 @@ import {
2625
import { AnthropicBatchClient } from '../anthropic/batch-client.js';
2726
import type { BatchRequest } from '../anthropic/batch-client.js';
2827

29-
const execAsync = promisify(exec);
30-
3128
export interface SubagentRequest {
3229
type:
3330
| 'planning'
@@ -64,8 +61,7 @@ export class ClaudeCodeSubagentClient {
6461
private activeSubagents: Map<string, AbortController> = new Map();
6562
private mockMode: boolean;
6663

67-
constructor(mockMode: boolean = true) {
68-
// Default to mock mode for testing
64+
constructor(mockMode: boolean = false) {
6965
this.mockMode = mockMode;
7066

7167
// Create temp directory for subagent communication
@@ -236,7 +232,8 @@ export class ClaudeCodeSubagentClient {
236232
}
237233

238234
/**
239-
* Original CLI-based subagent execution (unchanged behavior)
235+
* Execute subagent via Claude Code CLI (`claude -p --output-format stream-json`).
236+
* Spawns a real Claude Code process with full tool use.
240237
*/
241238
private async executeSubagentViaCLI(
242239
request: SubagentRequest,
@@ -250,34 +247,26 @@ export class ClaudeCodeSubagentClient {
250247
contextFile,
251248
JSON.stringify(request.context, null, 2)
252249
);
253-
const resultFile = path.join(this.tempDir, `${subagentId}-result.json`);
254-
const taskCommand = this.buildTaskCommand(
255-
request,
256-
prompt,
257-
contextFile,
258-
resultFile
259-
);
260-
const result = await this.executeTaskTool(taskCommand, request.timeout);
261250

262-
let subagentResult: any = {};
263-
if (fs.existsSync(resultFile)) {
264-
const resultContent = await fs.promises.readFile(resultFile, 'utf-8');
265-
try {
266-
subagentResult = JSON.parse(resultContent);
267-
} catch {
268-
subagentResult = { rawOutput: resultContent };
269-
}
270-
}
251+
const fullPrompt = `${prompt}\n\nContext (JSON): ${JSON.stringify(request.context)}`;
252+
const result = await this.spawnClaude(fullPrompt, request.timeout);
271253

272254
this.cleanup(subagentId);
273255

256+
let parsed: any;
257+
try {
258+
parsed = JSON.parse(result.text);
259+
} catch {
260+
parsed = { rawOutput: result.text };
261+
}
262+
274263
return {
275264
success: true,
276-
result: subagentResult,
277-
output: result.stdout,
265+
result: parsed,
266+
output: result.text,
278267
duration: Date.now() - startTime,
279268
subagentType: request.type,
280-
tokens: this.estimateTokens(prompt + JSON.stringify(subagentResult)),
269+
tokens: this.estimateTokens(fullPrompt + result.text),
281270
};
282271
} catch (error: any) {
283272
logger.error(`Subagent CLI execution failed: ${request.type}`, {
@@ -461,81 +450,115 @@ export class ClaudeCodeSubagentClient {
461450
}
462451

463452
/**
464-
* Build Task tool command
465-
* This creates a command that Claude Code's Task tool can execute
453+
* Spawn `claude -p --output-format stream-json` and collect the result.
454+
* Parses stream-json events to extract the final assistant text.
466455
*/
467-
private buildTaskCommand(
468-
request: SubagentRequest,
456+
private spawnClaude(
469457
prompt: string,
470-
contextFile: string,
471-
resultFile: string
472-
): string {
473-
// Create a script that the subagent will execute
474-
const scriptContent = `
475-
#!/bin/bash
476-
# Subagent execution script for ${request.type}
477-
478-
# Read context
479-
CONTEXT=$(cat "${contextFile}")
480-
481-
# Execute task based on type
482-
case "${request.type}" in
483-
"testing")
484-
# For testing subagent, actually run tests
485-
echo "Generating and running tests..."
486-
# The subagent will generate test files and run them
487-
;;
488-
"linting")
489-
# For linting subagent, run actual linters
490-
echo "Running linters..."
491-
npm run lint || true
492-
;;
493-
"code")
494-
# For code generation, create implementation files
495-
echo "Generating implementation..."
496-
;;
497-
*)
498-
# Default behavior
499-
echo "Executing ${request.type} task..."
500-
;;
501-
esac
502-
503-
# Write result
504-
echo '{"status": "completed", "type": "${request.type}"}' > "${resultFile}"
505-
`;
506-
507-
const scriptFile = path.join(this.tempDir, `${request.type}-script.sh`);
508-
fs.writeFileSync(scriptFile, scriptContent);
509-
fs.chmodSync(scriptFile, '755');
510-
511-
// Return the command that Task tool will execute
512-
// In practice, this would trigger Claude Code's Task tool
513-
return scriptFile;
514-
}
515-
516-
/**
517-
* Execute via Task tool (simulated for now)
518-
* In production, this would use Claude Code's actual Task tool API
519-
*/
520-
private async executeTaskTool(
521-
command: string,
522458
timeout?: number
523-
): Promise<{ stdout: string; stderr: string }> {
524-
try {
525-
// In production, this would call Claude Code's Task tool
526-
// For now, we simulate with a subprocess
527-
const result = await execAsync(command, {
528-
timeout: timeout || 300000, // 5 minutes default
529-
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
459+
): Promise<{ text: string; toolUseCount: number }> {
460+
return new Promise((resolve, reject) => {
461+
const args = [
462+
'-p',
463+
'--output-format',
464+
'stream-json',
465+
'--dangerously-skip-permissions',
466+
prompt,
467+
];
468+
469+
const claude = spawn('claude', args, {
470+
cwd: process.cwd(),
471+
env: { ...process.env },
472+
stdio: ['pipe', 'pipe', 'pipe'],
530473
});
531474

532-
return result;
533-
} catch (error: any) {
534-
if (error.killed || error.signal === 'SIGTERM') {
535-
throw new Error(`Subagent timeout after ${timeout}ms`);
536-
}
537-
throw error;
538-
}
475+
const timeoutMs = timeout || 300000; // 5 minutes default
476+
const timer = setTimeout(() => {
477+
claude.kill('SIGTERM');
478+
reject(new Error(`Subagent timeout after ${timeoutMs}ms`));
479+
}, timeoutMs);
480+
481+
let lastAssistantText = '';
482+
let toolUseCount = 0;
483+
let lineBuffer = '';
484+
let stderr = '';
485+
486+
claude.stdout.on('data', (chunk: Buffer) => {
487+
lineBuffer += chunk.toString();
488+
const lines = lineBuffer.split('\n');
489+
lineBuffer = lines.pop() || '';
490+
491+
for (const line of lines) {
492+
if (!line.trim()) continue;
493+
try {
494+
const event = JSON.parse(line);
495+
496+
if (event.type === 'assistant' && event.message) {
497+
const textBlocks = (event.message.content || [])
498+
.filter((b: any) => b.type === 'text')
499+
.map((b: any) => b.text);
500+
if (textBlocks.length > 0) {
501+
lastAssistantText = textBlocks.join('\n');
502+
}
503+
const toolBlocks = (event.message.content || []).filter(
504+
(b: any) => b.type === 'tool_use'
505+
);
506+
toolUseCount += toolBlocks.length;
507+
}
508+
509+
if (event.type === 'result' && event.result) {
510+
lastAssistantText = event.result;
511+
}
512+
} catch {
513+
// non-JSON line, ignore
514+
}
515+
}
516+
});
517+
518+
claude.stderr.on('data', (data: Buffer) => {
519+
stderr += data.toString();
520+
});
521+
522+
claude.on('close', (code: number | null) => {
523+
clearTimeout(timer);
524+
525+
// Process remaining buffer
526+
if (lineBuffer.trim()) {
527+
try {
528+
const event = JSON.parse(lineBuffer);
529+
if (event.type === 'result' && event.result) {
530+
lastAssistantText = event.result;
531+
}
532+
} catch {
533+
// ignore
534+
}
535+
}
536+
537+
logger.info('Claude subagent completed', {
538+
code,
539+
toolUseCount,
540+
outputLength: lastAssistantText.length,
541+
});
542+
543+
if (code === 0 && lastAssistantText) {
544+
resolve({ text: lastAssistantText, toolUseCount });
545+
} else if (code === 0) {
546+
resolve({
547+
text: '(Claude completed but produced no text output)',
548+
toolUseCount,
549+
});
550+
} else {
551+
reject(
552+
new Error(`Claude exited code ${code}: ${stderr.slice(0, 500)}`)
553+
);
554+
}
555+
});
556+
557+
claude.on('error', (err: Error) => {
558+
clearTimeout(timer);
559+
reject(new Error(`Failed to spawn claude: ${err.message}`));
560+
});
561+
});
539562
}
540563

541564
/**

0 commit comments

Comments
 (0)