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:
Lukas May
2026-01-31 08:41:49 +01:00
parent d0e9acf512
commit 6148af784e

391
src/agent/mock-manager.ts Normal file
View 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();
}
}