godcrm/backend/services/__tests__/AgentWorkerService.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

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();
});
});
});