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,13 @@
{
"name": "@waggle/waggle-dance",
"version": "0.1.0",
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"build": "tsc",
"test": "node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/waggle-dance/tests"
},
"dependencies": {
"@waggle/shared": "*"
}
}

View File

@@ -0,0 +1,324 @@
import type { WaggleMessage } from '@waggle/shared';
import { validateMessageTypeCombo } from './protocol.js';
export interface DispatchDeps {
// ── v1 (team-internal) ─────────────────────────────────────────────
/** Search memory for a query. */
searchMemory: (query: string) => Promise<string>;
/** Resolve a capability via the router. */
resolveCapability: (
query: string,
) => Array<{
source: string;
name: string;
description: string;
available: boolean;
}>;
/** Spawn a worker for a task delegation. */
spawnWorker: (task: string, role: string, context?: string) => Promise<string>;
// ── v2 (cross-tool activity bus, AI-OS Phase 1) ───────────────────
/**
* Emit a broadcast signal to the activity bus. Used by `discovery`,
* `routed_share`, and `model_recipe` subtypes. Implementations
* persist the message (DB) and push to live listeners (SSE / WS).
*
* Optional. When absent, the dispatcher still reports `handled=true`
* so a caller wiring persistence outside the dispatcher (e.g. via
* a Fastify route handler) does not see false negatives. The route
* layer is the authoritative persistence boundary.
*/
emitSignal?: (message: WaggleMessage) => Promise<void>;
/**
* Record a response message. Used by `knowledge_match` and
* `task_claim`. Implementations persist the response and correlate
* to the original request via `message.referenceId`.
*
* Optional. Same fall-through semantics as `emitSignal`.
*/
recordResponse?: (message: WaggleMessage) => Promise<void>;
/**
* Recommend a model for a `model_recommendation` request. The
* `context` argument carries any additional content fields (budget,
* latency hint, etc.) the implementation may use to score candidates.
*
* Optional. When absent the dispatcher returns a friendly
* not-configured response (still `handled=true`).
*/
recommendModel?: (
query: string,
context: Record<string, unknown>,
) => Promise<string | null>;
}
export interface DispatchResult {
handled: boolean;
response?: string;
error?: string;
}
/**
* Dispatches Waggle Dance protocol messages to real handlers.
*
* v1 (team-internal) subtypes: task_delegation, knowledge_check,
* skill_request, skill_share.
*
* v2 (cross-tool activity bus, AI-OS Phase 1): discovery, routed_share,
* model_recipe, knowledge_match, task_claim, model_recommendation.
*
* All v2 callbacks are optional — Phase 1A introduces the branches
* with safe defaults so the route layer can wire emitters / response
* recorders / model recommenders incrementally (Phase 1B/1C).
*/
export class WaggleDanceDispatcher {
private deps: DispatchDeps;
constructor(deps: DispatchDeps) {
this.deps = deps;
}
/** Dispatch a Waggle Dance message to the appropriate handler. */
async dispatch(message: WaggleMessage): Promise<DispatchResult> {
// Validate the message type-subtype combo first.
if (!validateMessageTypeCombo(message.type, message.subtype)) {
return {
handled: false,
error: `Invalid message: ${message.type}/${message.subtype}`,
};
}
switch (message.subtype) {
// ── v1 ────────────────────────────────────────────────────────
case 'task_delegation':
return this.handleTaskDelegation(message);
case 'knowledge_check':
return this.handleKnowledgeCheck(message);
case 'skill_request':
return this.handleSkillRequest(message);
case 'skill_share':
return this.handleSkillShare(message);
// ── v2 — broadcasts ──────────────────────────────────────────
case 'discovery':
case 'routed_share':
case 'model_recipe':
return this.handleBroadcastSignal(message);
// ── v2 — responses ───────────────────────────────────────────
case 'knowledge_match':
case 'task_claim':
return this.handleResponseRelay(message);
// ── v2 — model_recommendation request ────────────────────────
case 'model_recommendation':
return this.handleModelRecommendation(message);
default:
return {
handled: false,
error: `Unhandled subtype: ${(message as { subtype: string }).subtype}`,
};
}
}
// ── v1 handlers (unchanged) ──────────────────────────────────────
private async handleTaskDelegation(
message: WaggleMessage,
): Promise<DispatchResult> {
const task = (message.content.task as string) ?? '';
const role = (message.content.role as string) ?? 'analyst';
const context = (message.content.context as string) ?? undefined;
if (!task) {
return { handled: false, error: 'task_delegation requires content.task' };
}
try {
const result = await this.deps.spawnWorker(task, role, context);
return { handled: true, response: result };
} catch (err) {
return {
handled: false,
error: `Worker spawn failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
private async handleKnowledgeCheck(
message: WaggleMessage,
): Promise<DispatchResult> {
const query =
(message.content.query as string) ??
(message.content.topic as string) ??
'';
if (!query) {
return {
handled: false,
error: 'knowledge_check requires content.query or content.topic',
};
}
try {
const result = await this.deps.searchMemory(query);
return { handled: true, response: result };
} catch (err) {
return {
handled: false,
error: `Memory search failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
private async handleSkillShare(
message: WaggleMessage,
): Promise<DispatchResult> {
const skillName =
(message.content.name as string) ??
(message.content.skill as string) ??
'';
const skillContent = (message.content.content as string) ?? '';
if (!skillName || !skillContent) {
return {
handled: false,
error: 'skill_share requires content.name and content.content',
};
}
return {
handled: true,
response: JSON.stringify({
action: 'install_shared_skill',
skillName,
skillContent,
sharedBy: (message.content.sharedBy as string) ?? 'unknown',
}),
};
}
private async handleSkillRequest(
message: WaggleMessage,
): Promise<DispatchResult> {
const query =
(message.content.skill as string) ??
(message.content.query as string) ??
'';
if (!query) {
return {
handled: false,
error: 'skill_request requires content.skill or content.query',
};
}
const routes = this.deps.resolveCapability(query);
if (routes.length === 0) {
return { handled: true, response: `No capability found for "${query}"` };
}
const available = routes.filter((r) => r.available);
const routeList = routes
.map(
(r) =>
`- [${r.source}] ${r.name}: ${r.description} (${r.available ? 'available' : 'not available'})`,
)
.join('\n');
return {
handled: true,
response: `Found ${routes.length} capabilities (${available.length} available):\n${routeList}`,
};
}
// ── v2 handlers ──────────────────────────────────────────────────
/**
* Shared handler for `discovery`, `routed_share`, and `model_recipe`
* broadcasts. All three share the same shape (persist + emit);
* downstream consumers differentiate on `subtype`.
*/
private async handleBroadcastSignal(
message: WaggleMessage,
): Promise<DispatchResult> {
if (!this.deps.emitSignal) {
// Route layer is the authoritative persistence boundary. Reporting
// handled=true keeps the dispatcher composable with callers that
// do persistence externally.
return { handled: true, response: `${message.subtype} accepted` };
}
try {
await this.deps.emitSignal(message);
return { handled: true, response: `${message.subtype} emitted` };
} catch (err) {
return {
handled: false,
error: `Signal emit failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
/**
* Shared handler for `knowledge_match` and `task_claim` responses.
* Both are correlated to a prior request via `referenceId`; the
* dispatcher's job is to persist (or accept) the response and let
* downstream subscribers reconstruct the request/response pair.
*/
private async handleResponseRelay(
message: WaggleMessage,
): Promise<DispatchResult> {
if (!this.deps.recordResponse) {
return { handled: true, response: `${message.subtype} accepted` };
}
try {
await this.deps.recordResponse(message);
return { handled: true, response: `${message.subtype} recorded` };
} catch (err) {
return {
handled: false,
error: `Response record failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
private async handleModelRecommendation(
message: WaggleMessage,
): Promise<DispatchResult> {
const query = (message.content.query as string) ?? '';
if (!query) {
return {
handled: false,
error: 'model_recommendation requires content.query',
};
}
if (!this.deps.recommendModel) {
return {
handled: true,
response: 'no model recommender configured',
};
}
// Pass everything except `query` as context so the recommender
// can score on caller-provided hints (budget, latency, …).
const { query: _drop, ...rest } = message.content;
try {
const recommendation = await this.deps.recommendModel(query, rest);
if (!recommendation) {
return {
handled: true,
response: `no recommendation available for "${query}"`,
};
}
return { handled: true, response: recommendation };
} catch (err) {
return {
handled: false,
error: `Model recommendation failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
}

View File

@@ -0,0 +1,26 @@
// Types for hive query results — DB queries live in the server's MessageService
export interface HiveQueryResult {
entities: Array<{
id: string;
name: string;
entityType: string;
properties: Record<string, unknown>;
}>;
relatedTasks: Array<{
id: string;
title: string;
status: string;
createdBy: string;
}>;
relatedMessages: Array<{
id: string;
type: string;
subtype: string;
content: Record<string, unknown>;
}>;
}
export interface HiveQuery {
topic: string;
scope?: string;
}

View File

@@ -0,0 +1,4 @@
// @waggle/waggle-dance — Waggle Dance protocol and hive query types
export * from './protocol.js';
export * from './hive-query.js';
export * from './dispatcher.js';

View File

@@ -0,0 +1,16 @@
import type { MessageType, MessageSubtype } from '@waggle/shared';
// Valid type-subtype combinations for the Waggle Dance protocol
const VALID_COMBINATIONS: Record<MessageType, MessageSubtype[]> = {
request: ['knowledge_check', 'task_delegation', 'skill_request', 'model_recommendation'],
response: ['knowledge_match', 'task_claim'],
broadcast: ['discovery', 'routed_share', 'skill_share', 'model_recipe'],
};
export function validateMessageTypeCombo(type: MessageType, subtype: MessageSubtype): boolean {
return VALID_COMBINATIONS[type]?.includes(subtype) ?? false;
}
export function isRoutedMessage(subtype: MessageSubtype): boolean {
return subtype === 'routed_share';
}

View File

@@ -0,0 +1,508 @@
import { describe, it, expect, vi } from 'vitest';
import { WaggleDanceDispatcher } from '../src/dispatcher.js';
import type { DispatchDeps } from '../src/dispatcher.js';
import type { WaggleMessage } from '@waggle/shared';
function makeDeps(overrides?: Partial<DispatchDeps>): DispatchDeps {
return {
searchMemory: vi.fn(async (q: string) => `Memory results for: ${q}`),
resolveCapability: vi.fn((q: string) => [
{ source: 'native', name: 'search_memory', description: 'Search memory', available: true },
]),
spawnWorker: vi.fn(async (task: string, role: string) => `Worker completed: ${task}`),
...overrides,
};
}
/**
* Build deps that include the v2 emit/response/recommend callbacks.
* Most v2 tests want to assert that the right callback was invoked
* with the right message — use this helper to get spies.
*/
function makeV2Deps(overrides?: Partial<DispatchDeps>): DispatchDeps {
return {
...makeDeps(),
emitSignal: vi.fn(async () => undefined),
recordResponse: vi.fn(async () => undefined),
recommendModel: vi.fn(async () => 'claude-haiku-4-5'),
...overrides,
};
}
function makeMessage(overrides?: Partial<WaggleMessage>): WaggleMessage {
return {
id: 'msg-1',
teamId: 'team-1',
senderId: 'agent-1',
type: 'request',
subtype: 'task_delegation',
content: { task: 'Research TypeScript', role: 'researcher' },
referenceId: null,
routing: null,
createdAt: new Date(),
...overrides,
};
}
describe('WaggleDanceDispatcher', () => {
it('constructor creates dispatcher', () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
expect(dispatcher).toBeDefined();
expect(dispatcher).toBeInstanceOf(WaggleDanceDispatcher);
});
describe('task_delegation', () => {
it('dispatches to spawnWorker with task and role', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage());
expect(result.handled).toBe(true);
expect(result.response).toBe('Worker completed: Research TypeScript');
expect(deps.spawnWorker).toHaveBeenCalledWith('Research TypeScript', 'researcher', undefined);
});
it('passes context when provided', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
content: { task: 'Analyze data', role: 'analyst', context: 'Q4 sales figures' },
}));
expect(result.handled).toBe(true);
expect(deps.spawnWorker).toHaveBeenCalledWith('Analyze data', 'analyst', 'Q4 sales figures');
});
it('fails gracefully on missing task', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
content: { role: 'analyst' },
}));
expect(result.handled).toBe(false);
expect(result.error).toBe('task_delegation requires content.task');
});
it('handles spawnWorker errors', async () => {
const deps = makeDeps({
spawnWorker: vi.fn(async () => { throw new Error('Worker crashed'); }),
});
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage());
expect(result.handled).toBe(false);
expect(result.error).toContain('Worker spawn failed');
expect(result.error).toContain('Worker crashed');
});
});
describe('knowledge_check', () => {
it('dispatches to searchMemory', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'knowledge_check',
content: { query: 'TypeScript patterns' },
}));
expect(result.handled).toBe(true);
expect(result.response).toBe('Memory results for: TypeScript patterns');
expect(deps.searchMemory).toHaveBeenCalledWith('TypeScript patterns');
});
it('supports both query and topic fields', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
// topic field
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'knowledge_check',
content: { topic: 'Architecture decisions' },
}));
expect(result.handled).toBe(true);
expect(deps.searchMemory).toHaveBeenCalledWith('Architecture decisions');
});
it('fails on empty query', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'knowledge_check',
content: {},
}));
expect(result.handled).toBe(false);
expect(result.error).toBe('knowledge_check requires content.query or content.topic');
});
it('handles searchMemory errors', async () => {
const deps = makeDeps({
searchMemory: vi.fn(async () => { throw new Error('DB connection lost'); }),
});
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'knowledge_check',
content: { query: 'something' },
}));
expect(result.handled).toBe(false);
expect(result.error).toContain('Memory search failed');
});
});
describe('skill_request', () => {
it('dispatches to resolveCapability', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'skill_request',
content: { skill: 'web_search' },
}));
expect(result.handled).toBe(true);
expect(deps.resolveCapability).toHaveBeenCalledWith('web_search');
});
it('returns formatted capability routes', async () => {
const deps = makeDeps({
resolveCapability: vi.fn(() => [
{ source: 'native', name: 'search_memory', description: 'Search memory', available: true },
{ source: 'plugin', name: 'web_search', description: 'Web search', available: false },
]),
});
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'skill_request',
content: { skill: 'search' },
}));
expect(result.handled).toBe(true);
expect(result.response).toContain('Found 2 capabilities (1 available)');
expect(result.response).toContain('[native] search_memory');
expect(result.response).toContain('[plugin] web_search');
expect(result.response).toContain('(available)');
expect(result.response).toContain('(not available)');
});
it('handles no results', async () => {
const deps = makeDeps({
resolveCapability: vi.fn(() => []),
});
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'skill_request',
content: { skill: 'nonexistent' },
}));
expect(result.handled).toBe(true);
expect(result.response).toBe('No capability found for "nonexistent"');
});
it('fails on empty skill query', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'skill_request',
content: {},
}));
expect(result.handled).toBe(false);
expect(result.error).toBe('skill_request requires content.skill or content.query');
});
});
describe('skill_share', () => {
it('returns parsed install data when name and content provided', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'broadcast',
subtype: 'skill_share',
content: { name: 'code_review', content: '#!/bin/bash\necho review', sharedBy: 'agent-2' },
}));
expect(result.handled).toBe(true);
const parsed = JSON.parse(result.response!);
expect(parsed.action).toBe('install_shared_skill');
expect(parsed.skillName).toBe('code_review');
expect(parsed.skillContent).toBe('#!/bin/bash\necho review');
expect(parsed.sharedBy).toBe('agent-2');
});
it('defaults sharedBy to unknown when not provided', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'broadcast',
subtype: 'skill_share',
content: { name: 'deploy', content: 'deploy script body' },
}));
expect(result.handled).toBe(true);
const parsed = JSON.parse(result.response!);
expect(parsed.sharedBy).toBe('unknown');
});
it('accepts skill field as fallback for name', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'broadcast',
subtype: 'skill_share',
content: { skill: 'lint_fix', content: 'lint script' },
}));
expect(result.handled).toBe(true);
const parsed = JSON.parse(result.response!);
expect(parsed.skillName).toBe('lint_fix');
});
it('fails when name is missing', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'broadcast',
subtype: 'skill_share',
content: { content: 'some content' },
}));
expect(result.handled).toBe(false);
expect(result.error).toBe('skill_share requires content.name and content.content');
});
it('fails when content is missing', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'broadcast',
subtype: 'skill_share',
content: { name: 'some_skill' },
}));
expect(result.handled).toBe(false);
expect(result.error).toBe('skill_share requires content.name and content.content');
});
});
describe('validation', () => {
it('rejects invalid message type-subtype combo', async () => {
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'broadcast',
subtype: 'task_delegation',
}));
expect(result.handled).toBe(false);
expect(result.error).toBe('Invalid message: broadcast/task_delegation');
});
it('rejects unknown subtypes (runtime defense against malformed messages)', async () => {
// TypeScript prevents this in-process, but network messages may
// arrive with garbage subtypes. The dispatcher's switch must have
// a default branch that fails gracefully.
const deps = makeDeps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch({
...makeMessage(),
type: 'broadcast',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
subtype: 'nonexistent_subtype' as any,
content: {},
});
expect(result.handled).toBe(false);
expect(result.error).toMatch(/Invalid message|Unhandled subtype/);
});
});
// ── v2 — cross-tool activity bus ──────────────────────────────────
describe('discovery (v2 broadcast)', () => {
it('forwards to emitSignal and reports handled=true', async () => {
const deps = makeV2Deps();
const dispatcher = new WaggleDanceDispatcher(deps);
const message = makeMessage({
type: 'broadcast',
subtype: 'discovery',
content: { topic: 'webhook secret rotation', tool: 'claude-code', importance: 'normal' },
});
const result = await dispatcher.dispatch(message);
expect(result.handled).toBe(true);
expect(deps.emitSignal).toHaveBeenCalledWith(message);
});
it('handles missing emitSignal gracefully (Phase 1A behavior)', async () => {
// emitSignal is optional. When absent, the dispatcher still
// reports handled=true so callers can persist the message
// without an emitter wired.
const deps = makeDeps(); // no emitSignal
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'broadcast',
subtype: 'discovery',
content: { topic: 'whatever' },
}));
expect(result.handled).toBe(true);
});
it('propagates emitSignal errors', async () => {
const deps = makeV2Deps({
emitSignal: vi.fn(async () => { throw new Error('SSE pipe broken'); }),
});
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'broadcast',
subtype: 'discovery',
content: { topic: 'x' },
}));
expect(result.handled).toBe(false);
expect(result.error).toMatch(/Signal emit failed/);
});
});
describe('routed_share (v2 broadcast)', () => {
it('forwards to emitSignal preserving the routing field', async () => {
const deps = makeV2Deps();
const dispatcher = new WaggleDanceDispatcher(deps);
const message = makeMessage({
type: 'broadcast',
subtype: 'routed_share',
content: { payload: { docId: 'doc-42' } },
routing: [
{ userId: 'user-1', reason: 'subscribed to doc-42' },
{ userId: 'user-3', reason: 'reviewer' },
],
});
const result = await dispatcher.dispatch(message);
expect(result.handled).toBe(true);
expect(deps.emitSignal).toHaveBeenCalled();
const call = (deps.emitSignal as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.routing).toHaveLength(2);
});
});
describe('model_recipe (v2 broadcast)', () => {
it('forwards a shared model config via emitSignal', async () => {
const deps = makeV2Deps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'broadcast',
subtype: 'model_recipe',
content: {
name: 'reasoning-bias-low',
model: 'claude-opus-4-7',
temperature: 0.2,
system: 'You are concise.',
},
}));
expect(result.handled).toBe(true);
expect(deps.emitSignal).toHaveBeenCalled();
});
});
describe('knowledge_match (v2 response)', () => {
it('forwards to recordResponse', async () => {
const deps = makeV2Deps();
const dispatcher = new WaggleDanceDispatcher(deps);
const message = makeMessage({
type: 'response',
subtype: 'knowledge_match',
content: { matchedEntities: ['ent-1', 'ent-2'], confidence: 0.91 },
referenceId: 'request-msg-id-7',
});
const result = await dispatcher.dispatch(message);
expect(result.handled).toBe(true);
expect(deps.recordResponse).toHaveBeenCalledWith(message);
});
it('is handled even without recordResponse wired', async () => {
const deps = makeDeps(); // no recordResponse
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'response',
subtype: 'knowledge_match',
content: {},
referenceId: 'r-1',
}));
expect(result.handled).toBe(true);
});
});
describe('task_claim (v2 response)', () => {
it('forwards to recordResponse', async () => {
const deps = makeV2Deps();
const dispatcher = new WaggleDanceDispatcher(deps);
const message = makeMessage({
type: 'response',
subtype: 'task_claim',
content: { claimedBy: 'agent-9', eta: '10m' },
referenceId: 'task-delegation-msg-3',
});
const result = await dispatcher.dispatch(message);
expect(result.handled).toBe(true);
expect(deps.recordResponse).toHaveBeenCalledWith(message);
});
});
describe('model_recommendation (v2 request)', () => {
it('queries recommendModel and returns the recommendation', async () => {
const deps = makeV2Deps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'model_recommendation',
content: { query: 'fast classifier' },
}));
expect(result.handled).toBe(true);
expect(result.response).toContain('claude-haiku-4-5');
expect(deps.recommendModel).toHaveBeenCalledWith('fast classifier', expect.any(Object));
});
it('passes additional context fields to recommendModel', async () => {
const deps = makeV2Deps();
const dispatcher = new WaggleDanceDispatcher(deps);
await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'model_recommendation',
content: { query: 'reason about a contract', maxLatencyMs: 4000, budget: 'normal' },
}));
const call = (deps.recommendModel as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[1]).toMatchObject({ maxLatencyMs: 4000, budget: 'normal' });
});
it('handles missing recommendModel gracefully', async () => {
const deps = makeDeps(); // no recommendModel
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'model_recommendation',
content: { query: 'anything' },
}));
expect(result.handled).toBe(true);
expect(result.response).toMatch(/no model recommender/i);
});
it('fails when query is missing', async () => {
const deps = makeV2Deps();
const dispatcher = new WaggleDanceDispatcher(deps);
const result = await dispatcher.dispatch(makeMessage({
type: 'request',
subtype: 'model_recommendation',
content: {},
}));
expect(result.handled).toBe(false);
expect(result.error).toMatch(/model_recommendation requires content.query/);
});
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from 'vitest';
import { WaggleDanceDispatcher } from '../src/dispatcher.js';
import type { WaggleMessage } from '@waggle/shared';
describe('Waggle Dance Integration', () => {
it('full workflow: task_delegation → knowledge_check → skill_request', async () => {
const deps = {
searchMemory: vi.fn(async (q: string) => `Found memories about: ${q}`),
resolveCapability: vi.fn(() => [
{ source: 'native', name: 'web_search', description: 'Web search', available: true },
]),
spawnWorker: vi.fn(async (task: string) => `Worker completed: ${task}`),
};
const dispatcher = new WaggleDanceDispatcher(deps);
// 1. Delegate a task
const taskResult = await dispatcher.dispatch({
id: '1', teamId: 't1', senderId: 'a1', type: 'request', subtype: 'task_delegation',
content: { task: 'Analyze market data', role: 'analyst' },
referenceId: null, routing: null, createdAt: new Date(),
});
expect(taskResult.handled).toBe(true);
expect(deps.spawnWorker).toHaveBeenCalledWith('Analyze market data', 'analyst', undefined);
// 2. Check knowledge
const knowledgeResult = await dispatcher.dispatch({
id: '2', teamId: 't1', senderId: 'a1', type: 'request', subtype: 'knowledge_check',
content: { query: 'market trends' },
referenceId: null, routing: null, createdAt: new Date(),
});
expect(knowledgeResult.handled).toBe(true);
expect(knowledgeResult.response).toContain('market trends');
// 3. Request a skill
const skillResult = await dispatcher.dispatch({
id: '3', teamId: 't1', senderId: 'a1', type: 'request', subtype: 'skill_request',
content: { skill: 'web_search' },
referenceId: null, routing: null, createdAt: new Date(),
});
expect(skillResult.handled).toBe(true);
expect(skillResult.response).toContain('web_search');
});
it('round-trip: task delegation result feeds knowledge check', async () => {
let lastWorkerResult = '';
const deps = {
searchMemory: vi.fn(async () => lastWorkerResult),
resolveCapability: vi.fn(() => []),
spawnWorker: vi.fn(async (task: string) => {
lastWorkerResult = `Analysis complete for: ${task}`;
return lastWorkerResult;
}),
};
const dispatcher = new WaggleDanceDispatcher(deps);
// First: delegate
await dispatcher.dispatch({
id: '1', teamId: 't1', senderId: 'a1', type: 'request', subtype: 'task_delegation',
content: { task: 'Analyze competitors', role: 'analyst' },
referenceId: null, routing: null, createdAt: new Date(),
});
// Then: check what was learned
const result = await dispatcher.dispatch({
id: '2', teamId: 't1', senderId: 'a1', type: 'request', subtype: 'knowledge_check',
content: { query: 'competitors' },
referenceId: null, routing: null, createdAt: new Date(),
});
expect(result.response).toContain('Analyze competitors');
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import type { MessageType, MessageSubtype } from '@waggle/shared';
import { validateMessageTypeCombo, isRoutedMessage } from '../src/protocol.js';
describe('Waggle Dance protocol', () => {
describe('validateMessageTypeCombo', () => {
it('validates correct request subtypes', () => {
expect(validateMessageTypeCombo('request', 'knowledge_check')).toBe(true);
expect(validateMessageTypeCombo('request', 'task_delegation')).toBe(true);
expect(validateMessageTypeCombo('request', 'skill_request')).toBe(true);
expect(validateMessageTypeCombo('request', 'model_recommendation')).toBe(true);
});
it('validates correct response subtypes', () => {
expect(validateMessageTypeCombo('response', 'knowledge_match')).toBe(true);
expect(validateMessageTypeCombo('response', 'task_claim')).toBe(true);
});
it('validates correct broadcast subtypes', () => {
expect(validateMessageTypeCombo('broadcast', 'discovery')).toBe(true);
expect(validateMessageTypeCombo('broadcast', 'routed_share')).toBe(true);
expect(validateMessageTypeCombo('broadcast', 'skill_share')).toBe(true);
expect(validateMessageTypeCombo('broadcast', 'model_recipe')).toBe(true);
});
it('rejects invalid type-subtype combinations', () => {
expect(validateMessageTypeCombo('request', 'discovery')).toBe(false);
expect(validateMessageTypeCombo('broadcast', 'knowledge_check')).toBe(false);
expect(validateMessageTypeCombo('response', 'skill_share')).toBe(false);
expect(validateMessageTypeCombo('request', 'task_claim')).toBe(false);
});
it('rejects completely invalid types', () => {
expect(validateMessageTypeCombo('invalid' as MessageType, 'discovery')).toBe(false);
});
it('rejects completely invalid subtypes', () => {
expect(validateMessageTypeCombo('request', 'invalid' as MessageSubtype)).toBe(false);
});
});
describe('isRoutedMessage', () => {
it('identifies routed_share as routed', () => {
expect(isRoutedMessage('routed_share')).toBe(true);
});
it('identifies non-routed subtypes', () => {
expect(isRoutedMessage('discovery')).toBe(false);
expect(isRoutedMessage('knowledge_check')).toBe(false);
expect(isRoutedMessage('task_claim')).toBe(false);
expect(isRoutedMessage('skill_share')).toBe(false);
expect(isRoutedMessage('model_recipe')).toBe(false);
});
});
});

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true
},
"references": [
{ "path": "../shared" }
],
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}