Files
Codewalkers/apps/server/agent/manager.test.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
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
2026-03-06 16:48:12 +01:00

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);
});
});
});