Integrates main branch changes (headquarters dashboard, task retry count, agent prompt persistence, remote sync improvements) with the initiative's errand agent feature. Both features coexist in the merged result. Key resolutions: - Schema: take main's errands table (nullable projectId, no conflictFiles, with errandsRelations); migrate to 0035_faulty_human_fly - Router: keep both errandProcedures and headquartersProcedures - Errand prompt: take main's simpler version (no question-asking flow) - Manager: take main's status check (running|idle only, no waiting_for_input) - Tests: update to match removed conflictFiles field and undefined vs null
557 lines
16 KiB
TypeScript
557 lines
16 KiB
TypeScript
/**
|
|
* MultiProviderAgentManager Tests
|
|
*
|
|
* Unit tests for the MultiProviderAgentManager adapter.
|
|
* Mocks child_process.spawn since we can't spawn real Claude CLI in tests.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { MultiProviderAgentManager } from './manager.js';
|
|
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
|
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
|
import { EventEmitterBus } from '../events/index.js';
|
|
import type { DomainEvent } from '../events/index.js';
|
|
|
|
// Mock child_process.spawn and execFile
|
|
vi.mock('node:child_process', () => ({
|
|
spawn: vi.fn(),
|
|
execFile: vi.fn((_cmd: string, _args: string[], _opts: unknown, cb?: Function) => {
|
|
if (cb) cb(null, '', '');
|
|
}),
|
|
}));
|
|
|
|
// Import spawn to get the mock
|
|
import { spawn } from 'node:child_process';
|
|
const mockSpawn = vi.mocked(spawn);
|
|
|
|
// Mock SimpleGitWorktreeManager so spawn doesn't need a real git repo
|
|
vi.mock('../git/manager.js', () => {
|
|
return {
|
|
SimpleGitWorktreeManager: class MockWorktreeManager {
|
|
create = vi.fn().mockResolvedValue({ id: 'workspace', path: '/tmp/test-workspace/agent-workdirs/gastown/workspace', branch: 'agent/gastown' });
|
|
get = vi.fn().mockResolvedValue(null);
|
|
list = vi.fn().mockResolvedValue([]);
|
|
remove = vi.fn().mockResolvedValue(undefined);
|
|
},
|
|
};
|
|
});
|
|
|
|
// Mock fs operations for file-based output
|
|
vi.mock('node:fs', async () => {
|
|
const actual = await vi.importActual('node:fs');
|
|
// Create a mock write stream
|
|
const mockWriteStream = {
|
|
write: vi.fn(),
|
|
end: vi.fn(),
|
|
on: vi.fn(),
|
|
};
|
|
return {
|
|
...actual,
|
|
openSync: vi.fn().mockReturnValue(99),
|
|
closeSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
createWriteStream: vi.fn().mockReturnValue(mockWriteStream),
|
|
existsSync: vi.fn().mockReturnValue(true), // Default to true for our new validation
|
|
};
|
|
});
|
|
|
|
vi.mock('node:fs/promises', async () => {
|
|
const actual = await vi.importActual('node:fs/promises');
|
|
return {
|
|
...actual,
|
|
readFile: vi.fn().mockResolvedValue(''),
|
|
readdir: vi.fn().mockRejectedValue(new Error('ENOENT')),
|
|
rm: vi.fn().mockResolvedValue(undefined),
|
|
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
});
|
|
|
|
// Mock FileTailer to avoid actual file watching
|
|
vi.mock('./file-tailer.js', () => ({
|
|
FileTailer: class MockFileTailer {
|
|
start = vi.fn().mockResolvedValue(undefined);
|
|
stop = vi.fn().mockResolvedValue(undefined);
|
|
isStopped = false;
|
|
},
|
|
}));
|
|
|
|
import type { ChildProcess } from 'node:child_process';
|
|
|
|
/**
|
|
* Create a mock ChildProcess for detached spawning.
|
|
* The process is spawned detached and unreferenced.
|
|
*/
|
|
function createMockChildProcess(options?: {
|
|
pid?: number;
|
|
}) {
|
|
const { pid = 123 } = options ?? {};
|
|
|
|
// Create a minimal mock that satisfies the actual usage in spawnDetached
|
|
const childProcess = {
|
|
pid,
|
|
unref: vi.fn(),
|
|
on: vi.fn().mockReturnThis(),
|
|
kill: vi.fn(),
|
|
} as unknown as ChildProcess;
|
|
|
|
return childProcess;
|
|
}
|
|
|
|
describe('MultiProviderAgentManager', () => {
|
|
let manager: MultiProviderAgentManager;
|
|
let mockRepository: AgentRepository;
|
|
let mockProjectRepository: ProjectRepository;
|
|
let eventBus: EventEmitterBus;
|
|
let capturedEvents: DomainEvent[];
|
|
|
|
const mockAgent = {
|
|
id: 'agent-123',
|
|
name: 'gastown',
|
|
taskId: 'task-456',
|
|
initiativeId: null as string | null,
|
|
sessionId: 'session-789',
|
|
worktreeId: 'gastown',
|
|
status: 'idle' as const,
|
|
mode: 'execute' as const,
|
|
provider: 'claude',
|
|
accountId: null as string | null,
|
|
pid: null as number | null,
|
|
outputFilePath: null as string | null,
|
|
result: null as string | null,
|
|
pendingQuestions: null as string | null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
capturedEvents = [];
|
|
|
|
mockRepository = {
|
|
create: vi.fn().mockResolvedValue(mockAgent),
|
|
findById: vi.fn().mockResolvedValue(mockAgent),
|
|
findByName: vi.fn().mockResolvedValue(null), // No duplicate by default
|
|
findByTaskId: vi.fn().mockResolvedValue(mockAgent),
|
|
findBySessionId: vi.fn().mockResolvedValue(mockAgent),
|
|
findAll: vi.fn().mockResolvedValue([mockAgent]),
|
|
findByStatus: vi.fn().mockResolvedValue([mockAgent]),
|
|
update: vi.fn().mockResolvedValue(mockAgent),
|
|
delete: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
mockProjectRepository = {
|
|
create: vi.fn(),
|
|
findById: vi.fn(),
|
|
findByName: vi.fn(),
|
|
findAll: vi.fn().mockResolvedValue([]),
|
|
update: vi.fn(),
|
|
delete: vi.fn(),
|
|
addProjectToInitiative: vi.fn(),
|
|
removeProjectFromInitiative: vi.fn(),
|
|
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
|
|
setInitiativeProjects: vi.fn(),
|
|
};
|
|
|
|
eventBus = new EventEmitterBus();
|
|
// Subscribe to all agent events
|
|
eventBus.on('agent:spawned', (e) => capturedEvents.push(e));
|
|
eventBus.on('agent:stopped', (e) => capturedEvents.push(e));
|
|
eventBus.on('agent:crashed', (e) => capturedEvents.push(e));
|
|
eventBus.on('agent:resumed', (e) => capturedEvents.push(e));
|
|
eventBus.on('agent:waiting', (e) => capturedEvents.push(e));
|
|
|
|
manager = new MultiProviderAgentManager(
|
|
mockRepository,
|
|
'/tmp/test-workspace',
|
|
mockProjectRepository,
|
|
undefined,
|
|
eventBus
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('spawn', () => {
|
|
it('creates agent record with provided name', async () => {
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
const result = await manager.spawn({
|
|
name: 'gastown',
|
|
taskId: 'task-456',
|
|
prompt: 'Test task',
|
|
});
|
|
|
|
expect(mockRepository.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'gastown' })
|
|
);
|
|
expect(result.name).toBe('gastown');
|
|
});
|
|
|
|
it('rejects duplicate agent names', async () => {
|
|
mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent);
|
|
|
|
await expect(
|
|
manager.spawn({
|
|
name: 'gastown',
|
|
taskId: 'task-456',
|
|
prompt: 'Test',
|
|
})
|
|
).rejects.toThrow("Agent with name 'gastown' already exists");
|
|
});
|
|
|
|
it('emits AgentSpawned event with name', async () => {
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
await manager.spawn({
|
|
name: 'gastown',
|
|
taskId: 'task-456',
|
|
prompt: 'Test',
|
|
});
|
|
|
|
const spawnedEvent = capturedEvents.find(
|
|
(e) => e.type === 'agent:spawned'
|
|
);
|
|
expect(spawnedEvent).toBeDefined();
|
|
expect(
|
|
(spawnedEvent as { payload: { name: string } }).payload.name
|
|
).toBe('gastown');
|
|
});
|
|
|
|
it('writes diagnostic files for workdir verification', async () => {
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
// Mock fs/promises writeFile to capture diagnostic file writing
|
|
const { writeFile } = await import('node:fs/promises');
|
|
const mockWriteFile = vi.mocked(writeFile);
|
|
|
|
// The existsSync is already mocked globally to return true
|
|
|
|
await manager.spawn({
|
|
name: 'gastown',
|
|
taskId: 'task-456',
|
|
prompt: 'Test task',
|
|
});
|
|
|
|
// Verify diagnostic file was written
|
|
const diagnosticCalls = mockWriteFile.mock.calls.filter(call =>
|
|
call[0].toString().includes('spawn-diagnostic.json')
|
|
);
|
|
expect(diagnosticCalls).toHaveLength(1);
|
|
|
|
// Parse the diagnostic data to verify structure
|
|
const diagnosticCall = diagnosticCalls[0];
|
|
const diagnosticData = JSON.parse(diagnosticCall[1] as string);
|
|
|
|
expect(diagnosticData).toMatchObject({
|
|
agentId: expect.any(String),
|
|
alias: 'gastown',
|
|
intendedCwd: expect.stringContaining('/agent-workdirs/gastown/workspace'),
|
|
worktreeId: 'gastown',
|
|
provider: 'claude',
|
|
command: expect.any(String),
|
|
args: expect.any(Array),
|
|
env: expect.any(Object),
|
|
cwdExistsAtSpawn: true,
|
|
initiativeId: null,
|
|
customCwdProvided: false,
|
|
accountId: null,
|
|
timestamp: expect.any(String),
|
|
});
|
|
});
|
|
|
|
it('uses custom cwd if provided', async () => {
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
await manager.spawn({
|
|
name: 'chinatown',
|
|
taskId: 'task-789',
|
|
prompt: 'Test task',
|
|
cwd: '/custom/path',
|
|
});
|
|
|
|
// Verify spawn was called with custom cwd
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'claude',
|
|
expect.arrayContaining(['-p', expect.stringContaining('Test task'), '--output-format', 'stream-json']),
|
|
expect.objectContaining({ cwd: '/custom/path' })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('stop', () => {
|
|
it('stops running agent and updates status', async () => {
|
|
// When we call stop, it looks up the agent by ID
|
|
// The repository mock returns mockAgent which has id 'agent-123'
|
|
await manager.stop(mockAgent.id);
|
|
|
|
expect(mockRepository.update).toHaveBeenCalledWith(
|
|
mockAgent.id,
|
|
{ status: 'stopped', pendingQuestions: null }
|
|
);
|
|
});
|
|
|
|
it('kills detached process if running', async () => {
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
// Spawn returns immediately since process is detached
|
|
const spawned = await manager.spawn({
|
|
name: 'gastown',
|
|
taskId: 'task-456',
|
|
prompt: 'Test',
|
|
});
|
|
|
|
// Now stop using the returned agent ID
|
|
await manager.stop(spawned.id);
|
|
|
|
// Verify status was updated (process.kill is called internally, not on the child object)
|
|
expect(mockRepository.update).toHaveBeenCalledWith(
|
|
spawned.id,
|
|
{ status: 'stopped', pendingQuestions: null }
|
|
);
|
|
});
|
|
|
|
it('throws if agent not found', async () => {
|
|
mockRepository.findById = vi.fn().mockResolvedValue(null);
|
|
|
|
await expect(manager.stop('nonexistent')).rejects.toThrow(
|
|
"Agent 'nonexistent' not found"
|
|
);
|
|
});
|
|
|
|
it('emits AgentStopped event with user_requested reason', async () => {
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
const spawned = await manager.spawn({
|
|
name: 'gastown',
|
|
taskId: 'task-456',
|
|
prompt: 'Test',
|
|
});
|
|
await manager.stop(spawned.id);
|
|
|
|
const stoppedEvent = capturedEvents.find(
|
|
(e) => e.type === 'agent:stopped'
|
|
);
|
|
expect(stoppedEvent).toBeDefined();
|
|
expect(
|
|
(stoppedEvent as { payload: { reason: string } }).payload.reason
|
|
).toBe('user_requested');
|
|
});
|
|
});
|
|
|
|
describe('list', () => {
|
|
it('returns all agents with names', async () => {
|
|
const agents = await manager.list();
|
|
|
|
expect(agents).toHaveLength(1);
|
|
expect(agents[0].name).toBe('gastown');
|
|
});
|
|
});
|
|
|
|
describe('get', () => {
|
|
it('finds agent by id', async () => {
|
|
const agent = await manager.get('agent-123');
|
|
|
|
expect(mockRepository.findById).toHaveBeenCalledWith('agent-123');
|
|
expect(agent?.id).toBe('agent-123');
|
|
});
|
|
|
|
it('returns null if agent not found', async () => {
|
|
mockRepository.findById = vi.fn().mockResolvedValue(null);
|
|
|
|
const agent = await manager.get('nonexistent');
|
|
|
|
expect(agent).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getByName', () => {
|
|
it('finds agent by name', async () => {
|
|
mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent);
|
|
|
|
const agent = await manager.getByName('gastown');
|
|
|
|
expect(mockRepository.findByName).toHaveBeenCalledWith('gastown');
|
|
expect(agent?.name).toBe('gastown');
|
|
});
|
|
|
|
it('returns null if agent not found', async () => {
|
|
mockRepository.findByName = vi.fn().mockResolvedValue(null);
|
|
|
|
const agent = await manager.getByName('nonexistent');
|
|
|
|
expect(agent).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resume', () => {
|
|
it('resumes agent waiting for input with answers map', async () => {
|
|
mockRepository.findById = vi.fn().mockResolvedValue({
|
|
...mockAgent,
|
|
status: 'waiting_for_input',
|
|
});
|
|
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
await manager.resume(mockAgent.id, { q1: 'Answer one', q2: 'Answer two' });
|
|
|
|
// Verify spawn was called with resume args
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'claude',
|
|
expect.arrayContaining([
|
|
'--resume',
|
|
'session-789',
|
|
'--output-format',
|
|
'stream-json',
|
|
]),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('rejects if agent not waiting for input', async () => {
|
|
mockRepository.findById = vi.fn().mockResolvedValue({
|
|
...mockAgent,
|
|
status: 'running',
|
|
});
|
|
|
|
await expect(manager.resume(mockAgent.id, { q1: 'Answer' })).rejects.toThrow(
|
|
'not waiting for input'
|
|
);
|
|
});
|
|
|
|
it('rejects if agent has no session', async () => {
|
|
mockRepository.findById = vi.fn().mockResolvedValue({
|
|
...mockAgent,
|
|
status: 'waiting_for_input',
|
|
sessionId: null,
|
|
});
|
|
|
|
await expect(manager.resume(mockAgent.id, { q1: 'Answer' })).rejects.toThrow(
|
|
'has no session to resume'
|
|
);
|
|
});
|
|
|
|
it('emits AgentResumed event', async () => {
|
|
mockRepository.findById = vi.fn().mockResolvedValue({
|
|
...mockAgent,
|
|
status: 'waiting_for_input',
|
|
});
|
|
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
await manager.resume(mockAgent.id, { q1: 'User answer' });
|
|
|
|
const resumedEvent = capturedEvents.find(
|
|
(e) => e.type === 'agent:resumed'
|
|
);
|
|
expect(resumedEvent).toBeDefined();
|
|
expect(
|
|
(resumedEvent as { payload: { sessionId: string } }).payload.sessionId
|
|
).toBe('session-789');
|
|
});
|
|
});
|
|
|
|
describe('sendUserMessage', () => {
|
|
it('resumes errand agent in idle status', async () => {
|
|
mockRepository.findById = vi.fn().mockResolvedValue({
|
|
...mockAgent,
|
|
status: 'idle',
|
|
});
|
|
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
await expect(manager.sendUserMessage(mockAgent.id, 'my answer')).resolves.not.toThrow();
|
|
});
|
|
|
|
it('rejects if agent is stopped', async () => {
|
|
mockRepository.findById = vi.fn().mockResolvedValue({
|
|
...mockAgent,
|
|
status: 'stopped',
|
|
});
|
|
|
|
await expect(manager.sendUserMessage(mockAgent.id, 'message')).rejects.toThrow(
|
|
'Agent is not running'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getResult', () => {
|
|
it('returns null when agent has no result', async () => {
|
|
const result = await manager.getResult('agent-123');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('deletes agent and clears active state', async () => {
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
// Spawn an agent first
|
|
const spawned = await manager.spawn({
|
|
name: 'gastown',
|
|
taskId: 'task-456',
|
|
prompt: 'Test',
|
|
});
|
|
|
|
// Delete the agent
|
|
await manager.delete(spawned.id);
|
|
|
|
// Verify DB record was deleted
|
|
expect(mockRepository.delete).toHaveBeenCalledWith(spawned.id);
|
|
});
|
|
|
|
it('emits agent:deleted event', async () => {
|
|
const mockChild = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(mockChild);
|
|
|
|
eventBus.on('agent:deleted', (e) => capturedEvents.push(e));
|
|
|
|
const spawned = await manager.spawn({
|
|
name: 'gastown',
|
|
taskId: 'task-456',
|
|
prompt: 'Test',
|
|
});
|
|
|
|
await manager.delete(spawned.id);
|
|
|
|
const deletedEvent = capturedEvents.find(
|
|
(e) => e.type === 'agent:deleted'
|
|
);
|
|
expect(deletedEvent).toBeDefined();
|
|
expect(
|
|
(deletedEvent as { payload: { name: string } }).payload.name
|
|
).toBe('gastown');
|
|
});
|
|
|
|
it('throws if agent not found', async () => {
|
|
mockRepository.findById = vi.fn().mockResolvedValue(null);
|
|
|
|
await expect(manager.delete('nonexistent')).rejects.toThrow(
|
|
"Agent 'nonexistent' not found"
|
|
);
|
|
});
|
|
|
|
it('handles missing workdir gracefully', async () => {
|
|
// Agent exists in DB but has no active state and workdir doesn't exist
|
|
// The delete should succeed (best-effort cleanup)
|
|
await manager.delete(mockAgent.id);
|
|
|
|
expect(mockRepository.delete).toHaveBeenCalledWith(mockAgent.id);
|
|
});
|
|
});
|
|
});
|