Replaces the in-memory filter (agentManager.list() + filter) with a direct repository query that LEFT JOINs tasks, phases, and initiatives to return taskName, phaseName, initiativeName, and taskDescription alongside agent fields. - Adds AgentWithContext interface and findWaitingWithContext() to AgentRepository port - Implements findWaitingWithContext() in DrizzleAgentRepository using getTableColumns - Wires agentRepository into TRPCContext, CreateContextOptions, and TrpcAdapterOptions - Adds requireAgentRepository() helper following existing pattern - Updates listWaitingAgents to use repository query instead of agentManager - Adds 5 unit tests for findWaitingWithContext() covering all FK join edge cases - Updates existing AgentRepository mocks to satisfy updated interface Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
/**
|
|
* DrizzleAgentRepository Tests
|
|
*
|
|
* Tests for the Agent repository adapter.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { DrizzleAgentRepository } from './agent.js';
|
|
import { DrizzleTaskRepository } from './task.js';
|
|
import { DrizzlePhaseRepository } from './phase.js';
|
|
import { DrizzleInitiativeRepository } from './initiative.js';
|
|
import { createTestDatabase } from './test-helpers.js';
|
|
import type { DrizzleDatabase } from '../../index.js';
|
|
|
|
describe('DrizzleAgentRepository', () => {
|
|
let db: DrizzleDatabase;
|
|
let agentRepo: DrizzleAgentRepository;
|
|
let taskRepo: DrizzleTaskRepository;
|
|
let phaseRepo: DrizzlePhaseRepository;
|
|
let initiativeRepo: DrizzleInitiativeRepository;
|
|
let testTaskId: string;
|
|
|
|
beforeEach(async () => {
|
|
db = createTestDatabase();
|
|
agentRepo = new DrizzleAgentRepository(db);
|
|
taskRepo = new DrizzleTaskRepository(db);
|
|
phaseRepo = new DrizzlePhaseRepository(db);
|
|
initiativeRepo = new DrizzleInitiativeRepository(db);
|
|
|
|
// Create full hierarchy for FK constraint
|
|
const initiative = await initiativeRepo.create({
|
|
name: 'Test Initiative',
|
|
});
|
|
const phase = await phaseRepo.create({
|
|
initiativeId: initiative.id,
|
|
name: 'Test Phase',
|
|
});
|
|
const task = await taskRepo.create({
|
|
phaseId: phase.id,
|
|
name: 'Test Task',
|
|
order: 1,
|
|
});
|
|
testTaskId = task.id;
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should create an agent with generated id and timestamps', async () => {
|
|
const agent = await agentRepo.create({
|
|
name: 'gastown',
|
|
worktreeId: 'worktree-123',
|
|
taskId: testTaskId,
|
|
});
|
|
|
|
expect(agent.id).toBeDefined();
|
|
expect(agent.id.length).toBeGreaterThan(0);
|
|
expect(agent.name).toBe('gastown');
|
|
expect(agent.worktreeId).toBe('worktree-123');
|
|
expect(agent.taskId).toBe(testTaskId);
|
|
expect(agent.sessionId).toBeNull();
|
|
expect(agent.status).toBe('idle');
|
|
expect(agent.createdAt).toBeInstanceOf(Date);
|
|
expect(agent.updatedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('should create agent without taskId', async () => {
|
|
const agent = await agentRepo.create({
|
|
name: 'standalone',
|
|
worktreeId: 'worktree-456',
|
|
});
|
|
|
|
expect(agent.taskId).toBeNull();
|
|
});
|
|
|
|
it('should reject duplicate names', async () => {
|
|
await agentRepo.create({
|
|
name: 'unique-name',
|
|
worktreeId: 'worktree-1',
|
|
});
|
|
|
|
await expect(
|
|
agentRepo.create({
|
|
name: 'unique-name',
|
|
worktreeId: 'worktree-2',
|
|
})
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('findById', () => {
|
|
it('should return null for non-existent agent', async () => {
|
|
const result = await agentRepo.findById('non-existent-id');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should find an existing agent', async () => {
|
|
const created = await agentRepo.create({
|
|
name: 'findme',
|
|
worktreeId: 'worktree-123',
|
|
});
|
|
|
|
const found = await agentRepo.findById(created.id);
|
|
expect(found).not.toBeNull();
|
|
expect(found!.id).toBe(created.id);
|
|
expect(found!.name).toBe('findme');
|
|
});
|
|
});
|
|
|
|
describe('findByName', () => {
|
|
it('should return null for non-existent name', async () => {
|
|
const result = await agentRepo.findByName('nonexistent');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should find agent by human-readable name', async () => {
|
|
await agentRepo.create({
|
|
name: 'chinatown',
|
|
worktreeId: 'worktree-123',
|
|
});
|
|
|
|
const found = await agentRepo.findByName('chinatown');
|
|
expect(found).not.toBeNull();
|
|
expect(found!.name).toBe('chinatown');
|
|
});
|
|
});
|
|
|
|
describe('findByTaskId', () => {
|
|
it('should return null when no agent assigned to task', async () => {
|
|
const result = await agentRepo.findByTaskId(testTaskId);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should find agent by task', async () => {
|
|
await agentRepo.create({
|
|
name: 'task-agent',
|
|
worktreeId: 'worktree-123',
|
|
taskId: testTaskId,
|
|
});
|
|
|
|
const found = await agentRepo.findByTaskId(testTaskId);
|
|
expect(found).not.toBeNull();
|
|
expect(found!.taskId).toBe(testTaskId);
|
|
});
|
|
});
|
|
|
|
describe('findBySessionId', () => {
|
|
it('should return null when no agent has session', async () => {
|
|
const result = await agentRepo.findBySessionId('session-123');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should find agent by session ID', async () => {
|
|
const agent = await agentRepo.create({
|
|
name: 'session-agent',
|
|
worktreeId: 'worktree-123',
|
|
});
|
|
await agentRepo.update(agent.id, { sessionId: 'session-abc' });
|
|
|
|
const found = await agentRepo.findBySessionId('session-abc');
|
|
expect(found).not.toBeNull();
|
|
expect(found!.sessionId).toBe('session-abc');
|
|
});
|
|
});
|
|
|
|
describe('findAll', () => {
|
|
it('should return empty array when no agents', async () => {
|
|
const agents = await agentRepo.findAll();
|
|
expect(agents).toEqual([]);
|
|
});
|
|
|
|
it('should return all agents', async () => {
|
|
await agentRepo.create({ name: 'agent-1', worktreeId: 'wt-1' });
|
|
await agentRepo.create({ name: 'agent-2', worktreeId: 'wt-2' });
|
|
await agentRepo.create({ name: 'agent-3', worktreeId: 'wt-3' });
|
|
|
|
const agents = await agentRepo.findAll();
|
|
expect(agents.length).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('findByStatus', () => {
|
|
it('should return empty array when no agents have status', async () => {
|
|
const agents = await agentRepo.findByStatus('running');
|
|
expect(agents).toEqual([]);
|
|
});
|
|
|
|
it('should filter by status correctly', async () => {
|
|
const agent1 = await agentRepo.create({
|
|
name: 'idle-agent',
|
|
worktreeId: 'wt-1',
|
|
});
|
|
const agent2 = await agentRepo.create({
|
|
name: 'running-agent',
|
|
worktreeId: 'wt-2',
|
|
});
|
|
await agentRepo.update(agent2.id, { status: 'running' });
|
|
|
|
const idleAgents = await agentRepo.findByStatus('idle');
|
|
const runningAgents = await agentRepo.findByStatus('running');
|
|
|
|
expect(idleAgents.length).toBe(1);
|
|
expect(idleAgents[0].name).toBe('idle-agent');
|
|
expect(runningAgents.length).toBe(1);
|
|
expect(runningAgents[0].name).toBe('running-agent');
|
|
});
|
|
|
|
it('should filter by waiting_for_input status', async () => {
|
|
const agent = await agentRepo.create({
|
|
name: 'waiting-agent',
|
|
worktreeId: 'wt-1',
|
|
});
|
|
await agentRepo.update(agent.id, { status: 'waiting_for_input' });
|
|
|
|
const waitingAgents = await agentRepo.findByStatus('waiting_for_input');
|
|
expect(waitingAgents.length).toBe(1);
|
|
expect(waitingAgents[0].status).toBe('waiting_for_input');
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should change status and updatedAt', async () => {
|
|
const created = await agentRepo.create({
|
|
name: 'status-test',
|
|
worktreeId: 'wt-1',
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
const updated = await agentRepo.update(created.id, { status: 'running' });
|
|
|
|
expect(updated.status).toBe('running');
|
|
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
|
created.updatedAt.getTime()
|
|
);
|
|
});
|
|
|
|
it('should change sessionId and updatedAt', async () => {
|
|
const created = await agentRepo.create({
|
|
name: 'session-test',
|
|
worktreeId: 'wt-1',
|
|
});
|
|
expect(created.sessionId).toBeNull();
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
const updated = await agentRepo.update(created.id, { sessionId: 'new-session-id' });
|
|
|
|
expect(updated.sessionId).toBe('new-session-id');
|
|
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
|
created.updatedAt.getTime()
|
|
);
|
|
});
|
|
|
|
it('should throw for non-existent agent', async () => {
|
|
await expect(
|
|
agentRepo.update('non-existent-id', { status: 'running' })
|
|
).rejects.toThrow('Agent not found');
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should delete an existing agent', async () => {
|
|
const created = await agentRepo.create({
|
|
name: 'to-delete',
|
|
worktreeId: 'wt-1',
|
|
});
|
|
|
|
await agentRepo.delete(created.id);
|
|
|
|
const found = await agentRepo.findById(created.id);
|
|
expect(found).toBeNull();
|
|
});
|
|
|
|
it('should throw for non-existent agent', async () => {
|
|
await expect(agentRepo.delete('non-existent-id')).rejects.toThrow(
|
|
'Agent not found'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('DrizzleAgentRepository.findWaitingWithContext()', () => {
|
|
let agentRepo: DrizzleAgentRepository;
|
|
let taskRepo: DrizzleTaskRepository;
|
|
let phaseRepo: DrizzlePhaseRepository;
|
|
let initiativeRepo: DrizzleInitiativeRepository;
|
|
|
|
beforeEach(() => {
|
|
const db = createTestDatabase();
|
|
agentRepo = new DrizzleAgentRepository(db);
|
|
taskRepo = new DrizzleTaskRepository(db);
|
|
phaseRepo = new DrizzlePhaseRepository(db);
|
|
initiativeRepo = new DrizzleInitiativeRepository(db);
|
|
});
|
|
|
|
it('returns empty array when no waiting agents exist', async () => {
|
|
const result = await agentRepo.findWaitingWithContext();
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('only returns agents with status waiting_for_input', async () => {
|
|
await agentRepo.create({ name: 'running-agent', worktreeId: 'wt1', status: 'running' });
|
|
await agentRepo.create({ name: 'waiting-agent', worktreeId: 'wt2', status: 'waiting_for_input' });
|
|
|
|
const result = await agentRepo.findWaitingWithContext();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('waiting-agent');
|
|
});
|
|
|
|
it('populates taskName, phaseName, initiativeName, taskDescription when FK associations exist', async () => {
|
|
const initiative = await initiativeRepo.create({ name: 'My Initiative' });
|
|
const phase = await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1' });
|
|
const task = await taskRepo.create({
|
|
phaseId: phase.id,
|
|
name: 'Implement feature',
|
|
description: 'Write the feature code',
|
|
});
|
|
|
|
await agentRepo.create({
|
|
name: 'ctx-agent',
|
|
worktreeId: 'wt3',
|
|
status: 'waiting_for_input',
|
|
taskId: task.id,
|
|
initiativeId: initiative.id,
|
|
});
|
|
|
|
const result = await agentRepo.findWaitingWithContext();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].taskName).toBe('Implement feature');
|
|
expect(result[0].phaseName).toBe('Phase 1');
|
|
expect(result[0].initiativeName).toBe('My Initiative');
|
|
expect(result[0].taskDescription).toBe('Write the feature code');
|
|
});
|
|
|
|
it('returns null for context fields when agent has no taskId or initiativeId', async () => {
|
|
await agentRepo.create({ name: 'bare-agent', worktreeId: 'wt4', status: 'waiting_for_input' });
|
|
|
|
const result = await agentRepo.findWaitingWithContext();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].taskName).toBeNull();
|
|
expect(result[0].phaseName).toBeNull();
|
|
expect(result[0].initiativeName).toBeNull();
|
|
expect(result[0].taskDescription).toBeNull();
|
|
});
|
|
|
|
it('returns null phaseName when task has no phaseId', async () => {
|
|
const initiative = await initiativeRepo.create({ name: 'Orphan Init' });
|
|
const task = await taskRepo.create({
|
|
phaseId: null,
|
|
name: 'Orphan Task',
|
|
description: null,
|
|
});
|
|
|
|
await agentRepo.create({
|
|
name: 'orphan-agent',
|
|
worktreeId: 'wt5',
|
|
status: 'waiting_for_input',
|
|
taskId: task.id,
|
|
initiativeId: initiative.id,
|
|
});
|
|
|
|
const result = await agentRepo.findWaitingWithContext();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].phaseName).toBeNull();
|
|
expect(result[0].taskName).toBe('Orphan Task');
|
|
expect(result[0].initiativeName).toBe('Orphan Init');
|
|
});
|
|
});
|