/** * Errand Router Tests * * Tests all 9 errand tRPC procedures using in-memory SQLite, MockAgentManager, * and vi.mock for git operations. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { nanoid } from 'nanoid'; import type { BranchManager } from '../../git/branch-manager.js'; import type { MergeResult, MergeabilityResult } from '../../git/types.js'; import { MockAgentManager } from '../../agent/mock-manager.js'; import { EventEmitterBus } from '../../events/bus.js'; import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js'; import { createRepositories } from '../../container.js'; import { appRouter, createCallerFactory } from '../router.js'; import { createContext } from '../context.js'; // --------------------------------------------------------------------------- // vi.hoisted mock handles for git module mocks (hoisted before vi.mock calls) // --------------------------------------------------------------------------- const { mockCreate, mockRemove, mockEnsureProjectClone, mockWriteErrandManifest } = vi.hoisted(() => ({ mockCreate: vi.fn(), mockRemove: vi.fn(), mockEnsureProjectClone: vi.fn(), mockWriteErrandManifest: vi.fn(), })); vi.mock('../../git/manager.js', () => ({ SimpleGitWorktreeManager: class MockWorktreeManager { create = mockCreate; remove = mockRemove; }, })); vi.mock('../../git/project-clones.js', () => ({ ensureProjectClone: mockEnsureProjectClone, getProjectCloneDir: vi.fn().mockReturnValue('repos/test-project-abc123'), })); vi.mock('../../agent/file-io.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, writeErrandManifest: mockWriteErrandManifest, }; }); // --------------------------------------------------------------------------- // MockBranchManager // --------------------------------------------------------------------------- class MockBranchManager implements BranchManager { private ensureBranchError: Error | null = null; private mergeResultOverride: MergeResult | null = null; private diffResult = ''; public deletedBranches: string[] = []; public ensuredBranches: string[] = []; setEnsureBranchError(err: Error | null): void { this.ensureBranchError = err; } setMergeResult(result: MergeResult): void { this.mergeResultOverride = result; } setDiffResult(diff: string): void { this.diffResult = diff; } async ensureBranch(_repoPath: string, branch: string, _baseBranch: string): Promise { if (this.ensureBranchError) throw this.ensureBranchError; this.ensuredBranches.push(branch); } async mergeBranch(_repoPath: string, _src: string, _target: string): Promise { return this.mergeResultOverride ?? { success: true, message: 'Merged successfully' }; } async diffBranches(_repoPath: string, _base: string, _head: string): Promise { return this.diffResult; } async deleteBranch(_repoPath: string, branch: string): Promise { this.deletedBranches.push(branch); } async branchExists(_repoPath: string, _branch: string): Promise { return false; } async remoteBranchExists(_repoPath: string, _branch: string): Promise { return false; } async listCommits(_repoPath: string, _base: string, _head: string) { return []; } async diffCommit(_repoPath: string, _hash: string): Promise { return ''; } async getMergeBase(_repoPath: string, _b1: string, _b2: string): Promise { return ''; } async pushBranch(_repoPath: string, _branch: string, _remote?: string): Promise {} async checkMergeability(_repoPath: string, _src: string, _target: string): Promise { return { mergeable: true, conflicts: [] }; } async fetchRemote(_repoPath: string, _remote?: string): Promise {} async fastForwardBranch(_repoPath: string, _branch: string, _remote?: string): Promise {} } // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- const createCaller = createCallerFactory(appRouter); function createTestHarness() { const db = createTestDatabase(); const eventBus = new EventEmitterBus(); const repos = createRepositories(db); const agentManager = new MockAgentManager({ eventBus, agentRepository: repos.agentRepository }); const branchManager = new MockBranchManager(); const ctx = createContext({ eventBus, serverStartedAt: new Date(), processCount: 0, agentManager, errandRepository: repos.errandRepository, projectRepository: repos.projectRepository, branchManager, workspaceRoot: '/tmp/test-workspace', }); const caller = createCaller(ctx); return { db, caller, agentManager, branchManager, repos, }; } async function createProject(repos: ReturnType) { const suffix = nanoid().slice(0, 6); return repos.projectRepository.create({ name: `test-project-${suffix}`, url: `https://github.com/test/project-${suffix}`, defaultBranch: 'main', }); } async function createErrandDirect( repos: ReturnType, agentManager: MockAgentManager, overrides: Partial<{ description: string; branch: string; baseBranch: string; agentId: string | null; projectId: string; status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned'; conflictFiles: string | null; }> = {}, ) { const project = await createProject(repos); // Spawn an agent to get a real agent ID (unique name to avoid name collision) const agent = await agentManager.spawn({ prompt: 'Test errand', name: `errand-agent-${nanoid().slice(0, 6)}`, mode: 'errand', cwd: '/tmp/fake-worktree', taskId: null, }); const errand = await repos.errandRepository.create({ description: overrides.description ?? 'Fix typo in README', branch: overrides.branch ?? 'cw/errand/fix-typo-abc12345', baseBranch: overrides.baseBranch ?? 'main', agentId: overrides.agentId !== undefined ? overrides.agentId : agent.id, projectId: overrides.projectId ?? project.id, status: overrides.status ?? 'active', conflictFiles: overrides.conflictFiles ?? null, }); return { errand, project, agent }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('errand procedures', () => { let h: ReturnType; beforeEach(() => { h = createTestHarness(); // Reset mock call counts and set default passing behavior mockCreate.mockClear(); mockRemove.mockClear(); mockEnsureProjectClone.mockClear(); mockWriteErrandManifest.mockClear(); mockEnsureProjectClone.mockResolvedValue('/tmp/fake-clone'); mockCreate.mockResolvedValue({ id: 'errand-id', branch: 'cw/errand/test', path: '/tmp/worktree', isMainWorktree: false }); mockRemove.mockResolvedValue(undefined); mockWriteErrandManifest.mockResolvedValue(undefined); h.branchManager.setEnsureBranchError(null); h.branchManager.deletedBranches.splice(0); h.branchManager.ensuredBranches.splice(0); }); // ========================================================================= // errand.create // ========================================================================= describe('errand.create', () => { it('creates errand with valid input and returns id, branch, agentId', async () => { const project = await createProject(h.repos); const result = await h.caller.errand.create({ description: 'Fix typo in README', projectId: project.id, }); expect(result).toMatchObject({ id: expect.any(String), branch: expect.stringMatching(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/), agentId: expect.any(String), }); }); it('generates correct slug from description', async () => { const project = await createProject(h.repos); const result = await h.caller.errand.create({ description: 'fix typo in README', projectId: project.id, }); expect(result.branch).toMatch(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/); }); it('uses fallback slug "errand" when description has only special chars', async () => { const project = await createProject(h.repos); const result = await h.caller.errand.create({ description: '!!!', projectId: project.id, }); expect(result.branch).toMatch(/^cw\/errand\/errand-[a-zA-Z0-9_-]{8}$/); }); it('stores errand in database with correct fields', async () => { const project = await createProject(h.repos); const result = await h.caller.errand.create({ description: 'Fix typo in README', projectId: project.id, baseBranch: 'develop', }); const errand = await h.repos.errandRepository.findById(result.id); expect(errand).not.toBeNull(); expect(errand!.description).toBe('Fix typo in README'); expect(errand!.baseBranch).toBe('develop'); expect(errand!.projectId).toBe(project.id); expect(errand!.status).toBe('active'); expect(errand!.agentId).toBe(result.agentId); }); it('throws BAD_REQUEST when description exceeds 200 chars', async () => { const project = await createProject(h.repos); const longDesc = 'a'.repeat(201); await expect(h.caller.errand.create({ description: longDesc, projectId: project.id, })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: `description must be ≤200 characters (201 given)`, }); // No DB record created const errands = await h.repos.errandRepository.findAll(); expect(errands).toHaveLength(0); }); it('throws NOT_FOUND for non-existent projectId', async () => { await expect(h.caller.errand.create({ description: 'Fix something', projectId: 'nonexistent-project', })).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Project not found', }); // No DB record created const errands = await h.repos.errandRepository.findAll(); expect(errands).toHaveLength(0); }); it('throws INTERNAL_SERVER_ERROR when branch creation fails', async () => { const project = await createProject(h.repos); h.branchManager.setEnsureBranchError(new Error('Git error: branch locked')); await expect(h.caller.errand.create({ description: 'Fix something', projectId: project.id, })).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR', message: 'Git error: branch locked', }); // No DB record, no worktree created const errands = await h.repos.errandRepository.findAll(); expect(errands).toHaveLength(0); expect(mockCreate).not.toHaveBeenCalled(); }); it('throws INTERNAL_SERVER_ERROR when worktree creation fails, cleans up branch and DB record', async () => { const project = await createProject(h.repos); mockCreate.mockRejectedValueOnce(new Error('Worktree creation failed')); await expect(h.caller.errand.create({ description: 'Fix something', projectId: project.id, })).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR', message: 'Worktree creation failed', }); // No DB record (was created then deleted) const errands = await h.repos.errandRepository.findAll(); expect(errands).toHaveLength(0); // Branch was deleted expect(h.branchManager.deletedBranches.length).toBe(1); }); it('throws INTERNAL_SERVER_ERROR when agent spawn fails, cleans up worktree, DB record, and branch', async () => { const project = await createProject(h.repos); // Make spawn fail by using a scenario that throws immediately vi.spyOn(h.agentManager, 'spawn').mockRejectedValueOnce(new Error('Spawn failed')); await expect(h.caller.errand.create({ description: 'Fix something', projectId: project.id, })).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR', message: 'Spawn failed', }); // No DB record (was created then deleted) const errands = await h.repos.errandRepository.findAll(); expect(errands).toHaveLength(0); // Worktree was removed, branch deleted expect(mockRemove).toHaveBeenCalledOnce(); expect(h.branchManager.deletedBranches.length).toBe(1); }); }); // ========================================================================= // errand.list // ========================================================================= describe('errand.list', () => { it('returns all errands ordered newest first', async () => { const { errand: e1 } = await createErrandDirect(h.repos, h.agentManager, { description: 'First' }); const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' }); const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { description: 'Second', projectId: project2.id, branch: 'cw/errand/second-xyz12345' }); const result = await h.caller.errand.list({}); expect(result.length).toBe(2); // Both errands are present (repository orders by createdAt DESC) const ids = result.map(r => r.id); expect(ids).toContain(e1.id); expect(ids).toContain(e2.id); }); it('filters by projectId', async () => { const { errand: e1, project } = await createErrandDirect(h.repos, h.agentManager); const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' }); const agent2 = await h.agentManager.spawn({ prompt: 'x', mode: 'errand', cwd: '/tmp/x', taskId: null }); await h.repos.errandRepository.create({ description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active', conflictFiles: null }); const result = await h.caller.errand.list({ projectId: project.id }); expect(result.length).toBe(1); expect(result[0].id).toBe(e1.id); }); it('filters by status', async () => { await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', branch: 'cw/errand/merged-abc12345' }); const result = await h.caller.errand.list({ status: 'merged' }); expect(result.length).toBe(1); expect(result[0].id).toBe(e2.id); }); it('returns empty array when no errands exist', async () => { const result = await h.caller.errand.list({}); expect(result).toEqual([]); }); it('each record includes agentAlias', async () => { await createErrandDirect(h.repos, h.agentManager); const result = await h.caller.errand.list({}); expect(result[0]).toHaveProperty('agentAlias'); }); }); // ========================================================================= // errand.get // ========================================================================= describe('errand.get', () => { it('returns errand with agentAlias and parsed conflictFiles', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager); const result = await h.caller.errand.get({ id: errand.id }); expect(result.id).toBe(errand.id); expect(result).toHaveProperty('agentAlias'); expect(result.conflictFiles).toEqual([]); }); it('parses conflictFiles JSON when present', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'conflict', conflictFiles: '["src/a.ts","src/b.ts"]', }); const result = await h.caller.errand.get({ id: errand.id }); expect(result.conflictFiles).toEqual(['src/a.ts', 'src/b.ts']); }); it('throws NOT_FOUND for unknown id', async () => { await expect(h.caller.errand.get({ id: 'nonexistent' })).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Errand not found', }); }); }); // ========================================================================= // errand.diff // ========================================================================= describe('errand.diff', () => { it('returns diff string for an existing errand', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager); h.branchManager.setDiffResult('diff --git a/README.md b/README.md\n...'); const result = await h.caller.errand.diff({ id: errand.id }); expect(result.diff).toBe('diff --git a/README.md b/README.md\n...'); }); it('returns empty diff string when branch has no commits', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager); h.branchManager.setDiffResult(''); const result = await h.caller.errand.diff({ id: errand.id }); expect(result.diff).toBe(''); }); it('throws NOT_FOUND for unknown id', async () => { await expect(h.caller.errand.diff({ id: 'nonexistent' })).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Errand not found', }); }); }); // ========================================================================= // errand.complete // ========================================================================= describe('errand.complete', () => { it('transitions active errand to pending_review and stops agent', async () => { const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); const stopSpy = vi.spyOn(h.agentManager, 'stop'); const result = await h.caller.errand.complete({ id: errand.id }); expect(result!.status).toBe('pending_review'); expect(stopSpy).toHaveBeenCalledWith(agent.id); }); it('throws BAD_REQUEST when status is pending_review', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: "Cannot complete an errand with status 'pending_review'", }); }); it('throws BAD_REQUEST when status is merged', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null }); await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: "Cannot complete an errand with status 'merged'", }); }); }); // ========================================================================= // errand.merge // ========================================================================= describe('errand.merge', () => { it('merges clean pending_review errand, removes worktree, sets status to merged', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); h.branchManager.setMergeResult({ success: true, message: 'Merged' }); const result = await h.caller.errand.merge({ id: errand.id }); expect(result).toEqual({ status: 'merged' }); expect(mockRemove).toHaveBeenCalledOnce(); const updated = await h.repos.errandRepository.findById(errand.id); expect(updated!.status).toBe('merged'); }); it('merges clean conflict errand (re-merge after resolve)', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'conflict', conflictFiles: '["src/a.ts"]', }); h.branchManager.setMergeResult({ success: true, message: 'Merged' }); const result = await h.caller.errand.merge({ id: errand.id }); expect(result).toEqual({ status: 'merged' }); }); it('merges into target branch override', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); const mergeSpy = vi.spyOn(h.branchManager, 'mergeBranch'); await h.caller.errand.merge({ id: errand.id, target: 'develop' }); expect(mergeSpy).toHaveBeenCalledWith( expect.any(String), errand.branch, 'develop', ); }); it('throws BAD_REQUEST and stores conflictFiles on merge conflict', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); h.branchManager.setMergeResult({ success: false, conflicts: ['src/a.ts', 'src/b.ts'], message: 'Conflict detected', }); await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Merge conflict in 2 file(s)', }); const updated = await h.repos.errandRepository.findById(errand.id); expect(updated!.status).toBe('conflict'); expect(JSON.parse(updated!.conflictFiles!)).toEqual(['src/a.ts', 'src/b.ts']); }); it('throws BAD_REQUEST when status is active', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: "Cannot merge an errand with status 'active'", }); }); it('throws BAD_REQUEST when status is abandoned', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null }); await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: "Cannot merge an errand with status 'abandoned'", }); }); }); // ========================================================================= // errand.delete // ========================================================================= describe('errand.delete', () => { it('deletes active errand: stops agent, removes worktree, deletes branch and DB record', async () => { const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); const stopSpy = vi.spyOn(h.agentManager, 'stop'); const result = await h.caller.errand.delete({ id: errand.id }); expect(result).toEqual({ success: true }); expect(stopSpy).toHaveBeenCalledWith(agent.id); expect(mockRemove).toHaveBeenCalledOnce(); expect(h.branchManager.deletedBranches).toContain(errand.branch); const deleted = await h.repos.errandRepository.findById(errand.id); expect(deleted).toBeNull(); }); it('deletes non-active errand: skips agent stop', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); const stopSpy = vi.spyOn(h.agentManager, 'stop'); const result = await h.caller.errand.delete({ id: errand.id }); expect(result).toEqual({ success: true }); expect(stopSpy).not.toHaveBeenCalled(); const deleted = await h.repos.errandRepository.findById(errand.id); expect(deleted).toBeNull(); }); it('succeeds when worktree already removed (no-op)', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); mockRemove.mockRejectedValueOnce(new Error('Worktree not found')); // Should not throw const result = await h.caller.errand.delete({ id: errand.id }); expect(result).toEqual({ success: true }); const deleted = await h.repos.errandRepository.findById(errand.id); expect(deleted).toBeNull(); }); it('succeeds when branch already deleted (no-op)', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); // DeleteBranch doesn't throw (BranchManager interface says no-op if not found) const result = await h.caller.errand.delete({ id: errand.id }); expect(result).toEqual({ success: true }); }); it('throws NOT_FOUND for unknown id', async () => { await expect(h.caller.errand.delete({ id: 'nonexistent' })).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Errand not found', }); }); }); // ========================================================================= // errand.sendMessage // ========================================================================= describe('errand.sendMessage', () => { it('sends message to active running errand agent', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); const sendSpy = vi.spyOn(h.agentManager, 'sendUserMessage'); const result = await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' }); expect(result).toEqual({ success: true }); expect(sendSpy).toHaveBeenCalledWith(errand.agentId, 'Hello agent'); }); it('does NOT create a conversations record', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' }); // No pending conversation records should exist for the agent const convs = await h.repos.conversationRepository.findPendingForAgent(errand.agentId!); expect(convs).toHaveLength(0); }); it('throws BAD_REQUEST when agent is stopped', async () => { const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); // Stop the agent to set its status to stopped await h.agentManager.stop(agent.id); await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Agent is not running (status: stopped)', }); }); it('throws BAD_REQUEST when errand is not active', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Errand is not active', }); }); }); // ========================================================================= // errand.abandon // ========================================================================= describe('errand.abandon', () => { it('abandons active errand: stops agent, removes worktree, deletes branch, sets status', async () => { const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' }); const stopSpy = vi.spyOn(h.agentManager, 'stop'); const result = await h.caller.errand.abandon({ id: errand.id }); expect(result!.status).toBe('abandoned'); expect(result!.agentId).toBe(agent.id); // agentId preserved expect(stopSpy).toHaveBeenCalledWith(agent.id); expect(mockRemove).toHaveBeenCalledOnce(); expect(h.branchManager.deletedBranches).toContain(errand.branch); // DB record preserved with abandoned status const found = await h.repos.errandRepository.findById(errand.id); expect(found!.status).toBe('abandoned'); }); it('abandons pending_review errand: skips agent stop', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' }); const stopSpy = vi.spyOn(h.agentManager, 'stop'); const result = await h.caller.errand.abandon({ id: errand.id }); expect(result!.status).toBe('abandoned'); expect(stopSpy).not.toHaveBeenCalled(); }); it('abandons conflict errand: skips agent stop, removes worktree, deletes branch', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'conflict', conflictFiles: '["src/a.ts"]', agentId: null, }); const result = await h.caller.errand.abandon({ id: errand.id }); expect(result!.status).toBe('abandoned'); }); it('throws BAD_REQUEST when status is merged', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null }); await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: "Cannot abandon an errand with status 'merged'", }); }); it('throws BAD_REQUEST when status is abandoned', async () => { const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null }); await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: "Cannot abandon an errand with status 'abandoned'", }); }); }); });