Files
Codewalkers/apps/server/trpc/routers/errand.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

708 lines
28 KiB
TypeScript

/**
* 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<string, unknown>;
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<void> {
if (this.ensureBranchError) throw this.ensureBranchError;
this.ensuredBranches.push(branch);
}
async mergeBranch(_repoPath: string, _src: string, _target: string): Promise<MergeResult> {
return this.mergeResultOverride ?? { success: true, message: 'Merged successfully' };
}
async diffBranches(_repoPath: string, _base: string, _head: string): Promise<string> {
return this.diffResult;
}
async deleteBranch(_repoPath: string, branch: string): Promise<void> {
this.deletedBranches.push(branch);
}
async branchExists(_repoPath: string, _branch: string): Promise<boolean> { return false; }
async remoteBranchExists(_repoPath: string, _branch: string): Promise<boolean> { return false; }
async listCommits(_repoPath: string, _base: string, _head: string) { return []; }
async diffCommit(_repoPath: string, _hash: string): Promise<string> { return ''; }
async getMergeBase(_repoPath: string, _b1: string, _b2: string): Promise<string> { return ''; }
async pushBranch(_repoPath: string, _branch: string, _remote?: string): Promise<void> {}
async checkMergeability(_repoPath: string, _src: string, _target: string): Promise<MergeabilityResult> {
return { mergeable: true, conflicts: [] };
}
async fetchRemote(_repoPath: string, _remote?: string): Promise<void> {}
async fastForwardBranch(_repoPath: string, _branch: string, _remote?: string): Promise<void> {}
}
// ---------------------------------------------------------------------------
// 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<typeof createRepositories>) {
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<typeof createRepositories>,
agentManager: MockAgentManager,
overrides: Partial<{
description: string;
branch: string;
baseBranch: string;
agentId: string | null;
projectId: string;
status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
}> = {},
) {
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({
id: nanoid(),
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',
});
return { errand, project, agent };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('errand procedures', () => {
let h: ReturnType<typeof createTestHarness>;
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({ id: nanoid(), description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active' });
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 projectPath', 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).toHaveProperty('projectPath');
});
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',
});
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 sets status to conflict 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');
});
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).toBeUndefined();
});
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).toBeUndefined();
});
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).toBeUndefined();
});
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',
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'",
});
});
});
});