moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View 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');
});
});

View 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');
});
});

View 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');
});
});

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

View 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]');
});
});