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