This commit is contained in:
13
packages/waggle-dance/package.json
Normal file
13
packages/waggle-dance/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
324
packages/waggle-dance/src/dispatcher.ts
Normal file
324
packages/waggle-dance/src/dispatcher.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
26
packages/waggle-dance/src/hive-query.ts
Normal file
26
packages/waggle-dance/src/hive-query.ts
Normal 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;
|
||||
}
|
||||
4
packages/waggle-dance/src/index.ts
Normal file
4
packages/waggle-dance/src/index.ts
Normal 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';
|
||||
16
packages/waggle-dance/src/protocol.ts
Normal file
16
packages/waggle-dance/src/protocol.ts
Normal 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';
|
||||
}
|
||||
508
packages/waggle-dance/tests/dispatcher.test.ts
Normal file
508
packages/waggle-dance/tests/dispatcher.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/waggle-dance/tests/integration.test.ts
Normal file
72
packages/waggle-dance/tests/integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
55
packages/waggle-dance/tests/protocol.test.ts
Normal file
55
packages/waggle-dance/tests/protocol.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
24
packages/waggle-dance/tsconfig.json
Normal file
24
packages/waggle-dance/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user