feat(07-01): implement MockAgentManager adapter
- MockAgentScenario interface with outcome, delay, message, filesModified, question - Constructor takes optional eventBus and defaultScenario - setScenario() for per-agent scenario overrides - spawn() creates agent, schedules async completion based on scenario - Emits all lifecycle events: agent:spawned, agent:stopped, agent:crashed, agent:waiting, agent:resumed - stop() cancels pending completion, marks agent stopped - resume() re-runs scenario for waiting_for_input agents - getResult() returns stored result after completion - clear() for test cleanup
This commit is contained in:
391
src/agent/mock-manager.ts
Normal file
391
src/agent/mock-manager.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Mock Agent Manager Adapter
|
||||
*
|
||||
* Implementation of AgentManager port for test scenarios.
|
||||
* Simulates configurable agent behaviors (success, crash, waiting_for_input)
|
||||
* without spawning real Claude agents.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
AgentManager,
|
||||
AgentInfo,
|
||||
SpawnAgentOptions,
|
||||
AgentResult,
|
||||
AgentStatus,
|
||||
} from './types.js';
|
||||
import type {
|
||||
EventBus,
|
||||
AgentSpawnedEvent,
|
||||
AgentStoppedEvent,
|
||||
AgentCrashedEvent,
|
||||
AgentResumedEvent,
|
||||
AgentWaitingEvent,
|
||||
} from '../events/index.js';
|
||||
|
||||
/**
|
||||
* Scenario configuration for mock agent behavior.
|
||||
*/
|
||||
export interface MockAgentScenario {
|
||||
/** How agent completes: 'success' | 'crash' | 'waiting_for_input' */
|
||||
outcome: 'success' | 'crash' | 'waiting_for_input';
|
||||
/** Delay before completion (ms). Default 0 for synchronous tests. */
|
||||
delay?: number;
|
||||
/** Result message for success/crash */
|
||||
message?: string;
|
||||
/** Files modified (for success) */
|
||||
filesModified?: string[];
|
||||
/** Question to surface (for waiting_for_input) */
|
||||
question?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal agent record with scenario and timer tracking.
|
||||
*/
|
||||
interface MockAgentRecord {
|
||||
info: AgentInfo;
|
||||
scenario: MockAgentScenario;
|
||||
result?: AgentResult;
|
||||
completionTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default scenario: immediate success with generic message.
|
||||
*/
|
||||
const DEFAULT_SCENARIO: MockAgentScenario = {
|
||||
outcome: 'success',
|
||||
delay: 0,
|
||||
message: 'Task completed successfully',
|
||||
filesModified: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* MockAgentManager - Adapter implementing AgentManager port for testing.
|
||||
*
|
||||
* Enables E2E testing of dispatch/coordination flows without spawning
|
||||
* real Claude agents. Simulates configurable agent behaviors and
|
||||
* emits proper lifecycle events.
|
||||
*/
|
||||
export class MockAgentManager implements AgentManager {
|
||||
private agents: Map<string, MockAgentRecord> = new Map();
|
||||
private scenarioOverrides: Map<string, MockAgentScenario> = new Map();
|
||||
private defaultScenario: MockAgentScenario;
|
||||
private eventBus?: EventBus;
|
||||
|
||||
constructor(options?: { eventBus?: EventBus; defaultScenario?: MockAgentScenario }) {
|
||||
this.eventBus = options?.eventBus;
|
||||
this.defaultScenario = options?.defaultScenario ?? DEFAULT_SCENARIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set scenario override for a specific agent name.
|
||||
* When spawn() is called with this name, the override takes precedence.
|
||||
*/
|
||||
setScenario(agentName: string, scenario: MockAgentScenario): void {
|
||||
this.scenarioOverrides.set(agentName, scenario);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear scenario override for a specific agent name.
|
||||
*/
|
||||
clearScenario(agentName: string): void {
|
||||
this.scenarioOverrides.delete(agentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a new mock agent.
|
||||
*
|
||||
* Creates agent record in internal Map, schedules completion based on scenario.
|
||||
* Completion happens async via setTimeout (even if delay=0).
|
||||
*/
|
||||
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
|
||||
const { name, taskId, prompt } = options;
|
||||
|
||||
// Check name uniqueness
|
||||
for (const record of this.agents.values()) {
|
||||
if (record.info.name === name) {
|
||||
throw new Error(`Agent with name '${name}' already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const agentId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
const worktreeId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
// Determine scenario (override takes precedence)
|
||||
const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario;
|
||||
|
||||
const info: AgentInfo = {
|
||||
id: agentId,
|
||||
name,
|
||||
taskId,
|
||||
sessionId,
|
||||
worktreeId,
|
||||
status: 'running',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const record: MockAgentRecord = {
|
||||
info,
|
||||
scenario,
|
||||
};
|
||||
|
||||
this.agents.set(agentId, record);
|
||||
|
||||
// Emit spawned event
|
||||
if (this.eventBus) {
|
||||
const event: AgentSpawnedEvent = {
|
||||
type: 'agent:spawned',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name,
|
||||
taskId,
|
||||
worktreeId,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
|
||||
// Schedule completion async (even with delay=0, uses setTimeout for async behavior)
|
||||
this.scheduleCompletion(agentId, scenario);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule agent completion based on scenario.
|
||||
*/
|
||||
private scheduleCompletion(agentId: string, scenario: MockAgentScenario): void {
|
||||
const delay = scenario.delay ?? 0;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.completeAgent(agentId, scenario);
|
||||
}, delay);
|
||||
|
||||
const record = this.agents.get(agentId);
|
||||
if (record) {
|
||||
record.completionTimer = timer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete agent based on scenario outcome.
|
||||
*/
|
||||
private completeAgent(agentId: string, scenario: MockAgentScenario): void {
|
||||
const record = this.agents.get(agentId);
|
||||
if (!record) return;
|
||||
|
||||
const { info } = record;
|
||||
|
||||
switch (scenario.outcome) {
|
||||
case 'success':
|
||||
record.result = {
|
||||
success: true,
|
||||
message: scenario.message ?? 'Task completed successfully',
|
||||
filesModified: scenario.filesModified,
|
||||
};
|
||||
record.info.status = 'idle';
|
||||
record.info.updatedAt = new Date();
|
||||
|
||||
if (this.eventBus) {
|
||||
const event: AgentStoppedEvent = {
|
||||
type: 'agent:stopped',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name: info.name,
|
||||
taskId: info.taskId,
|
||||
reason: 'task_complete',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'crash':
|
||||
record.result = {
|
||||
success: false,
|
||||
message: scenario.message ?? 'Agent crashed',
|
||||
};
|
||||
record.info.status = 'crashed';
|
||||
record.info.updatedAt = new Date();
|
||||
|
||||
if (this.eventBus) {
|
||||
const event: AgentCrashedEvent = {
|
||||
type: 'agent:crashed',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name: info.name,
|
||||
taskId: info.taskId,
|
||||
error: scenario.message ?? 'Agent crashed',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'waiting_for_input':
|
||||
record.info.status = 'waiting_for_input';
|
||||
record.info.updatedAt = new Date();
|
||||
|
||||
if (this.eventBus) {
|
||||
const event: AgentWaitingEvent = {
|
||||
type: 'agent:waiting',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name: info.name,
|
||||
taskId: info.taskId,
|
||||
sessionId: info.sessionId ?? '',
|
||||
question: scenario.question ?? 'User input required',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running agent.
|
||||
*
|
||||
* Cancels scheduled completion, marks agent stopped, emits agent:stopped event.
|
||||
*/
|
||||
async stop(agentId: string): Promise<void> {
|
||||
const record = this.agents.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent '${agentId}' not found`);
|
||||
}
|
||||
|
||||
// Cancel any pending completion
|
||||
if (record.completionTimer) {
|
||||
clearTimeout(record.completionTimer);
|
||||
record.completionTimer = undefined;
|
||||
}
|
||||
|
||||
record.info.status = 'stopped';
|
||||
record.info.updatedAt = new Date();
|
||||
|
||||
if (this.eventBus) {
|
||||
const event: AgentStoppedEvent = {
|
||||
type: 'agent:stopped',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name: record.info.name,
|
||||
taskId: record.info.taskId,
|
||||
reason: 'user_requested',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agents with their current status.
|
||||
*/
|
||||
async list(): Promise<AgentInfo[]> {
|
||||
return Array.from(this.agents.values()).map((record) => record.info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific agent by ID.
|
||||
*/
|
||||
async get(agentId: string): Promise<AgentInfo | null> {
|
||||
const record = this.agents.get(agentId);
|
||||
return record ? record.info : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific agent by name.
|
||||
*/
|
||||
async getByName(name: string): Promise<AgentInfo | null> {
|
||||
for (const record of this.agents.values()) {
|
||||
if (record.info.name === name) {
|
||||
return record.info;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an agent that's waiting for input.
|
||||
*
|
||||
* Re-runs the scenario for the resumed agent. Emits agent:resumed event.
|
||||
* Agent must be in 'waiting_for_input' status.
|
||||
*/
|
||||
async resume(agentId: string, prompt: string): Promise<void> {
|
||||
const record = this.agents.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent '${agentId}' not found`);
|
||||
}
|
||||
|
||||
if (record.info.status !== 'waiting_for_input') {
|
||||
throw new Error(
|
||||
`Agent '${record.info.name}' is not waiting for input (status: ${record.info.status})`
|
||||
);
|
||||
}
|
||||
|
||||
if (!record.info.sessionId) {
|
||||
throw new Error(`Agent '${record.info.name}' has no session to resume`);
|
||||
}
|
||||
|
||||
// Update status to running
|
||||
record.info.status = 'running';
|
||||
record.info.updatedAt = new Date();
|
||||
|
||||
// Emit resumed event
|
||||
if (this.eventBus) {
|
||||
const event: AgentResumedEvent = {
|
||||
type: 'agent:resumed',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name: record.info.name,
|
||||
taskId: record.info.taskId,
|
||||
sessionId: record.info.sessionId,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
|
||||
// Re-run scenario (after resume, typically completes successfully)
|
||||
// For testing, we use a new scenario that defaults to success
|
||||
const resumeScenario: MockAgentScenario = {
|
||||
outcome: 'success',
|
||||
delay: record.scenario.delay ?? 0,
|
||||
message: 'Resumed and completed successfully',
|
||||
filesModified: record.scenario.filesModified,
|
||||
};
|
||||
|
||||
this.scheduleCompletion(agentId, resumeScenario);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the result of an agent's work.
|
||||
*
|
||||
* Only available after agent completes or crashes.
|
||||
*/
|
||||
async getResult(agentId: string): Promise<AgentResult | null> {
|
||||
const record = this.agents.get(agentId);
|
||||
return record?.result ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all agents and pending timers.
|
||||
* Useful for test cleanup.
|
||||
*/
|
||||
clear(): void {
|
||||
for (const record of this.agents.values()) {
|
||||
if (record.completionTimer) {
|
||||
clearTimeout(record.completionTimer);
|
||||
}
|
||||
}
|
||||
this.agents.clear();
|
||||
this.scenarioOverrides.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user