This commit is contained in:
352
packages/worker/tests/execution/strategies.test.ts
Normal file
352
packages/worker/tests/execution/strategies.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { executeParallel, type AgentMemberConfig, type ExecutionDeps } from '../../src/execution/parallel.js';
|
||||
import { executeSequential } from '../../src/execution/sequential.js';
|
||||
import { executeCoordinator } from '../../src/execution/coordinator.js';
|
||||
|
||||
const mockMembers: AgentMemberConfig[] = [
|
||||
{
|
||||
member: { roleInGroup: 'lead', executionOrder: 0 },
|
||||
agent: { id: 'a1', name: 'leader', model: 'claude-sonnet', systemPrompt: 'You are a leader.', tools: ['search_memory'] },
|
||||
},
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 1 },
|
||||
agent: { id: 'a2', name: 'researcher', model: 'claude-haiku', systemPrompt: 'You are a researcher.', tools: ['web_search'] },
|
||||
},
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 2 },
|
||||
agent: { id: 'a3', name: 'writer', model: 'claude-haiku', systemPrompt: 'You are a writer.', tools: ['write_file'] },
|
||||
},
|
||||
];
|
||||
|
||||
// ── Mock ExecutionDeps ─────────────────────────────────────────────────
|
||||
|
||||
function createMockDeps(overrides?: Partial<ExecutionDeps>): ExecutionDeps {
|
||||
return {
|
||||
runAgent: vi.fn(async (config) => ({
|
||||
content: `Output from agent with prompt: ${config.systemPrompt.slice(0, 30)}...`,
|
||||
toolsUsed: ['mock_tool'],
|
||||
usage: { inputTokens: 100, outputTokens: 50 },
|
||||
})),
|
||||
resolveTools: vi.fn((names) => names.map(n => ({
|
||||
name: n,
|
||||
description: `Mock ${n}`,
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute: async () => 'ok',
|
||||
}))),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Stub mode (backward compat — no deps passed)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Execution Strategies (stub mode)', () => {
|
||||
describe('parallel', () => {
|
||||
it('runs all agents and merges output', async () => {
|
||||
const result = await executeParallel(mockMembers, { task: 'test' });
|
||||
|
||||
expect(result.strategy).toBe('parallel');
|
||||
expect(result.agentCount).toBe(3);
|
||||
expect(result.results).toHaveLength(3);
|
||||
expect(result.mergedOutput).toContain('leader');
|
||||
expect(result.mergedOutput).toContain('researcher');
|
||||
expect(result.mergedOutput).toContain('writer');
|
||||
});
|
||||
|
||||
it('includes agent metadata in each result', async () => {
|
||||
const result = await executeParallel(mockMembers, { task: 'test' });
|
||||
const results = result.results as Array<Record<string, unknown>>;
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
agentId: 'a1',
|
||||
agentName: 'leader',
|
||||
model: 'claude-sonnet',
|
||||
role: 'lead',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles single agent', async () => {
|
||||
const single = [mockMembers[0]];
|
||||
const result = await executeParallel(single, {});
|
||||
|
||||
expect(result.agentCount).toBe(1);
|
||||
expect(result.results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequential', () => {
|
||||
it('chains output through agents in order', async () => {
|
||||
const result = await executeSequential(mockMembers, { task: 'test' });
|
||||
|
||||
expect(result.strategy).toBe('sequential');
|
||||
expect(result.agentCount).toBe(3);
|
||||
expect(result.results).toHaveLength(3);
|
||||
expect(result.finalOutput).toContain('writer');
|
||||
});
|
||||
|
||||
it('first agent receives taskInput, subsequent receive previous output', async () => {
|
||||
const result = await executeSequential(mockMembers, { task: 'test' });
|
||||
const results = result.results as Array<Record<string, unknown>>;
|
||||
|
||||
expect(results[0].inputFrom).toBe('taskInput');
|
||||
expect(results[1].inputFrom).toBe('leader');
|
||||
expect(results[2].inputFrom).toBe('researcher');
|
||||
});
|
||||
|
||||
it('handles single agent', async () => {
|
||||
const single = [mockMembers[0]];
|
||||
const result = await executeSequential(single, { data: 'input' });
|
||||
|
||||
expect(result.agentCount).toBe(1);
|
||||
expect(result.finalOutput).toContain('leader');
|
||||
});
|
||||
|
||||
it('handles empty members list', async () => {
|
||||
const result = await executeSequential([], {});
|
||||
|
||||
expect(result.agentCount).toBe(0);
|
||||
expect(result.results).toHaveLength(0);
|
||||
expect(result.finalOutput).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('coordinator', () => {
|
||||
it('lead delegates, workers execute, lead synthesizes', async () => {
|
||||
const result = await executeCoordinator(mockMembers, { task: 'test' });
|
||||
|
||||
expect(result.strategy).toBe('coordinator');
|
||||
expect(result.leadAgent).toBe('leader');
|
||||
expect(result.workerCount).toBe(2);
|
||||
expect(result.plan).toBeDefined();
|
||||
expect(result.workerResults).toHaveLength(2);
|
||||
expect(result.synthesis).toBeDefined();
|
||||
expect(result.finalOutput).toContain('synthesized');
|
||||
});
|
||||
|
||||
it('only workers appear in workerResults (not lead)', async () => {
|
||||
const result = await executeCoordinator(mockMembers, {});
|
||||
const workerResults = result.workerResults as Array<Record<string, unknown>>;
|
||||
|
||||
const agentNames = workerResults.map(r => r.agentName);
|
||||
expect(agentNames).toContain('researcher');
|
||||
expect(agentNames).toContain('writer');
|
||||
expect(agentNames).not.toContain('leader');
|
||||
});
|
||||
|
||||
it('plan includes subtask assignments', async () => {
|
||||
const result = await executeCoordinator(mockMembers, {});
|
||||
const plan = result.plan as Record<string, unknown>;
|
||||
|
||||
expect(plan.phase).toBe('planning');
|
||||
expect(plan.subtasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('synthesis references worker count', async () => {
|
||||
const result = await executeCoordinator(mockMembers, {});
|
||||
const synthesis = result.synthesis as Record<string, unknown>;
|
||||
|
||||
expect(synthesis.phase).toBe('synthesis');
|
||||
expect(synthesis.output).toContain('2');
|
||||
});
|
||||
|
||||
it('throws if no lead agent', async () => {
|
||||
const noLead = mockMembers.map(m => ({
|
||||
...m,
|
||||
member: { ...m.member, roleInGroup: 'worker' },
|
||||
}));
|
||||
|
||||
await expect(executeCoordinator(noLead, {})).rejects.toThrow('requires a lead');
|
||||
});
|
||||
|
||||
it('works with lead and no workers', async () => {
|
||||
const leadOnly: AgentMemberConfig[] = [{
|
||||
member: { roleInGroup: 'lead', executionOrder: 0 },
|
||||
agent: { id: 'a1', name: 'solo-lead', model: 'claude-sonnet', systemPrompt: null, tools: [] },
|
||||
}];
|
||||
|
||||
const result = await executeCoordinator(leadOnly, {});
|
||||
|
||||
expect(result.workerCount).toBe(0);
|
||||
expect(result.workerResults).toHaveLength(0);
|
||||
expect(result.finalOutput).toContain('synthesized 0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Real execution (with mocked runAgent via ExecutionDeps)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Execution Strategies (real execution)', () => {
|
||||
describe('parallel with deps', () => {
|
||||
it('runs all agents concurrently via runAgent', async () => {
|
||||
const deps = createMockDeps();
|
||||
const result = await executeParallel(mockMembers, { task: 'analyze data' }, deps);
|
||||
|
||||
expect(result.strategy).toBe('parallel');
|
||||
expect(result.agentCount).toBe(3);
|
||||
expect(deps.runAgent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('each agent gets its own systemPrompt and resolved tools', async () => {
|
||||
const deps = createMockDeps();
|
||||
await executeParallel(mockMembers, { task: 'analyze' }, deps);
|
||||
|
||||
// Check that resolveTools was called with each agent's tool list
|
||||
expect(deps.resolveTools).toHaveBeenCalledWith(['search_memory']);
|
||||
expect(deps.resolveTools).toHaveBeenCalledWith(['web_search']);
|
||||
expect(deps.resolveTools).toHaveBeenCalledWith(['write_file']);
|
||||
});
|
||||
|
||||
it('captures output, toolsUsed, and usage from each agent', async () => {
|
||||
const deps = createMockDeps();
|
||||
const result = await executeParallel(mockMembers, { task: 'test' }, deps);
|
||||
const results = result.results as Array<Record<string, unknown>>;
|
||||
|
||||
expect(results[0].toolsUsed).toEqual(['mock_tool']);
|
||||
expect(results[0].usage).toEqual({ inputTokens: 100, outputTokens: 50 });
|
||||
expect(typeof results[0].output).toBe('string');
|
||||
expect((results[0].output as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles one agent failing (others still complete)', async () => {
|
||||
const deps = createMockDeps({
|
||||
runAgent: vi.fn(async (config) => {
|
||||
if (config.systemPrompt.includes('researcher')) throw new Error('API timeout');
|
||||
return { content: 'OK', toolsUsed: [], usage: { inputTokens: 10, outputTokens: 5 } };
|
||||
}),
|
||||
});
|
||||
const result = await executeParallel(mockMembers, { task: 'test' }, deps);
|
||||
|
||||
// All 3 agents attempted
|
||||
expect(result.agentCount).toBe(3);
|
||||
// Error captured, not thrown
|
||||
const errors = result.errors as Array<Record<string, unknown>>;
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].agent).toBe('researcher');
|
||||
expect(errors[0].error).toBe('API timeout');
|
||||
// Other agents still succeeded
|
||||
expect(result.mergedOutput).toContain('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequential with deps', () => {
|
||||
it('runs agents in order, each receives previous output as context', async () => {
|
||||
let callOrder = 0;
|
||||
const deps = createMockDeps({
|
||||
runAgent: vi.fn(async (config) => {
|
||||
callOrder++;
|
||||
return {
|
||||
content: `Step ${callOrder} result`,
|
||||
toolsUsed: [],
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await executeSequential(mockMembers, { task: 'research then write' }, deps);
|
||||
|
||||
expect(result.strategy).toBe('sequential');
|
||||
expect(deps.runAgent).toHaveBeenCalledTimes(3);
|
||||
// Final output comes from last agent
|
||||
expect(result.finalOutput).toBe('Step 3 result');
|
||||
});
|
||||
|
||||
it('second agent receives first agents output in systemPrompt', async () => {
|
||||
const deps = createMockDeps({
|
||||
runAgent: vi.fn(async (config) => ({
|
||||
content: `Output: ${config.systemPrompt.includes('Previous Agent') ? 'chained' : 'first'}`,
|
||||
toolsUsed: [],
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
})),
|
||||
});
|
||||
|
||||
await executeSequential(mockMembers, { task: 'pipeline' }, deps);
|
||||
|
||||
// Second call should have "Previous Agent's Output" in the system prompt
|
||||
const calls = vi.mocked(deps.runAgent).mock.calls;
|
||||
expect(calls[0][0].systemPrompt).not.toContain('Previous Agent');
|
||||
expect(calls[1][0].systemPrompt).toContain('Previous Agent');
|
||||
expect(calls[2][0].systemPrompt).toContain('Previous Agent');
|
||||
});
|
||||
|
||||
it('error in middle agent stops the chain', async () => {
|
||||
const deps = createMockDeps({
|
||||
runAgent: vi.fn(async (config) => {
|
||||
if (config.systemPrompt.includes('researcher')) throw new Error('Crash');
|
||||
return { content: 'OK', toolsUsed: [], usage: { inputTokens: 10, outputTokens: 5 } };
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await executeSequential(mockMembers, { task: 'test' }, deps);
|
||||
|
||||
// Chain stopped at researcher (agent 2), writer (agent 3) never ran
|
||||
expect(result.agentCount).toBe(2); // leader + researcher (failed)
|
||||
expect(result.chainBroken).toBe(true);
|
||||
expect(deps.runAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coordinator with deps', () => {
|
||||
it('runs 3-phase pattern: plan → execute → synthesize', async () => {
|
||||
const deps = createMockDeps();
|
||||
const result = await executeCoordinator(mockMembers, { task: 'complex task' }, deps);
|
||||
|
||||
expect(result.strategy).toBe('coordinator');
|
||||
// Lead calls: 1 planning + 1 synthesis = 2
|
||||
// Workers: 2 parallel calls
|
||||
// Total: 4
|
||||
expect(deps.runAgent).toHaveBeenCalledTimes(4);
|
||||
|
||||
const plan = result.plan as Record<string, unknown>;
|
||||
expect(plan.phase).toBe('planning');
|
||||
expect(typeof plan.output).toBe('string');
|
||||
|
||||
const synthesis = result.synthesis as Record<string, unknown>;
|
||||
expect(synthesis.phase).toBe('synthesis');
|
||||
});
|
||||
|
||||
it('workers receive the plan as context', async () => {
|
||||
const deps = createMockDeps({
|
||||
runAgent: vi.fn(async (config) => ({
|
||||
content: config.systemPrompt.includes('Coordinator') ? 'Worker got plan' : 'Plan created',
|
||||
toolsUsed: [],
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
})),
|
||||
});
|
||||
|
||||
const result = await executeCoordinator(mockMembers, { task: 'test' }, deps);
|
||||
const workerResults = result.workerResults as Array<Record<string, unknown>>;
|
||||
|
||||
// Workers should have received the plan in their systemPrompt
|
||||
expect(workerResults[0].output).toBe('Worker got plan');
|
||||
expect(workerResults[1].output).toBe('Worker got plan');
|
||||
});
|
||||
|
||||
it('handles zero workers (lead does everything)', async () => {
|
||||
const leadOnly: AgentMemberConfig[] = [{
|
||||
member: { roleInGroup: 'lead', executionOrder: 0 },
|
||||
agent: { id: 'a1', name: 'solo', model: 'claude-sonnet', systemPrompt: 'Solo lead', tools: [] },
|
||||
}];
|
||||
|
||||
const deps = createMockDeps();
|
||||
const result = await executeCoordinator(leadOnly, { task: 'solo task' }, deps);
|
||||
|
||||
expect(result.workerCount).toBe(0);
|
||||
expect(result.workerResults).toHaveLength(0);
|
||||
// Lead still runs plan + synthesis = 2 calls
|
||||
expect(deps.runAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('still throws if no lead (even with deps)', async () => {
|
||||
const noLead = mockMembers.map(m => ({
|
||||
...m,
|
||||
member: { ...m.member, roleInGroup: 'worker' },
|
||||
}));
|
||||
const deps = createMockDeps();
|
||||
|
||||
await expect(executeCoordinator(noLead, {}, deps)).rejects.toThrow('requires a lead');
|
||||
});
|
||||
});
|
||||
});
|
||||
126
packages/worker/tests/handlers/chat-handler.test.ts
Normal file
126
packages/worker/tests/handlers/chat-handler.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Job } from 'bullmq';
|
||||
import type { Db } from '../../../server/src/db/connection.js';
|
||||
import type { JobData } from '../../src/job-processor.js';
|
||||
|
||||
/** Build a minimal Job<JobData> mock — only `.data` is exercised by handlers. */
|
||||
function makeJob(data: JobData): Job<JobData> {
|
||||
return { data } as unknown as Job<JobData>;
|
||||
}
|
||||
|
||||
vi.mock('@waggle/agent', () => ({
|
||||
runAgentLoop: vi.fn(async () => ({
|
||||
content: 'Agent response here',
|
||||
toolsUsed: ['search_memory'],
|
||||
usage: { inputTokens: 100, outputTokens: 50 },
|
||||
})),
|
||||
createSystemTools: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
import { chatHandler } from '../../src/handlers/chat-handler.js';
|
||||
import { runAgentLoop, createSystemTools } from '@waggle/agent';
|
||||
|
||||
describe('Chat Handler', () => {
|
||||
const mockDb = {} as Db;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls runAgentLoop and returns response with correct shape', async () => {
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j1',
|
||||
teamId: 't1',
|
||||
userId: 'u1',
|
||||
jobType: 'chat',
|
||||
input: { message: 'hello world' },
|
||||
});
|
||||
|
||||
const result = await chatHandler(mockJob, mockDb);
|
||||
|
||||
expect(result.response).toBe('Agent response here');
|
||||
expect(result.userId).toBe('u1');
|
||||
expect(result.model).toBe('claude-sonnet');
|
||||
expect(result.toolsUsed).toEqual(['search_memory']);
|
||||
expect(result.tokensUsed).toBe(150);
|
||||
});
|
||||
|
||||
it('passes correct config to runAgentLoop', async () => {
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j2',
|
||||
teamId: 't1',
|
||||
userId: 'u1',
|
||||
jobType: 'chat',
|
||||
input: { message: 'test message', model: 'gpt-4o' },
|
||||
});
|
||||
|
||||
await chatHandler(mockJob, mockDb);
|
||||
|
||||
expect(runAgentLoop).toHaveBeenCalledOnce();
|
||||
const callArgs = vi.mocked(runAgentLoop).mock.calls[0][0];
|
||||
expect(callArgs.model).toBe('gpt-4o');
|
||||
expect(callArgs.messages).toEqual([{ role: 'user', content: 'test message' }]);
|
||||
expect(callArgs.systemPrompt).toContain('Waggle AI agent');
|
||||
expect(callArgs.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates system tools with workspaceDir from input', async () => {
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j3',
|
||||
teamId: 't1',
|
||||
userId: 'u1',
|
||||
jobType: 'chat',
|
||||
input: { message: 'test', workspaceDir: '/custom/workspace' },
|
||||
});
|
||||
|
||||
await chatHandler(mockJob, mockDb);
|
||||
|
||||
expect(createSystemTools).toHaveBeenCalledWith('/custom/workspace');
|
||||
});
|
||||
|
||||
it('uses default model when not specified in input', async () => {
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j4',
|
||||
teamId: 't1',
|
||||
userId: 'u1',
|
||||
jobType: 'chat',
|
||||
input: { message: 'hi' },
|
||||
});
|
||||
|
||||
await chatHandler(mockJob, mockDb);
|
||||
|
||||
const callArgs = vi.mocked(runAgentLoop).mock.calls[0][0];
|
||||
expect(callArgs.model).toBe('claude-sonnet');
|
||||
});
|
||||
|
||||
it('handles missing message gracefully', async () => {
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j5',
|
||||
teamId: 't1',
|
||||
userId: 'u1',
|
||||
jobType: 'chat',
|
||||
input: {},
|
||||
});
|
||||
|
||||
const result = await chatHandler(mockJob, mockDb);
|
||||
|
||||
expect(result.response).toBe('Agent response here');
|
||||
expect(result.userId).toBe('u1');
|
||||
const callArgs = vi.mocked(runAgentLoop).mock.calls[0][0];
|
||||
expect(callArgs.messages).toEqual([{ role: 'user', content: '' }]);
|
||||
});
|
||||
|
||||
it('includes userId in response', async () => {
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j6',
|
||||
teamId: 't1',
|
||||
userId: 'user-abc',
|
||||
jobType: 'chat',
|
||||
input: { message: 'test' },
|
||||
});
|
||||
|
||||
const result = await chatHandler(mockJob, mockDb);
|
||||
|
||||
expect(result.userId).toBe('user-abc');
|
||||
});
|
||||
});
|
||||
66
packages/worker/tests/handlers/cron-handler.test.ts
Normal file
66
packages/worker/tests/handlers/cron-handler.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Job } from 'bullmq';
|
||||
import type { Db } from '../../../server/src/db/connection.js';
|
||||
import type { JobData } from '../../src/job-processor.js';
|
||||
import { createCronHandler } from '../../src/handlers/cron-handler.js';
|
||||
|
||||
function makeJob(input: Record<string, unknown>): Job<JobData> {
|
||||
return {
|
||||
data: {
|
||||
jobId: 'cron-job-1',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'cron',
|
||||
input,
|
||||
},
|
||||
} as unknown as Job<JobData>;
|
||||
}
|
||||
|
||||
describe('Cron handler', () => {
|
||||
const db = {} as Db;
|
||||
|
||||
it('delegates a scheduled job to a real worker handler', async () => {
|
||||
const dispatch = vi.fn(async (job: Job<JobData>) => ({
|
||||
response: `ran ${job.data.jobType}`,
|
||||
input: job.data.input,
|
||||
}));
|
||||
const handler = createCronHandler(dispatch);
|
||||
|
||||
const result = await handler(makeJob({
|
||||
jobType: 'chat',
|
||||
jobConfig: { message: 'send the daily brief' },
|
||||
}), db);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledOnce();
|
||||
const delegatedJob = dispatch.mock.calls[0][0];
|
||||
expect(delegatedJob.data.jobType).toBe('chat');
|
||||
expect(delegatedJob.data.input).toEqual({ message: 'send the daily brief' });
|
||||
expect(result).toEqual({
|
||||
response: 'ran chat',
|
||||
input: { message: 'send the daily brief' },
|
||||
cron: { delegatedJobType: 'chat' },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts input as the delegated payload for queue clients', async () => {
|
||||
const dispatch = vi.fn(async () => ({ ok: true }));
|
||||
const handler = createCronHandler(dispatch);
|
||||
|
||||
await handler(makeJob({
|
||||
jobType: 'task',
|
||||
input: { taskId: 'task-1' },
|
||||
}), db);
|
||||
|
||||
expect(dispatch.mock.calls[0][0].data.input).toEqual({ taskId: 'task-1' });
|
||||
});
|
||||
|
||||
it('rejects missing, unsupported, and malformed delegation targets', async () => {
|
||||
const handler = createCronHandler(vi.fn(async () => ({ ok: true })));
|
||||
|
||||
await expect(handler(makeJob({}), db)).rejects.toThrow('requires input.jobType');
|
||||
await expect(handler(makeJob({ jobType: 'cron', jobConfig: {} }), db))
|
||||
.rejects.toThrow('cannot delegate to "cron"');
|
||||
await expect(handler(makeJob({ jobType: 'chat' }), db))
|
||||
.rejects.toThrow('requires input.jobConfig or input.input');
|
||||
});
|
||||
});
|
||||
340
packages/worker/tests/handlers/handlers.test.ts
Normal file
340
packages/worker/tests/handlers/handlers.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Job } from 'bullmq';
|
||||
import type { Db } from '../../../server/src/db/connection.js';
|
||||
import type { JobData } from '../../src/job-processor.js';
|
||||
|
||||
/** Build a minimal Job<JobData> mock — only `.data` is exercised by handlers. */
|
||||
function makeJob(data: JobData): Job<JobData> {
|
||||
return { data } as unknown as Job<JobData>;
|
||||
}
|
||||
|
||||
// ── Mock @waggle/agent before importing handlers ────────────────────────
|
||||
vi.mock('@waggle/agent', () => ({
|
||||
runAgentLoop: vi.fn(async () => ({
|
||||
content: 'Task completed successfully',
|
||||
toolsUsed: ['bash', 'read_file'],
|
||||
usage: { inputTokens: 200, outputTokens: 100 },
|
||||
})),
|
||||
createSystemTools: vi.fn(() => [
|
||||
{ name: 'bash', description: 'Run shell commands', parameters: { type: 'object', properties: {} }, execute: async () => 'ok' },
|
||||
{ name: 'read_file', description: 'Read a file', parameters: { type: 'object', properties: {} }, execute: async () => 'ok' },
|
||||
]),
|
||||
}));
|
||||
|
||||
// ── Mock drizzle-orm ────────────────────────────────────────────────────
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((col: unknown, val: unknown) => ({ column: col, value: val })),
|
||||
}));
|
||||
|
||||
// ── Mock server DB schema ───────────────────────────────────────────────
|
||||
vi.mock('../../../server/src/db/schema.js', () => ({
|
||||
tasks: {
|
||||
id: 'tasks.id',
|
||||
teamId: 'tasks.teamId',
|
||||
status: 'tasks.status',
|
||||
assignedTo: 'tasks.assignedTo',
|
||||
updatedAt: 'tasks.updatedAt',
|
||||
title: 'tasks.title',
|
||||
description: 'tasks.description',
|
||||
priority: 'tasks.priority',
|
||||
},
|
||||
agentGroups: { id: 'agent_groups.id' },
|
||||
agentGroupMembers: {
|
||||
groupId: 'agent_group_members.groupId',
|
||||
agentId: 'agent_group_members.agentId',
|
||||
executionOrder: 'agent_group_members.executionOrder',
|
||||
},
|
||||
agents: { id: 'agents.id' },
|
||||
}));
|
||||
|
||||
import { taskHandler } from '../../src/handlers/task-handler.js';
|
||||
import { groupHandler } from '../../src/handlers/group-handler.js';
|
||||
import { runAgentLoop } from '@waggle/agent';
|
||||
|
||||
// ── Helper: mock DB ─────────────────────────────────────────────────────
|
||||
|
||||
function createMockDb(overrides?: {
|
||||
selectResult?: unknown[];
|
||||
selectResultSecond?: unknown[];
|
||||
}): Db {
|
||||
const selectResult = overrides?.selectResult ?? [];
|
||||
const hasSecondResult = overrides?.selectResultSecond !== undefined;
|
||||
const selectResultSecond = overrides?.selectResultSecond ?? [];
|
||||
let selectCallCount = 0;
|
||||
|
||||
const mockChain = (results: unknown[]) => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(results),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(results),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
select: vi.fn(() => {
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) return mockChain(selectResult);
|
||||
return mockChain(hasSecondResult ? selectResultSecond : selectResult);
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Task Handler Tests (11F-7)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Task Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('executes a task job and returns results', async () => {
|
||||
const mockTask = {
|
||||
id: 'task-1',
|
||||
title: 'Fix login bug',
|
||||
description: 'Users cannot log in with SSO',
|
||||
teamId: 'team-1',
|
||||
priority: 'high',
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockTask] });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j1',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: { taskId: 'task-1' },
|
||||
});
|
||||
|
||||
const result = await taskHandler(mockJob, mockDb);
|
||||
|
||||
expect(result.taskId).toBe('task-1');
|
||||
expect(result.taskTitle).toBe('Fix login bug');
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.result).toBe('Task completed successfully');
|
||||
expect(result.toolsUsed).toEqual(['bash', 'read_file']);
|
||||
expect(result.tokensUsed).toBe(300); // 200 + 100
|
||||
expect(result.userId).toBe('user-1');
|
||||
|
||||
// Verify runAgentLoop was called
|
||||
expect(runAgentLoop).toHaveBeenCalledOnce();
|
||||
|
||||
// Verify DB was updated (in-progress then completed)
|
||||
expect(mockDb.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws error and resets task to open on agent failure', async () => {
|
||||
const mockTask = {
|
||||
id: 'task-2',
|
||||
title: 'Deploy service',
|
||||
description: null,
|
||||
teamId: 'team-1',
|
||||
priority: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockTask] });
|
||||
vi.mocked(runAgentLoop).mockRejectedValueOnce(new Error('LLM API timeout'));
|
||||
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j2',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: { taskId: 'task-2' },
|
||||
});
|
||||
|
||||
await expect(taskHandler(mockJob, mockDb)).rejects.toThrow('LLM API timeout');
|
||||
|
||||
// Verify DB was updated: first to in_progress, then back to open on error
|
||||
expect(mockDb.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws if taskId is missing from input', async () => {
|
||||
const mockDb = createMockDb();
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j3',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: {}, // no taskId
|
||||
});
|
||||
|
||||
await expect(taskHandler(mockJob, mockDb)).rejects.toThrow('taskHandler requires input.taskId');
|
||||
});
|
||||
|
||||
it('throws if task is not found in DB', async () => {
|
||||
const mockDb = createMockDb({ selectResult: [] }); // no task found
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j4',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: { taskId: 'nonexistent-task' },
|
||||
});
|
||||
|
||||
await expect(taskHandler(mockJob, mockDb)).rejects.toThrow('Task not found: nonexistent-task');
|
||||
});
|
||||
|
||||
it('throws if task belongs to a different team', async () => {
|
||||
const mockTask = {
|
||||
id: 'task-5',
|
||||
title: 'Other team task',
|
||||
description: null,
|
||||
teamId: 'team-other', // different team
|
||||
priority: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockTask] });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j5',
|
||||
teamId: 'team-1', // requesting team doesn't match
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: { taskId: 'task-5' },
|
||||
});
|
||||
|
||||
await expect(taskHandler(mockJob, mockDb)).rejects.toThrow(
|
||||
'Task task-5 does not belong to team team-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Group Handler Tests (11F-7)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Group Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws if groupId is missing from input', async () => {
|
||||
const mockDb = createMockDb();
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j1',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: {}, // no groupId
|
||||
});
|
||||
|
||||
await expect(groupHandler(mockJob, mockDb)).rejects.toThrow('groupHandler requires input.groupId');
|
||||
});
|
||||
|
||||
it('throws if group is not found in DB', async () => {
|
||||
const mockDb = createMockDb({ selectResult: [] });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j2',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'nonexistent-group' },
|
||||
});
|
||||
|
||||
await expect(groupHandler(mockJob, mockDb)).rejects.toThrow('Agent group not found: nonexistent-group');
|
||||
});
|
||||
|
||||
it('throws if group has no members', async () => {
|
||||
const mockGroup = { id: 'group-1', strategy: 'parallel', name: 'Test Group' };
|
||||
// First select returns the group, second returns empty members
|
||||
const mockDb = createMockDb({ selectResult: [mockGroup], selectResultSecond: [] });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j3',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'group-1' },
|
||||
});
|
||||
|
||||
await expect(groupHandler(mockJob, mockDb)).rejects.toThrow('Agent group group-1 has no members');
|
||||
});
|
||||
|
||||
it('dispatches parallel strategy correctly', async () => {
|
||||
const mockGroup = { id: 'group-1', strategy: 'parallel', name: 'Parallel Group' };
|
||||
const mockMembers = [
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 0, groupId: 'group-1', agentId: 'a1' },
|
||||
agent: { id: 'a1', name: 'Agent Alpha', model: 'claude-sonnet', systemPrompt: 'You are Alpha', tools: [] },
|
||||
},
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 1, groupId: 'group-1', agentId: 'a2' },
|
||||
agent: { id: 'a2', name: 'Agent Beta', model: 'claude-haiku', systemPrompt: 'You are Beta', tools: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockGroup], selectResultSecond: mockMembers });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j4',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'group-1', taskInput: { task: 'analyze data' } },
|
||||
});
|
||||
|
||||
const result = await groupHandler(mockJob, mockDb);
|
||||
|
||||
expect(result.strategy).toBe('parallel');
|
||||
expect(result.agentCount).toBe(2);
|
||||
// runAgentLoop called once per member
|
||||
expect(runAgentLoop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('dispatches sequential strategy correctly', async () => {
|
||||
const mockGroup = { id: 'group-2', strategy: 'sequential', name: 'Sequential Group' };
|
||||
const mockMembers = [
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 0, groupId: 'group-2', agentId: 'a1' },
|
||||
agent: { id: 'a1', name: 'Researcher', model: 'claude-sonnet', systemPrompt: 'Research first', tools: [] },
|
||||
},
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 1, groupId: 'group-2', agentId: 'a2' },
|
||||
agent: { id: 'a2', name: 'Writer', model: 'claude-haiku', systemPrompt: 'Write based on research', tools: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockGroup], selectResultSecond: mockMembers });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j5',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'group-2', taskInput: { task: 'research and draft' } },
|
||||
});
|
||||
|
||||
const result = await groupHandler(mockJob, mockDb);
|
||||
|
||||
expect(result.strategy).toBe('sequential');
|
||||
expect(result.agentCount).toBe(2);
|
||||
expect(runAgentLoop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws for unknown execution strategy', async () => {
|
||||
const mockGroup = { id: 'group-3', strategy: 'unknown_strategy', name: 'Bad Group' };
|
||||
const mockMembers = [
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 0, groupId: 'group-3', agentId: 'a1' },
|
||||
agent: { id: 'a1', name: 'Agent', model: 'claude-sonnet', systemPrompt: null, tools: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockGroup], selectResultSecond: mockMembers });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j6',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'group-3', taskInput: {} },
|
||||
});
|
||||
|
||||
await expect(groupHandler(mockJob, mockDb)).rejects.toThrow('Unknown execution strategy: unknown_strategy');
|
||||
});
|
||||
});
|
||||
92
packages/worker/tests/handlers/waggle-dispatch.test.ts
Normal file
92
packages/worker/tests/handlers/waggle-dispatch.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for waggle handler dispatcher integration.
|
||||
* Since waggle-handler depends on BullMQ Job + DB, we test the dispatcher
|
||||
* integration logic directly by importing the dispatcher.
|
||||
*/
|
||||
import { WaggleDanceDispatcher } from '@waggle/waggle-dance';
|
||||
import type { DispatchDeps } from '@waggle/waggle-dance';
|
||||
|
||||
function makeDeps(overrides?: Partial<DispatchDeps>): DispatchDeps {
|
||||
return {
|
||||
searchMemory: vi.fn(async (q: string) => `Knowledge for: ${q}`),
|
||||
resolveCapability: vi.fn((q: string) => [
|
||||
{ source: 'native', name: q, description: `Capability: ${q}`, available: true },
|
||||
]),
|
||||
spawnWorker: vi.fn(async (task: string, role: string) => `Worker spawned: ${task} (${role})`),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('waggle handler dispatcher integration', () => {
|
||||
it('task_delegation routes through dispatcher to spawnWorker', async () => {
|
||||
const deps = makeDeps();
|
||||
const dispatcher = new WaggleDanceDispatcher(deps);
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
id: 'msg-1', teamId: 't1', senderId: 'agent-1',
|
||||
type: 'request', subtype: 'task_delegation',
|
||||
content: { task: 'Analyze Q4 data', role: 'analyst' },
|
||||
referenceId: null, routing: null, createdAt: new Date(),
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.response).toContain('Analyze Q4 data');
|
||||
expect(deps.spawnWorker).toHaveBeenCalledWith('Analyze Q4 data', 'analyst', undefined);
|
||||
});
|
||||
|
||||
it('knowledge_check routes through dispatcher to searchMemory', async () => {
|
||||
const deps = makeDeps();
|
||||
const dispatcher = new WaggleDanceDispatcher(deps);
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
id: 'msg-2', teamId: 't1', senderId: 'agent-1',
|
||||
type: 'request', subtype: 'knowledge_check',
|
||||
content: { query: 'TypeScript patterns' },
|
||||
referenceId: null, routing: null, createdAt: new Date(),
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.response).toBe('Knowledge for: TypeScript patterns');
|
||||
expect(deps.searchMemory).toHaveBeenCalledWith('TypeScript patterns');
|
||||
});
|
||||
|
||||
it('skill_request routes through dispatcher to resolveCapability', async () => {
|
||||
const deps = makeDeps();
|
||||
const dispatcher = new WaggleDanceDispatcher(deps);
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
id: 'msg-3', teamId: 't1', senderId: 'agent-1',
|
||||
type: 'request', subtype: 'skill_request',
|
||||
content: { skill: 'web_search' },
|
||||
referenceId: null, routing: null, createdAt: new Date(),
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(deps.resolveCapability).toHaveBeenCalledWith('web_search');
|
||||
});
|
||||
|
||||
it('invalid message type returns error', async () => {
|
||||
const deps = makeDeps();
|
||||
const dispatcher = new WaggleDanceDispatcher(deps);
|
||||
|
||||
const result = await dispatcher.dispatch({
|
||||
id: 'msg-4', teamId: 't1', senderId: 'agent-1',
|
||||
type: 'broadcast', subtype: 'task_delegation', // invalid combo
|
||||
content: {},
|
||||
referenceId: null, routing: null, createdAt: new Date(),
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(false);
|
||||
expect(result.error).toContain('Invalid message');
|
||||
});
|
||||
|
||||
it('existing dispatcher tests still pass (14 tests in dispatcher.test.ts)', async () => {
|
||||
// This test verifies the dispatcher module imports correctly
|
||||
// The actual 14 tests run in packages/waggle-dance/tests/dispatcher.test.ts
|
||||
const deps = makeDeps();
|
||||
const dispatcher = new WaggleDanceDispatcher(deps);
|
||||
expect(dispatcher).toBeDefined();
|
||||
});
|
||||
});
|
||||
77
packages/worker/tests/handlers/waggle-handler.test.ts
Normal file
77
packages/worker/tests/handlers/waggle-handler.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Job } from 'bullmq';
|
||||
import type { Db } from '../../../server/src/db/connection.js';
|
||||
import type { JobData } from '../../src/job-processor.js';
|
||||
import { createWaggleHandler } from '../../src/handlers/waggle-handler.js';
|
||||
|
||||
function makeJob(input: Record<string, unknown>): Job<JobData> {
|
||||
return {
|
||||
data: {
|
||||
jobId: 'waggle-job-1',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'waggle',
|
||||
input,
|
||||
},
|
||||
} as unknown as Job<JobData>;
|
||||
}
|
||||
|
||||
describe('Waggle worker handler', () => {
|
||||
const db = {} as Db;
|
||||
|
||||
it('queues a real child chat job for task delegation', async () => {
|
||||
const enqueueWorker = vi.fn(async () => 'child-job-1');
|
||||
const handler = createWaggleHandler({ enqueueWorker });
|
||||
|
||||
const result = await handler(makeJob({
|
||||
type: 'request',
|
||||
subtype: 'task_delegation',
|
||||
content: { task: 'Analyze Q4 data', role: 'analyst', context: 'Use the latest report' },
|
||||
}), db);
|
||||
|
||||
expect(enqueueWorker).toHaveBeenCalledWith({
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
task: 'Analyze Q4 data',
|
||||
role: 'analyst',
|
||||
context: 'Use the latest report',
|
||||
});
|
||||
expect(result.response).toContain('child-job-1');
|
||||
expect(result.handled).toBe(true);
|
||||
});
|
||||
|
||||
it('reports missing capabilities as unavailable instead of fabricating success', async () => {
|
||||
const handler = createWaggleHandler();
|
||||
|
||||
const result = await handler(makeJob({
|
||||
type: 'request',
|
||||
subtype: 'skill_request',
|
||||
content: { query: 'something-that-does-not-exist' },
|
||||
}), db);
|
||||
|
||||
expect(result.response).toContain('available):');
|
||||
expect(result.response).toContain('not available');
|
||||
});
|
||||
|
||||
it('returns a concrete legacy knowledge gap instead of a stub marker', async () => {
|
||||
const mockDb = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn(async () => []),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
} as unknown as Db;
|
||||
const handler = createWaggleHandler();
|
||||
|
||||
const result = await handler(makeJob({ topic: 'Q4 planning' }), mockDb);
|
||||
|
||||
expect(result.gaps).toEqual([
|
||||
'No team knowledge matched "Q4 planning".',
|
||||
'No team tasks matched "Q4 planning".',
|
||||
]);
|
||||
expect(result.recommendation).toContain('Create a knowledge entry or task');
|
||||
expect(JSON.stringify(result)).not.toContain('[Stub]');
|
||||
});
|
||||
});
|
||||
139
packages/worker/tests/job-processor.test.ts
Normal file
139
packages/worker/tests/job-processor.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import type { Job } from 'bullmq';
|
||||
import { createWorker } from '../src/index.js';
|
||||
import { JobService } from '../../server/src/services/job-service.js';
|
||||
import { createDb } from '../../server/src/db/connection.js';
|
||||
import { users, teams, teamMembers, agentJobs } from '../../server/src/db/schema.js';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import type { JobData } from '../src/job-processor.js';
|
||||
|
||||
const REDIS_URL = 'redis://localhost:6381';
|
||||
const DATABASE_URL = 'postgres://waggle:waggle_dev@localhost:5434/waggle';
|
||||
const QUEUE_NAME = `waggle-jobs-test-${Date.now()}`;
|
||||
|
||||
/** Poll for job status until it reaches target or timeout. */
|
||||
async function waitForJobStatus(
|
||||
jobService: JobService,
|
||||
jobId: string,
|
||||
target: string,
|
||||
timeoutMs = 10_000,
|
||||
intervalMs = 200,
|
||||
): Promise<Awaited<ReturnType<JobService['getJob']>>> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const job = await jobService.getJob(jobId);
|
||||
if (job?.status === target || job?.status === 'failed') return job;
|
||||
await new Promise(r => setTimeout(r, intervalMs));
|
||||
}
|
||||
// Return last state even on timeout
|
||||
return jobService.getJob(jobId);
|
||||
}
|
||||
|
||||
describe('BullMQ Worker', () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let jobService: JobService;
|
||||
let workerInstance: ReturnType<typeof createWorker>;
|
||||
let testUserId: string;
|
||||
let testTeamId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = createDb(DATABASE_URL);
|
||||
jobService = new JobService(db, REDIS_URL, QUEUE_NAME);
|
||||
workerInstance = createWorker(REDIS_URL, DATABASE_URL, QUEUE_NAME);
|
||||
|
||||
// Override handlers with fast mocks (real handlers call LiteLLM which isn't running in tests)
|
||||
workerInstance.processor.register('chat', async (job) => ({
|
||||
response: `Mock response for: ${String(job.data.input.message ?? '')}`,
|
||||
model: 'mock',
|
||||
}));
|
||||
workerInstance.processor.register('task', async (job) => ({
|
||||
result: `Mock task result`,
|
||||
input: job.data.input,
|
||||
}));
|
||||
|
||||
// Create test data
|
||||
const [user] = await db.insert(users).values({
|
||||
clerkId: 'worker_test_user_' + Date.now(),
|
||||
displayName: 'Worker Test',
|
||||
email: `worker_${Date.now()}@test.com`,
|
||||
}).returning();
|
||||
testUserId = user.id;
|
||||
|
||||
const [team] = await db.insert(teams).values({
|
||||
name: 'Worker Team',
|
||||
slug: 'worker-test-' + Date.now(),
|
||||
ownerId: testUserId,
|
||||
}).returning();
|
||||
testTeamId = team.id;
|
||||
|
||||
await db.insert(teamMembers).values({
|
||||
teamId: testTeamId,
|
||||
userId: testUserId,
|
||||
role: 'owner',
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
afterAll(async () => {
|
||||
try { await workerInstance.worker.close(); } catch { /* ignore */ }
|
||||
try { await jobService.close(); } catch { /* ignore */ }
|
||||
await db.execute(sql`DELETE FROM agent_jobs WHERE team_id = ${testTeamId}`);
|
||||
await db.execute(sql`DELETE FROM team_members WHERE user_id = ${testUserId}`);
|
||||
await db.execute(sql`DELETE FROM teams WHERE id = ${testTeamId}`);
|
||||
await db.execute(sql`DELETE FROM users WHERE id = ${testUserId}`);
|
||||
}, 15_000);
|
||||
|
||||
it('queues job and worker picks it up', async () => {
|
||||
const job = await jobService.createJob(testTeamId, testUserId, 'chat', { message: 'hello' });
|
||||
expect(job.status).toBe('queued');
|
||||
|
||||
const updated = await waitForJobStatus(jobService, job.id, 'completed');
|
||||
expect(updated?.status).toBe('completed');
|
||||
expect(updated?.output).toBeDefined();
|
||||
}, 15_000);
|
||||
|
||||
it('tracks job status transitions', async () => {
|
||||
const job = await jobService.createJob(testTeamId, testUserId, 'chat', { message: 'status test' });
|
||||
|
||||
const completed = await waitForJobStatus(jobService, job.id, 'completed');
|
||||
expect(completed?.startedAt).toBeDefined();
|
||||
expect(completed?.completedAt).toBeDefined();
|
||||
expect(completed?.status).toBe('completed');
|
||||
}, 15_000);
|
||||
|
||||
it('marks failed jobs', async () => {
|
||||
// Register a failing handler
|
||||
workerInstance.processor.register('fail_test', async () => {
|
||||
throw new Error('Intentional failure');
|
||||
});
|
||||
|
||||
const job = await jobService.createJob(testTeamId, testUserId, 'fail_test', {});
|
||||
|
||||
const failed = await waitForJobStatus(jobService, job.id, 'failed');
|
||||
expect(failed?.status).toBe('failed');
|
||||
expect((failed?.output as Record<string, unknown>)?.error).toContain('Intentional failure');
|
||||
}, 15_000);
|
||||
|
||||
it('executes cron wrapper jobs through the registered target handler', async () => {
|
||||
workerInstance.processor.register('chat', async (job) => ({
|
||||
response: String(job.data.input.message),
|
||||
}));
|
||||
|
||||
const result = await workerInstance.processor.process({
|
||||
data: {
|
||||
jobId: 'cron-wrapper-1',
|
||||
teamId: testTeamId,
|
||||
userId: testUserId,
|
||||
jobType: 'cron',
|
||||
input: {
|
||||
jobType: 'chat',
|
||||
jobConfig: { message: 'scheduled brief' },
|
||||
},
|
||||
},
|
||||
} as unknown as Job<JobData>, db);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: 'scheduled brief',
|
||||
cron: { delegatedJobType: 'chat' },
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
Reference in New Issue
Block a user