refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
This commit is contained in:
529
apps/server/agent/manager.test.ts
Normal file
529
apps/server/agent/manager.test.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
});
|
||||
|
||||
// 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.writeFileSync to capture diagnostic file writing
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
const mockWriteFileSync = vi.mocked(writeFileSync);
|
||||
|
||||
// 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 = mockWriteFileSync.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('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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user