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>
526 lines
14 KiB
TypeScript
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();
|
|
}
|
|
}
|