Files
Codewalkers/apps/server/agent/mock-manager.ts
Lukas May b2f4004191 feat: Persist agent prompt in DB so getAgentPrompt survives log cleanup
The `getAgentPrompt` tRPC procedure previously read exclusively from
`.cw/agent-logs/<name>/PROMPT.md`. Once the cleanup-manager removes
that directory, the prompt is gone forever.

Adds a `prompt` text column to the `agents` table and writes the fully
assembled prompt (including workspace layout, inter-agent comms, and
preview sections) to the DB in the same `repository.update()` call
that saves pid/outputFilePath after spawn.

`getAgentPrompt` now reads from DB first (`agent.prompt`) and falls
back to the filesystem only for agents spawned before this change.

Addresses review comment [MMcmVlEK16bBfkJuXvG6h].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:13:01 +01:00

526 lines
14 KiB
TypeScript

/**
* 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,
AgentMode,
SpawnAgentOptions,
AgentResult,
AgentStatus,
PendingQuestions,
QuestionItem,
} from './types.js';
import type {
EventBus,
AgentSpawnedEvent,
AgentStoppedEvent,
AgentCrashedEvent,
AgentResumedEvent,
AgentDeletedEvent,
AgentWaitingEvent,
} from '../events/index.js';
/**
* Scenario configuration for mock agent behavior.
* Matches the simplified agent signal schema: done, questions, or error.
* Mode-specific stopped reasons are derived from the agent's mode.
*/
export type MockAgentScenario =
| {
status: 'done';
result?: string;
filesModified?: string[];
delay?: number;
}
| {
status: 'questions';
questions: QuestionItem[];
delay?: number;
}
| {
status: 'error';
error: string;
delay?: number;
};
/**
* Internal agent record with scenario and timer tracking.
*/
interface MockAgentRecord {
info: AgentInfo;
scenario: MockAgentScenario;
result?: AgentResult;
pendingQuestions?: PendingQuestions;
completionTimer?: ReturnType<typeof setTimeout>;
}
/**
* Default scenario: immediate success with generic message.
*/
const DEFAULT_SCENARIO: MockAgentScenario = {
status: 'done',
result: 'Task completed successfully',
filesModified: [],
delay: 0,
};
/**
* 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 { taskId, prompt } = options;
const name = options.name ?? `agent-${taskId?.slice(0, 6) ?? 'noTask'}`;
// 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 — use original name or generated)
const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario;
const info: AgentInfo = {
id: agentId,
name: name ?? `mock-${agentId.slice(0, 6)}`,
taskId: taskId ?? null,
initiativeId: options.initiativeId ?? null,
sessionId,
worktreeId,
status: 'running',
mode: options.mode ?? 'execute',
provider: options.provider ?? 'claude',
accountId: null,
createdAt: now,
updatedAt: now,
exitCode: null,
prompt: null,
};
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: taskId ?? null,
worktreeId,
provider: options.provider ?? 'claude',
},
};
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;
}
}
/**
* Map agent mode to stopped event reason.
*/
private getStoppedReason(mode: AgentMode): AgentStoppedEvent['payload']['reason'] {
switch (mode) {
case 'discuss': return 'context_complete';
case 'plan': return 'plan_complete';
case 'detail': return 'detail_complete';
case 'refine': return 'refine_complete';
default: return 'task_complete';
}
}
/**
* Complete agent based on scenario status.
*/
private completeAgent(agentId: string, scenario: MockAgentScenario): void {
const record = this.agents.get(agentId);
if (!record) return;
const { info } = record;
switch (scenario.status) {
case 'done':
record.result = {
success: true,
message: scenario.result ?? 'Task completed successfully',
filesModified: scenario.filesModified,
};
record.info.status = 'idle';
record.info.updatedAt = new Date();
if (this.eventBus) {
const reason = this.getStoppedReason(info.mode);
const event: AgentStoppedEvent = {
type: 'agent:stopped',
timestamp: new Date(),
payload: {
agentId,
name: info.name,
taskId: info.taskId,
reason,
},
};
this.eventBus.emit(event);
}
break;
case 'error':
record.result = {
success: false,
message: scenario.error,
};
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.error,
},
};
this.eventBus.emit(event);
}
break;
case 'questions':
record.info.status = 'waiting_for_input';
record.info.updatedAt = new Date();
record.pendingQuestions = {
questions: scenario.questions,
};
if (this.eventBus) {
const event: AgentWaitingEvent = {
type: 'agent:waiting',
timestamp: new Date(),
payload: {
agentId,
name: info.name,
taskId: info.taskId,
sessionId: info.sessionId ?? '',
questions: scenario.questions,
},
};
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);
}
}
/**
* Delete an agent and clean up.
* Removes from internal map and emits agent:deleted event.
*/
async delete(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;
}
const name = record.info.name;
this.agents.delete(agentId);
if (this.eventBus) {
const event: AgentDeletedEvent = {
type: 'agent:deleted',
timestamp: new Date(),
payload: {
agentId,
name,
},
};
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.
*
* @param agentId - Agent to resume
* @param answers - Map of question ID to user's answer
*/
async resume(agentId: string, answers: Record<string, 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, clear pending questions
record.info.status = 'running';
record.info.updatedAt = new Date();
record.pendingQuestions = undefined;
// 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
// Extract filesModified from original scenario if it was a 'done' type
const originalFilesModified =
record.scenario.status === 'done' ? record.scenario.filesModified : undefined;
const resumeScenario: MockAgentScenario = {
status: 'done',
delay: record.scenario.delay ?? 0,
result: 'Resumed and completed successfully',
filesModified: originalFilesModified,
};
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;
}
/**
* Get pending questions for an agent waiting for input.
*/
async getPendingQuestions(agentId: string): Promise<PendingQuestions | null> {
const record = this.agents.get(agentId);
return record?.pendingQuestions ?? null;
}
/**
* Dismiss an agent.
* Mock implementation just marks the agent as dismissed.
*/
async dismiss(agentId: string): Promise<void> {
const record = this.agents.get(agentId);
if (!record) {
throw new Error(`Agent '${agentId}' not found`);
}
const now = new Date();
record.info.userDismissedAt = now;
record.info.updatedAt = now;
}
/**
* Resume an idle agent to answer an inter-agent conversation.
* Mock implementation: marks agent as running and schedules immediate completion.
*/
async resumeForConversation(
agentId: string,
conversationId: string,
question: string,
fromAgentId: string,
): Promise<boolean> {
const record = this.agents.get(agentId);
if (!record || record.info.status !== 'idle' || !record.info.sessionId) {
return false;
}
record.info.status = 'running';
record.info.updatedAt = new Date();
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);
}
this.scheduleCompletion(agentId, { status: 'done', delay: 0, result: 'Answered conversation' });
return true;
}
/**
* 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();
}
}