Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
518 lines
19 KiB
JavaScript
518 lines
19 KiB
JavaScript
/**
|
|
* AgentWorkerService Tests — ADR-104
|
|
*
|
|
* Tests the async agent dispatch system:
|
|
* - findReadyTickets: query for backlog/assigned tickets
|
|
* - executeTicket: full lifecycle (assign → execute → review/error)
|
|
* - ensureTicketConversation: ticket-conversation binding
|
|
* - poll: concurrency limits, deduplication
|
|
* - buildTicketUserMessage / buildTicketSystemPrompt: context builders
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
|
|
// ===== MOCKS =====
|
|
|
|
const mockDbGet = vi.fn();
|
|
const mockDbRun = vi.fn();
|
|
const mockDbAll = vi.fn();
|
|
|
|
vi.mock('../../database/connection', () => ({
|
|
dbGet: (...args) => mockDbGet(...args),
|
|
dbRun: (...args) => mockDbRun(...args),
|
|
dbAll: (...args) => mockDbAll(...args),
|
|
isPostgres: () => false,
|
|
safeJsonParse: (val, fallback) => {
|
|
if (!val) return fallback;
|
|
try { return typeof val === 'string' ? JSON.parse(val) : val; } catch { return fallback; }
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../utils/baseId', () => ({
|
|
generateBaseId: () => 'TESTID01',
|
|
}));
|
|
|
|
vi.mock('../../utils/logger', () => ({
|
|
apiLogger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockAgentLoop = vi.fn();
|
|
const mockSaveStepMessage = vi.fn();
|
|
const mockResolveAllowedTools = vi.fn();
|
|
|
|
vi.mock('../AgentLoopService', () => ({
|
|
agentLoop: (...args) => mockAgentLoop(...args),
|
|
saveStepMessage: (...args) => mockSaveStepMessage(...args),
|
|
resolveAllowedTools: (...args) => mockResolveAllowedTools(...args),
|
|
}));
|
|
|
|
const mockResolveProvider = vi.fn();
|
|
const mockBuildSystemPrompt = vi.fn();
|
|
|
|
const mockDetectProvider = vi.fn();
|
|
|
|
vi.mock('../chat/agent-execution-shared', () => ({
|
|
resolveAgentProvider: (...args) => mockResolveProvider(...args),
|
|
buildAgentSystemPrompt: (...args) => mockBuildSystemPrompt(...args),
|
|
detectProvider: (...args) => mockDetectProvider(...args),
|
|
}));
|
|
|
|
const mockCreateAndDispatchJob = vi.fn();
|
|
|
|
vi.mock('../AgentJobService', () => ({
|
|
createAndDispatchJob: (...args) => mockCreateAndDispatchJob(...args),
|
|
}));
|
|
|
|
vi.mock('../ChainHandoffService', () => {
|
|
const STATE = {
|
|
BACKLOG: 24275,
|
|
ASSIGNED: 43436,
|
|
IN_PROGRESS: 24276,
|
|
REVIEW: 24277,
|
|
CONTROL: 43437,
|
|
REJECTED: 43438,
|
|
DONE: 24278,
|
|
};
|
|
const AGENT_USERS = {
|
|
ORCHESTRATOR: 18,
|
|
DEV_RALPH: 19,
|
|
DEVELOPER: 20,
|
|
FRONTEND: 21,
|
|
FRONTEND_QA: 22,
|
|
TEST_RUNNER: 23,
|
|
ARCHITECT: 24,
|
|
TABLE_ARCHITECT: 25,
|
|
WIDGET_DEVELOPER: 26,
|
|
DOCUMENT_AGENT: 28,
|
|
};
|
|
return {
|
|
default: {
|
|
updateTicketStatus: vi.fn().mockResolvedValue({}),
|
|
logActivity: vi.fn().mockResolvedValue(undefined),
|
|
getAgentName: (id) => {
|
|
const map = { 19: 'Developer Ralph', 20: 'Developer', 21: 'Frontend', 23: 'Test Runner', 24: 'Architect' };
|
|
return map[id] || `Agent-${id}`;
|
|
},
|
|
resolveAgentId: vi.fn(),
|
|
},
|
|
STATE,
|
|
AGENT_USERS,
|
|
};
|
|
});
|
|
|
|
vi.mock('../agent-worker/constants', () => ({
|
|
TICKETS_TABLE_ID: 1708,
|
|
AI_AGENTS_TABLE_ID: 1784,
|
|
SPACE_ID: 11,
|
|
POLL_INTERVAL_MS: 5000,
|
|
MAX_CONCURRENT: 3,
|
|
EXECUTION_TIMEOUT_MS: 1800000,
|
|
AGENT_USER_IDS: [18, 19, 20, 21, 22, 23, 24, 25, 26, 28],
|
|
AGENT_USER_TO_ROW: {
|
|
18: 31112, 19: 31113, 20: 33483, 21: 31114,
|
|
22: 33485, 23: 31115, 24: 33491, 25: 33487,
|
|
26: 33488, 28: 33489,
|
|
},
|
|
AGENT_SLUGS: ['orchestrator', 'architect', 'developer', 'developer-ralph', 'dev-ralph', 'frontend', 'frontend-qa', 'frontendqa', 'test-runner', 'test_runner', 'table-architect', 'widget-developer', 'document-agent'],
|
|
normalizeAgentId: (val) => {
|
|
if (typeof val === 'number') return val;
|
|
const asInt = parseInt(val, 10);
|
|
if (!isNaN(asInt) && String(asInt) === String(val)) return asInt;
|
|
return val;
|
|
},
|
|
}));
|
|
|
|
// Import after mocks
|
|
import { AgentWorkerService } from '../AgentWorkerService.js';
|
|
import { executeTicket, ensureTicketConversation, buildTicketUserMessage } from '../agent-worker/execution.js';
|
|
import ChainHandoffService, { STATE } from '../ChainHandoffService.js';
|
|
|
|
// ===== HELPERS =====
|
|
|
|
function makeTicketRow(id, data) {
|
|
return {
|
|
id,
|
|
data: JSON.stringify({
|
|
what: 'Test task',
|
|
why: 'Testing',
|
|
assigned_to: 19,
|
|
state: STATE.BACKLOG,
|
|
priority: 24274,
|
|
type: 24269,
|
|
...data,
|
|
}),
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
// ===== TESTS =====
|
|
|
|
describe('AgentWorkerService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
AgentWorkerService._activeJobs.clear();
|
|
AgentWorkerService._started = false;
|
|
if (AgentWorkerService._interval) {
|
|
clearInterval(AgentWorkerService._interval);
|
|
AgentWorkerService._interval = null;
|
|
}
|
|
// Default mock returns
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 100 });
|
|
mockDbGet.mockResolvedValue(null);
|
|
mockDbAll.mockResolvedValue([]);
|
|
mockAgentLoop.mockResolvedValue('Agent completed the task.');
|
|
mockResolveProvider.mockResolvedValue({ apiKey: 'test-key', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false });
|
|
mockDetectProvider.mockReturnValue({ isClaudeCode: false, isCopilot: false, isAnthropic: true });
|
|
mockBuildSystemPrompt.mockReturnValue('You are a helpful agent.');
|
|
mockSaveStepMessage.mockResolvedValue(1);
|
|
mockCreateAndDispatchJob.mockResolvedValue({ jobId: 'job-123', id: 1 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (AgentWorkerService._interval) {
|
|
clearInterval(AgentWorkerService._interval);
|
|
AgentWorkerService._interval = null;
|
|
}
|
|
AgentWorkerService._started = false;
|
|
});
|
|
|
|
// ----- findReadyTickets -----
|
|
|
|
describe('findReadyTickets()', () => {
|
|
it('should query tickets table for backlog/assigned state', async () => {
|
|
mockDbAll.mockResolvedValue([]);
|
|
await AgentWorkerService.findReadyTickets();
|
|
|
|
expect(mockDbAll).toHaveBeenCalledTimes(1);
|
|
const query = mockDbAll.mock.calls[0][0];
|
|
expect(query).toContain('table_id');
|
|
expect(query).toContain(String(STATE.BACKLOG));
|
|
expect(query).toContain(String(STATE.ASSIGNED));
|
|
});
|
|
|
|
it('should return ticket rows from the query', async () => {
|
|
const rows = [makeTicketRow(1, {}), makeTicketRow(2, {})];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const result = await AgentWorkerService.findReadyTickets();
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].id).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ----- ensureTicketConversation -----
|
|
|
|
describe('ensureTicketConversation()', () => {
|
|
it('should return existing conversation if one is bound to ticket', async () => {
|
|
mockDbGet.mockResolvedValue({ id: 42 });
|
|
|
|
const convId = await ensureTicketConversation(123, 19, { what: 'Test' });
|
|
expect(convId).toBe(42);
|
|
// Should NOT have inserted a new conversation
|
|
expect(mockDbRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should create new conversation if none exists', async () => {
|
|
mockDbGet.mockResolvedValue(null); // no existing conversation
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 99 });
|
|
|
|
const convId = await ensureTicketConversation(123, 19, { what: 'Build feature' });
|
|
expect(convId).toBe(99);
|
|
// Should have created a conversation
|
|
const insertCall = mockDbRun.mock.calls[0];
|
|
expect(insertCall[0]).toContain('INSERT INTO conversations');
|
|
expect(insertCall[0]).toContain('ticket_chat');
|
|
});
|
|
|
|
it('should add agent as participant after creating conversation', async () => {
|
|
mockDbGet.mockResolvedValue(null);
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 99 });
|
|
|
|
await ensureTicketConversation(123, 19, { what: 'Test' });
|
|
|
|
// Second dbRun call should be the participant insert
|
|
expect(mockDbRun).toHaveBeenCalledTimes(2);
|
|
const partCall = mockDbRun.mock.calls[1];
|
|
expect(partCall[0]).toContain('conversation_participants');
|
|
});
|
|
});
|
|
|
|
// ----- buildTicketUserMessage -----
|
|
|
|
describe('buildTicketUserMessage()', () => {
|
|
it('should include task title', () => {
|
|
const msg = buildTicketUserMessage({ what: 'Implement login' });
|
|
expect(msg).toContain('Implement login');
|
|
});
|
|
|
|
it('should include context when provided', () => {
|
|
const msg = buildTicketUserMessage({ what: 'Task', why: 'We need auth' });
|
|
expect(msg).toContain('We need auth');
|
|
expect(msg).toContain('Context');
|
|
});
|
|
|
|
it('should include acceptance criteria when provided', () => {
|
|
const msg = buildTicketUserMessage({
|
|
what: 'Task',
|
|
acceptance_criteria: '- [ ] Users can log in',
|
|
});
|
|
expect(msg).toContain('Acceptance Criteria');
|
|
expect(msg).toContain('Users can log in');
|
|
});
|
|
|
|
it('should include chain info when provided', () => {
|
|
const msg = buildTicketUserMessage({
|
|
what: 'Task',
|
|
_chain: { chain_id: 'chain-abc', step: 2, dispatched_by: 18 },
|
|
});
|
|
expect(msg).toContain('chain-abc');
|
|
expect(msg).toContain('Step: 2');
|
|
});
|
|
});
|
|
|
|
// ----- executeTicket -----
|
|
|
|
describe('executeTicket()', () => {
|
|
it('should transition ticket to in_progress', async () => {
|
|
const ticket = makeTicketRow(1, { assigned_to: 19 });
|
|
// Mock loadAgentConfig
|
|
mockDbGet.mockResolvedValueOnce(null) // findTicketConversation
|
|
.mockResolvedValueOnce({ data: JSON.stringify({ name: 'Developer Ralph', system_prompt: 'You are Ralph' }) }); // loadAgentConfig
|
|
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 50 });
|
|
|
|
await executeTicket(ticket, AgentWorkerService._activeJobs);
|
|
|
|
expect(ChainHandoffService.updateTicketStatus).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
ticket_id: 1,
|
|
new_state: STATE.IN_PROGRESS,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should create a bound conversation for the ticket', async () => {
|
|
const ticket = makeTicketRow(1, { assigned_to: 19 });
|
|
mockDbGet.mockResolvedValue(null); // no existing conversation
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 50 });
|
|
|
|
// Agent config lookup returns a result on the right call
|
|
mockDbGet.mockResolvedValueOnce(null) // findTicketConversation
|
|
.mockResolvedValueOnce({ data: JSON.stringify({ name: 'Developer Ralph' }) }); // loadAgentConfig (by row ID)
|
|
|
|
await executeTicket(ticket, AgentWorkerService._activeJobs);
|
|
|
|
// At least one INSERT INTO conversations
|
|
const insertCalls = mockDbRun.mock.calls.filter(c => c[0].includes('INSERT INTO conversations'));
|
|
expect(insertCalls.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('should call executeAgentToolLoop with correct params', async () => {
|
|
const ticket = makeTicketRow(1, { assigned_to: 19, what: 'Do the thing' });
|
|
mockDbGet.mockResolvedValueOnce(null) // findTicketConversation
|
|
.mockResolvedValueOnce({ data: JSON.stringify({ name: 'Developer Ralph', system_prompt: 'Be Ralph' }) }); // loadAgentConfig
|
|
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 50 });
|
|
|
|
await executeTicket(ticket, AgentWorkerService._activeJobs);
|
|
|
|
expect(mockAgentLoop).toHaveBeenCalledTimes(1);
|
|
const callParams = mockAgentLoop.mock.calls[0][0];
|
|
expect(callParams.conversationId).toBe(50);
|
|
expect(callParams.senderId).toBe(19);
|
|
expect(callParams.userMessage).toContain('Do the thing');
|
|
});
|
|
|
|
it('should transition ticket to review on success', async () => {
|
|
const ticket = makeTicketRow(1, { assigned_to: 19 });
|
|
mockDbGet.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ data: JSON.stringify({ name: 'Developer Ralph' }) });
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 50 });
|
|
|
|
await executeTicket(ticket, AgentWorkerService._activeJobs);
|
|
|
|
// Last updateTicketStatus call should transition to REVIEW
|
|
const reviewCall = ChainHandoffService.updateTicketStatus.mock.calls.find(
|
|
c => c[0].new_state === STATE.REVIEW
|
|
);
|
|
expect(reviewCall).toBeDefined();
|
|
});
|
|
|
|
it('should save error to conversation on failure', async () => {
|
|
const ticket = makeTicketRow(1, { assigned_to: 19 });
|
|
mockDbGet.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ data: JSON.stringify({ name: 'Developer Ralph' }) });
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 50 });
|
|
mockAgentLoop.mockRejectedValue(new Error('API key expired'));
|
|
|
|
// findTicketConversation for error handling — returns the conversation
|
|
mockDbGet.mockResolvedValueOnce({ id: 50 });
|
|
|
|
await executeTicket(ticket, AgentWorkerService._activeJobs);
|
|
|
|
// Should have saved an error message
|
|
const errorSave = mockSaveStepMessage.mock.calls.find(
|
|
c => c[1]?.content?.includes('error')
|
|
);
|
|
expect(errorSave).toBeDefined();
|
|
});
|
|
|
|
it('should dispatch async job for claude-code agents instead of executeAgentToolLoop', async () => {
|
|
const ticket = makeTicketRow(1, { assigned_to: 19, what: 'Build table' });
|
|
mockDbGet.mockResolvedValueOnce(null) // findTicketConversation
|
|
.mockResolvedValueOnce({ data: JSON.stringify({ name: 'Table Architect', system_prompt: 'You build tables' }) }); // loadAgentConfig
|
|
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 50 });
|
|
// Override provider to claude-code
|
|
mockResolveProvider.mockResolvedValue({ apiKey: null, model: 'claude-sonnet-4', provider: 'claude-code', isLocal: true });
|
|
mockDetectProvider.mockReturnValue({ isClaudeCode: true, isCopilot: false, isAnthropic: false });
|
|
|
|
await executeTicket(ticket, AgentWorkerService._activeJobs);
|
|
|
|
// Should NOT have called executeAgentToolLoop
|
|
expect(mockAgentLoop).not.toHaveBeenCalled();
|
|
// Should have called createAndDispatchJob
|
|
expect(mockCreateAndDispatchJob).toHaveBeenCalledTimes(1);
|
|
const jobArgs = mockCreateAndDispatchJob.mock.calls[0][0];
|
|
expect(jobArgs.conversationId).toBe(50);
|
|
expect(jobArgs.agent.name).toBe('Developer Ralph'); // from ChainHandoffService.getAgentName(19)
|
|
expect(jobArgs.messageContent).toContain('Build table');
|
|
});
|
|
|
|
it('should still call executeAgentToolLoop for non-claude-code agents', async () => {
|
|
const ticket = makeTicketRow(1, { assigned_to: 19, what: 'Do the thing' });
|
|
mockDbGet.mockResolvedValueOnce(null) // findTicketConversation
|
|
.mockResolvedValueOnce({ data: JSON.stringify({ name: 'Developer Ralph', system_prompt: 'Be Ralph' }) }); // loadAgentConfig
|
|
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 50 });
|
|
// Ensure provider is anthropic (non-claude-code)
|
|
mockResolveProvider.mockResolvedValue({ apiKey: 'test-key', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false });
|
|
mockDetectProvider.mockReturnValue({ isClaudeCode: false, isCopilot: false, isAnthropic: true });
|
|
|
|
await executeTicket(ticket, AgentWorkerService._activeJobs);
|
|
|
|
// Should have called executeAgentToolLoop
|
|
expect(mockAgentLoop).toHaveBeenCalledTimes(1);
|
|
// Should NOT have called createAndDispatchJob
|
|
expect(mockCreateAndDispatchJob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should stay in_progress after async claude-code dispatch (no REVIEW transition)', async () => {
|
|
const ticket = makeTicketRow(1, { assigned_to: 19 });
|
|
mockDbGet.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ data: JSON.stringify({ name: 'Table Architect' }) });
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 50 });
|
|
mockResolveProvider.mockResolvedValue({ apiKey: null, model: 'claude-sonnet-4', provider: 'claude-code', isLocal: true });
|
|
mockDetectProvider.mockReturnValue({ isClaudeCode: true, isCopilot: false, isAnthropic: false });
|
|
|
|
await executeTicket(ticket, AgentWorkerService._activeJobs);
|
|
|
|
// Claude-code path returns early — should NOT transition to REVIEW
|
|
const reviewCall = ChainHandoffService.updateTicketStatus.mock.calls.find(
|
|
c => c[0].new_state === STATE.REVIEW
|
|
);
|
|
expect(reviewCall).toBeUndefined();
|
|
});
|
|
|
|
it('should remove ticket from activeJobs after completion', async () => {
|
|
const ticket = makeTicketRow(1, { assigned_to: 19 });
|
|
mockDbGet.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce({ data: JSON.stringify({ name: 'Developer Ralph' }) });
|
|
mockDbRun.mockResolvedValue({ lastInsertRowid: 50 });
|
|
|
|
AgentWorkerService._activeJobs.set(1, { agentId: 19, startedAt: Date.now() });
|
|
await executeTicket(ticket, AgentWorkerService._activeJobs);
|
|
|
|
expect(AgentWorkerService._activeJobs.has(1)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ----- poll -----
|
|
|
|
describe('poll()', () => {
|
|
it('should skip polling when MAX_CONCURRENT reached', async () => {
|
|
// Fill up active jobs
|
|
for (let i = 0; i < 3; i++) {
|
|
AgentWorkerService._activeJobs.set(i, {
|
|
agentId: 19,
|
|
startedAt: Date.now(),
|
|
promise: Promise.resolve(),
|
|
});
|
|
}
|
|
|
|
await AgentWorkerService.poll();
|
|
// Should NOT have queried for tickets
|
|
expect(mockDbAll).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not re-execute tickets already in activeJobs', async () => {
|
|
const tickets = [makeTicketRow(1, { assigned_to: 19 })];
|
|
mockDbAll.mockResolvedValue(tickets);
|
|
AgentWorkerService._activeJobs.set(1, {
|
|
agentId: 19,
|
|
startedAt: Date.now(),
|
|
promise: Promise.resolve(),
|
|
});
|
|
|
|
await AgentWorkerService.poll();
|
|
// Should have queried but NOT executed ticket 1
|
|
expect(mockDbAll).toHaveBeenCalled();
|
|
// activeJobs should still just have the one entry
|
|
expect(AgentWorkerService._activeJobs.size).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ----- getStatus -----
|
|
|
|
describe('getStatus()', () => {
|
|
it('should return worker status with config', () => {
|
|
const status = AgentWorkerService.getStatus();
|
|
expect(status.started).toBe(false);
|
|
expect(status.config).toHaveProperty('poll_interval_ms');
|
|
expect(status.config).toHaveProperty('max_concurrent');
|
|
expect(status.active_count).toBe(0);
|
|
});
|
|
|
|
it('should include active jobs info', () => {
|
|
AgentWorkerService._activeJobs.set(42, {
|
|
agentId: 19,
|
|
startedAt: Date.now(),
|
|
promise: Promise.resolve(),
|
|
});
|
|
|
|
const status = AgentWorkerService.getStatus();
|
|
expect(status.active_count).toBe(1);
|
|
expect(status.active_jobs[0].ticket_id).toBe(42);
|
|
expect(status.active_jobs[0].agent_name).toBe('Developer Ralph');
|
|
});
|
|
});
|
|
|
|
// ----- start / stop -----
|
|
|
|
describe('lifecycle', () => {
|
|
it('should set _started flag on start()', async () => {
|
|
await AgentWorkerService.start();
|
|
expect(AgentWorkerService._started).toBe(true);
|
|
expect(AgentWorkerService._interval).not.toBeNull();
|
|
});
|
|
|
|
it('should ignore duplicate start() calls', async () => {
|
|
await AgentWorkerService.start();
|
|
const interval1 = AgentWorkerService._interval;
|
|
await AgentWorkerService.start(); // duplicate
|
|
expect(AgentWorkerService._interval).toBe(interval1);
|
|
});
|
|
|
|
it('should clear interval on stop()', async () => {
|
|
await AgentWorkerService.start();
|
|
await AgentWorkerService.stop();
|
|
expect(AgentWorkerService._started).toBe(false);
|
|
expect(AgentWorkerService._interval).toBeNull();
|
|
});
|
|
});
|
|
});
|