This commit is contained in:
473
packages/server/tests/waggle-dance-routes.test.ts
Normal file
473
packages/server/tests/waggle-dance-routes.test.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* AI-OS Phase 1B — waggle-dance routes integration tests.
|
||||
*
|
||||
* Boots a real local server, posts signals, queries the bus.
|
||||
* Verifies the cross-tool activity bus end-to-end via REST.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { MindDB, FrameStore, SessionStore } from '@waggle/core';
|
||||
import { buildLocalServer } from '../src/local/index.js';
|
||||
import { injectWithAuth } from './test-utils.js';
|
||||
|
||||
function createTmpDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `waggle-wd-${prefix}-`));
|
||||
}
|
||||
|
||||
function cleanupDir(dir: string): void {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
describe('Waggle-Dance routes (Phase 1B)', () => {
|
||||
let server: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = createTmpDir('routes');
|
||||
const personalPath = path.join(tmpDir, 'personal.mind');
|
||||
const mind = new MindDB(personalPath);
|
||||
const sessions = new SessionStore(mind);
|
||||
const frames = new FrameStore(mind);
|
||||
const s = sessions.create('wd-routes-test');
|
||||
frames.createIFrame(s.gop_id, 'wd-routes-test seed', 'normal');
|
||||
mind.close();
|
||||
|
||||
server = await buildLocalServer({ dataDir: tmpDir });
|
||||
await server.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (server) await server.close();
|
||||
if (tmpDir) cleanupDir(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the bus between tests so we don't have cross-test pollution.
|
||||
if (server?.signalBus) server.signalBus.clear();
|
||||
});
|
||||
|
||||
// ── POST /api/waggle-dance/signal ────────────────────────────────
|
||||
|
||||
it('POST returns 201 and persists a discovery signal', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
senderId: 'claude-code-hook',
|
||||
content: { tool: 'claude-code', topic: 'webhook rotation' },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.dispatched).toBe(true);
|
||||
expect(body.message.subtype).toBe('discovery');
|
||||
expect(body.message.teamId).toBe('personal::claude-code-hook');
|
||||
expect(body.message.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('POST defaults senderId to "local" and derives a personal team', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { topic: 'no-sender case' },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.message.senderId).toBe('local');
|
||||
expect(body.message.teamId).toBe('personal::local');
|
||||
});
|
||||
|
||||
it('POST rejects invalid type/subtype combo', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'request',
|
||||
subtype: 'discovery', // discovery is broadcast-only
|
||||
content: {},
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('Invalid type-subtype combination');
|
||||
});
|
||||
|
||||
it('POST rejects unknown subtype', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'made_up_subtype',
|
||||
content: {},
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('POST accepts a routed_share with routing list', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'routed_share',
|
||||
senderId: 'hermes',
|
||||
content: { tool: 'hermes', payload: { docId: 'd-7' } },
|
||||
routing: [{ userId: 'u-1', reason: 'subscribed' }],
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.message.routing).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('POST accepts a knowledge_match response with referenceId', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'response',
|
||||
subtype: 'knowledge_match',
|
||||
senderId: 'cursor-hook',
|
||||
referenceId: 'orig-msg-id',
|
||||
content: { matchedEntities: ['e-1'] },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().message.referenceId).toBe('orig-msg-id');
|
||||
});
|
||||
|
||||
// ── GET /api/waggle-dance/signals ────────────────────────────────
|
||||
|
||||
it('scopes an external run credential to its own sender, Room, and workspace', async () => {
|
||||
const room = server.agentRunRegistry.createRoom({
|
||||
workspaceIds: ['default'], source: 'external_tool', title: 'External room', task: 'Collaborate',
|
||||
});
|
||||
const run = server.agentRunRegistry.createWorker({
|
||||
parentRunId: room.id, workspaceId: 'default', source: 'external_tool',
|
||||
executor: { kind: 'external_tool', toolId: 'codex' }, title: 'Codex', task: room.task,
|
||||
});
|
||||
const token = server.agentRunRegistry.issueCredential(run.id);
|
||||
|
||||
const spoofed = await server.inject({
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json', 'x-waggle-run-token': token },
|
||||
payload: {
|
||||
type: 'broadcast', subtype: 'discovery', senderId: 'run::someone-else',
|
||||
teamId: 'room::someone-else', content: { runId: 'someone-else', topic: 'spoof' },
|
||||
},
|
||||
});
|
||||
expect(spoofed.statusCode).toBe(403);
|
||||
expect(spoofed.json().code).toBe('RUN_SCOPE_VIOLATION');
|
||||
|
||||
const sent = await server.inject({
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json', 'x-waggle-run-token': token },
|
||||
payload: {
|
||||
type: 'request', subtype: 'knowledge_check', content: { query: 'Who has the schema?' },
|
||||
},
|
||||
});
|
||||
expect(sent.statusCode).toBe(201);
|
||||
expect(sent.json().message).toMatchObject({
|
||||
senderId: `run::${run.id}`,
|
||||
teamId: `room::${room.id}`,
|
||||
content: { roomId: room.id, runId: run.id, workspaceId: 'default' },
|
||||
});
|
||||
|
||||
const discovery = await server.inject({
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json', 'x-waggle-run-token': token },
|
||||
payload: {
|
||||
type: 'broadcast', subtype: 'discovery',
|
||||
content: {
|
||||
summary: 'Found the migration owner and saved the result.',
|
||||
frameId: '42', memoryWorkspace: 'default',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(discovery.statusCode).toBe(201);
|
||||
const updated = server.agentRunRegistry.get(run.id);
|
||||
expect(updated).toMatchObject({
|
||||
status: 'running',
|
||||
result: { summary: 'Found the migration owner and saved the result.' },
|
||||
memoryRefs: {
|
||||
status: 'complete',
|
||||
workspaceFrameIds: { default: [42] },
|
||||
},
|
||||
});
|
||||
expect(updated?.memoryRefs.personalFrameIds).toHaveLength(1);
|
||||
expect(new FrameStore(server.multiMind.personal).getById(updated!.memoryRefs.personalFrameIds![0])?.content)
|
||||
.toContain('Found the migration owner');
|
||||
|
||||
const forbiddenRead = await server.inject({
|
||||
method: 'GET', url: '/api/waggle-dance/signals?teamId=room%3A%3Aother',
|
||||
headers: { 'x-waggle-run-token': token },
|
||||
});
|
||||
expect(forbiddenRead.statusCode).toBe(403);
|
||||
const ownRead = await server.inject({
|
||||
method: 'GET', url: '/api/waggle-dance/signals',
|
||||
headers: { 'x-waggle-run-token': token },
|
||||
});
|
||||
expect(ownRead.statusCode).toBe(200);
|
||||
expect((ownRead.json() as { signals: Array<{ teamId: string }> }).signals.every(
|
||||
(signal) => signal.teamId === `room::${room.id}`,
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('relays Room requests, correlated responses, and broadcasts exactly once', async () => {
|
||||
const room = server.agentRunRegistry.createRoom({
|
||||
workspaceIds: ['workspace-a', 'workspace-b'], source: 'external_tool',
|
||||
title: 'Collaboration room', task: 'Solve together',
|
||||
});
|
||||
const runA = server.agentRunRegistry.createWorker({
|
||||
parentRunId: room.id, workspaceId: 'workspace-a', source: 'external_tool',
|
||||
executor: { kind: 'external_tool', toolId: 'codex' }, title: 'Codex', task: room.task,
|
||||
});
|
||||
const runB = server.agentRunRegistry.createWorker({
|
||||
parentRunId: room.id, workspaceId: 'workspace-b', source: 'external_tool',
|
||||
executor: { kind: 'external_tool', toolId: 'claude-code' }, title: 'Claude Code', task: room.task,
|
||||
});
|
||||
const otherRoom = server.agentRunRegistry.createRoom({
|
||||
workspaceIds: ['workspace-c'], source: 'external_tool',
|
||||
title: 'Other room', task: 'Stay isolated',
|
||||
});
|
||||
const runC = server.agentRunRegistry.createWorker({
|
||||
parentRunId: otherRoom.id, workspaceId: 'workspace-c', source: 'external_tool',
|
||||
executor: { kind: 'external_tool', toolId: 'cursor' }, title: 'Cursor', task: otherRoom.task,
|
||||
});
|
||||
const tokenA = server.agentRunRegistry.issueCredential(runA.id);
|
||||
const tokenB = server.agentRunRegistry.issueCredential(runB.id);
|
||||
const tokenC = server.agentRunRegistry.issueCredential(runC.id);
|
||||
|
||||
const knowledgeRequest = await server.inject({
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json', 'x-waggle-run-token': tokenA },
|
||||
payload: {
|
||||
type: 'request', subtype: 'knowledge_check',
|
||||
content: { query: 'Who owns the schema?' },
|
||||
},
|
||||
});
|
||||
expect(knowledgeRequest.statusCode).toBe(201);
|
||||
const knowledgeRequestId = knowledgeRequest.json().message.id as string;
|
||||
|
||||
const taskRequest = await server.inject({
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json', 'x-waggle-run-token': tokenA },
|
||||
payload: {
|
||||
type: 'request', subtype: 'task_delegation',
|
||||
content: { task: 'Inspect the migrations', role: 'analyst' },
|
||||
},
|
||||
});
|
||||
expect(taskRequest.statusCode).toBe(201);
|
||||
const taskRequestId = taskRequest.json().message.id as string;
|
||||
|
||||
const peerRead = await server.inject({
|
||||
method: 'GET', url: '/api/waggle-dance/signals',
|
||||
headers: { 'x-waggle-run-token': tokenB },
|
||||
});
|
||||
expect(peerRead.statusCode).toBe(200);
|
||||
const peerSignals = peerRead.json().signals as Array<{ id: string; senderId: string; teamId: string }>;
|
||||
expect(peerSignals.filter((signal) => signal.id === knowledgeRequestId)).toHaveLength(1);
|
||||
expect(peerSignals.filter((signal) => signal.id === taskRequestId)).toHaveLength(1);
|
||||
expect(peerSignals.every((signal) => signal.senderId === `run::${runA.id}`)).toBe(true);
|
||||
expect(peerSignals.every((signal) => signal.teamId === `room::${room.id}`)).toBe(true);
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json', 'x-waggle-run-token': tokenB },
|
||||
payload: {
|
||||
type: 'response', subtype: 'knowledge_match', referenceId: knowledgeRequestId,
|
||||
content: { matchedEntities: ['schema-owner'] },
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const responseId = response.json().message.id as string;
|
||||
|
||||
const broadcast = await server.inject({
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json', 'x-waggle-run-token': tokenB },
|
||||
payload: {
|
||||
type: 'broadcast', subtype: 'routed_share',
|
||||
content: { payload: { owner: 'run-b' } },
|
||||
},
|
||||
});
|
||||
expect(broadcast.statusCode).toBe(201);
|
||||
const broadcastId = broadcast.json().message.id as string;
|
||||
|
||||
const requesterRead = await server.inject({
|
||||
method: 'GET', url: '/api/waggle-dance/signals',
|
||||
headers: { 'x-waggle-run-token': tokenA },
|
||||
});
|
||||
expect(requesterRead.statusCode).toBe(200);
|
||||
const requesterSignals = requesterRead.json().signals as Array<{
|
||||
id: string; senderId: string; referenceId?: string | null;
|
||||
}>;
|
||||
for (const id of [knowledgeRequestId, taskRequestId, responseId, broadcastId]) {
|
||||
expect(requesterSignals.filter((signal) => signal.id === id)).toHaveLength(1);
|
||||
}
|
||||
expect(requesterSignals.find((signal) => signal.id === responseId)).toMatchObject({
|
||||
senderId: `run::${runB.id}`,
|
||||
referenceId: knowledgeRequestId,
|
||||
});
|
||||
|
||||
const isolatedRead = await server.inject({
|
||||
method: 'GET', url: '/api/waggle-dance/signals',
|
||||
headers: { 'x-waggle-run-token': tokenC },
|
||||
});
|
||||
expect(isolatedRead.statusCode).toBe(200);
|
||||
expect(isolatedRead.json().signals).toHaveLength(0);
|
||||
|
||||
const forbiddenCrossRoomRead = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/waggle-dance/signals?teamId=${encodeURIComponent(`room::${room.id}`)}`,
|
||||
headers: { 'x-waggle-run-token': tokenC },
|
||||
});
|
||||
expect(forbiddenCrossRoomRead.statusCode).toBe(403);
|
||||
expect(forbiddenCrossRoomRead.json().code).toBe('RUN_SCOPE_VIOLATION');
|
||||
});
|
||||
|
||||
it('GET returns recently posted signals newest-first', async () => {
|
||||
for (const topic of ['a', 'b', 'c']) {
|
||||
await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
senderId: 'test',
|
||||
content: { tool: 'cursor', topic },
|
||||
},
|
||||
});
|
||||
}
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/waggle-dance/signals',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.total).toBe(3);
|
||||
expect(body.signals[0].content.topic).toBe('c');
|
||||
expect(body.signals[2].content.topic).toBe('a');
|
||||
});
|
||||
|
||||
it('GET filters by subtype', async () => {
|
||||
await injectWithAuth(server, {
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast', subtype: 'discovery',
|
||||
senderId: 't', content: { tool: 'claude-code' },
|
||||
},
|
||||
});
|
||||
await injectWithAuth(server, {
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast', subtype: 'model_recipe',
|
||||
senderId: 't', content: { name: 'recipe-1', model: 'haiku' },
|
||||
},
|
||||
});
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/waggle-dance/signals?subtype=discovery',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.signals[0].subtype).toBe('discovery');
|
||||
});
|
||||
|
||||
it('GET filters by tool', async () => {
|
||||
for (const tool of ['claude-code', 'cursor', 'claude-code']) {
|
||||
await injectWithAuth(server, {
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast', subtype: 'discovery',
|
||||
senderId: 't', content: { tool },
|
||||
},
|
||||
});
|
||||
}
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/waggle-dance/signals?tool=claude-code',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().total).toBe(2);
|
||||
});
|
||||
|
||||
it('GET honors limit', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await injectWithAuth(server, {
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast', subtype: 'discovery',
|
||||
senderId: 't', content: { tool: 'x', idx: i },
|
||||
},
|
||||
});
|
||||
}
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/waggle-dance/signals?limit=2',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().signals).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('GET rejects unknown subtype filter', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/waggle-dance/signals?subtype=garbage',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
// ── round trip ────────────────────────────────────────────────────
|
||||
|
||||
it('POST → GET round trip preserves full message shape', async () => {
|
||||
const postRes = await injectWithAuth(server, {
|
||||
method: 'POST', url: '/api/waggle-dance/signal',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
senderId: 'claude-code-hook',
|
||||
content: { tool: 'claude-code', topic: 'rotation', importance: 'high' },
|
||||
},
|
||||
});
|
||||
const postedId = postRes.json().message.id;
|
||||
|
||||
const getRes = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/waggle-dance/signals',
|
||||
});
|
||||
const signals = getRes.json().signals;
|
||||
const found = signals.find((s: { id: string }) => s.id === postedId);
|
||||
expect(found).toBeTruthy();
|
||||
expect(found.content.topic).toBe('rotation');
|
||||
expect(found.senderId).toBe('claude-code-hook');
|
||||
expect(found.teamId).toBe('personal::claude-code-hook');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user