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,227 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { createSkillTools, type SkillToolsDeps } from '@waggle/agent';
import { loadSkills, needsConfirmation } from '@waggle/agent';
/**
* Integration tests for the Capability Acquisition Loop MVP.
*
* Tests the full flow: detect gap → search → propose → install → runtime availability.
* These tests use real filesystem operations to prove the loop is end-to-end real.
*/
describe('Capability Acquisition — Integration', () => {
let tmpDir: string;
let waggleHome: string;
let skillsDir: string;
let starterDir: string;
let tools: ReturnType<typeof createSkillTools>;
let skillsReloaded: boolean;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-acq-int-'));
waggleHome = path.join(tmpDir, '.waggle');
skillsDir = path.join(waggleHome, 'skills');
starterDir = path.join(tmpDir, 'starter-skills');
fs.mkdirSync(skillsDir, { recursive: true });
fs.mkdirSync(starterDir, { recursive: true });
// Create starter skills
fs.writeFileSync(
path.join(starterDir, 'risk-assessment.md'),
'# Risk Assessment — Project Risk Identification and Ranking\n\nSystematically identify, evaluate, and plan mitigations for project risks.\n\n## What to do\n1. Identify risks\n2. Evaluate likelihood and impact\n3. Plan mitigations',
);
fs.writeFileSync(
path.join(starterDir, 'daily-plan.md'),
'# Daily Plan — Structured Day Planning\n\nCreate a focused daily plan.\n\n## What to do\n1. Review tasks\n2. Prioritize\n3. Time-block',
);
// Pre-install one skill
fs.writeFileSync(
path.join(skillsDir, 'catch-up.md'),
'# Catch-up — Workspace Restart Summary\n\nGenerate a catch-up summary.',
);
skillsReloaded = false;
const deps: SkillToolsDeps = {
waggleHome,
starterSkillsDir: starterDir,
nativeToolNames: ['web_search', 'read_file', 'bash', 'search_memory'],
getInstalledSkills: () => loadSkills(waggleHome),
onSkillsChanged: () => { skillsReloaded = true; },
};
tools = createSkillTools(deps);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function getTool(name: string) {
const tool = tools.find(t => t.name === name);
if (!tool) throw new Error(`Tool "${name}" not found`);
return tool;
}
// ── Full acquisition flow ─────────────────────────────────────────
it('full flow: acquire → find starter skill → install → runtime available', async () => {
// Step 1: Detect gap — user needs risk assessment
const acquireTool = getTool('acquire_capability');
const proposal = await acquireTool.execute({ need: 'risk assessment for my project' });
// Proposal should identify the gap and recommend installation.
// The summary uses an inline-install marker (parsed by
// CapabilityRequestCard in the UI) as the modern action-of-record
// instead of naming the tool 'install_capability' inline.
expect(proposal).toContain('risk-assessment');
expect(proposal).toContain('<!--waggle:capability_request');
expect(proposal).toContain('starter-pack');
// Step 2: Verify the skill is NOT yet installed
const preInstallSkills = loadSkills(waggleHome);
expect(preInstallSkills.find(s => s.name === 'risk-assessment')).toBeUndefined();
// Step 3: Install the recommended skill
const installTool = getTool('install_capability');
const installResult = await installTool.execute({
name: 'risk-assessment',
source: 'starter-pack',
});
// Should succeed and return the skill content
expect(installResult).toContain('Skill Installed Successfully');
expect(installResult).toContain('risk-assessment');
expect(installResult).toContain('Active');
expect(installResult).toContain('mitigations'); // From the skill content
// Step 4: Verify hot-reload was triggered
expect(skillsReloaded).toBe(true);
// Step 5: Verify the skill is NOW installed on disk
const postInstallFile = path.join(skillsDir, 'risk-assessment.md');
expect(fs.existsSync(postInstallFile)).toBe(true);
// Step 6: Verify the skill is available via loadSkills (runtime availability)
const postInstallSkills = loadSkills(waggleHome);
const installedRisk = postInstallSkills.find(s => s.name === 'risk-assessment');
expect(installedRisk).toBeDefined();
expect(installedRisk!.content).toContain('mitigations');
// Step 7: Verify acquire_capability now shows it as active (no longer installable)
const recheck = await acquireTool.execute({ need: 'risk assessment' });
expect(recheck).toContain('already have');
expect(recheck).not.toContain('Capability Gap Detected');
});
// ── Approval gate integration ─────────────────────────────────────
it('install_capability triggers the approval gate', () => {
// The confirmation module should flag install_capability
expect(needsConfirmation('install_capability')).toBe(true);
});
it('install_capability does NOT trigger approval for read-only tools', () => {
expect(needsConfirmation('acquire_capability')).toBe(false);
expect(needsConfirmation('list_skills')).toBe(false);
expect(needsConfirmation('search_skills')).toBe(false);
expect(needsConfirmation('suggest_skill')).toBe(false);
});
// ── Candidate grounding (not blind install) ────────────────────────
it('rejects install of non-existent starter skill', async () => {
const installTool = getTool('install_capability');
const result = await installTool.execute({
name: 'nonexistent-skill',
source: 'starter-pack',
});
expect(result).toContain('Error');
expect(result).toContain('not found');
});
it('rejects install from unsupported source', async () => {
const installTool = getTool('install_capability');
const result = await installTool.execute({
name: 'risk-assessment',
source: 'marketplace',
});
expect(result).toContain('Error');
expect(result).toContain('not supported');
});
it('rejects install of already-installed skill', async () => {
// First install risk-assessment so it exists in both starter and installed
const installTool = getTool('install_capability');
await installTool.execute({ name: 'risk-assessment', source: 'starter-pack' });
// Now try to install it again
const result = await installTool.execute({
name: 'risk-assessment',
source: 'starter-pack',
});
expect(result).toContain('Error');
expect(result).toContain('already installed');
});
it('rejects path traversal in name', async () => {
const installTool = getTool('install_capability');
const result = await installTool.execute({
name: '../../../etc/passwd',
source: 'starter-pack',
});
expect(result).toContain('Error');
expect(result).toContain('Invalid');
});
// ── acquire_capability distinctions ────────────────────────────────
it('identifies native tools without suggesting install', async () => {
const acquireTool = getTool('acquire_capability');
const result = await acquireTool.execute({ need: 'search the web for information' });
expect(result).toContain('web_search');
expect(result).toContain('built-in tool');
expect(result).not.toContain('Capability Gap Detected');
});
it('distinguishes active skills from installable ones', async () => {
const acquireTool = getTool('acquire_capability');
const result = await acquireTool.execute({ need: 'catch up on workspace' });
// catch-up is already installed
expect(result).toContain('catch-up');
expect(result).toContain('already have');
});
it('returns meaningful response for empty need', async () => {
const acquireTool = getTool('acquire_capability');
const result = await acquireTool.execute({ need: '' });
expect(result).toContain('Error');
});
// ── Continuation proof (correction #5) ────────────────────────────
it('install returns full skill content so agent can apply it immediately', async () => {
const installTool = getTool('install_capability');
const result = await installTool.execute({
name: 'risk-assessment',
source: 'starter-pack',
});
// The result should contain the actual skill instructions
expect(result).toContain('## What to do');
expect(result).toContain('Identify risks');
expect(result).toContain('mitigations');
expect(result).toContain('apply this skill');
});
});

View File

@@ -0,0 +1,119 @@
/**
* PR4 Phase C — agent-search route pure helpers: need tokenization, the
* connector lane (the one searchCapabilities can't produce), engine-candidate
* annotation (id rejoin), and the one-of-each-kind three-up grouping.
*/
import { describe, it, expect } from 'vitest';
import type { ConnectorDefinition } from '@waggle/shared';
import type { CapabilityCandidate } from '@waggle/agent';
import {
tokenizeNeed, scoreConnectors, annotateEngineCandidate, pickThreeUp,
type AgentSearchCandidate,
} from '../../src/local/routes/agent-search.js';
const conn = (over: Partial<ConnectorDefinition>): ConnectorDefinition => ({
id: 'x', name: 'X', description: '', service: 'x', authType: 'bearer',
status: 'disconnected', capabilities: [], substrate: 'waggle', tools: [], category: 'misc',
...over,
});
describe('tokenizeNeed', () => {
it('drops stopwords + short tokens and dedupes', () => {
expect(tokenizeNeed('I need to send a Slack message about slack')).toEqual(['send', 'slack', 'message']);
});
it('returns empty for an all-stopword need', () => {
expect(tokenizeNeed('I can do it')).toEqual([]);
});
});
describe('scoreConnectors', () => {
const defs = [
conn({ id: 'slack', name: 'Slack', description: 'Team chat', service: 'slack', authType: 'bearer', tools: ['send_message'], category: 'comms' }),
conn({ id: 'gcal', name: 'Google Calendar', description: 'Calendar events', service: 'google', authType: 'oauth2', category: 'productivity' }),
conn({ id: 'github', name: 'GitHub', description: 'Code hosting', service: 'github', authType: 'bearer', status: 'connected', category: 'dev' }),
];
it('matches a token connector to a store-install descriptor with the namespaced id', () => {
const out = scoreConnectors(defs, 'send a slack message');
expect(out[0].name).toBe('Slack');
expect(out[0].type).toBe('connector');
expect(out[0].matchReason).toMatch(/slack/);
expect(out[0].install).toEqual({ mode: 'store', extensionId: 'connector:slack', type: 'connector', kind: 'federated', authType: 'bearer' });
});
it('routes an OAuth connector to the Hub (open-in), never an inline token', () => {
const out = scoreConnectors(defs, 'add a calendar event');
const gcal = out.find(c => c.name === 'Google Calendar');
expect(gcal?.install).toEqual({ mode: 'open-in', appId: 'connectors' });
});
it('an already-connected match is active (no install action)', () => {
const out = scoreConnectors(defs, 'github code hosting');
const gh = out.find(c => c.name === 'GitHub');
expect(gh?.availability).toBe('active');
expect(gh?.installAction).toBeNull();
expect(gh?.install).toEqual({ mode: 'active' });
});
it('returns nothing when no token matches', () => {
expect(scoreConnectors(defs, 'quantum chromodynamics')).toEqual([]);
});
});
describe('annotateEngineCandidate', () => {
const cand = (over: Partial<CapabilityCandidate>): CapabilityCandidate => ({
name: 'c', type: 'marketplace', availability: 'installable', description: '',
source: 'marketplace', matchScore: 0.5, matchReason: 'why', installAction: 'install_capability', ...over,
});
const byName = new Map([
['web-scraper', { id: 7, waggle_install_type: 'skill' as const }],
['pg-mcp', { id: 9, waggle_install_type: 'mcp' as const }],
]);
it('rejoins a marketplace candidate to its package id (skill)', () => {
const a = annotateEngineCandidate(cand({ name: 'web-scraper' }), byName);
expect(a.install).toEqual({ mode: 'store', extensionId: 'pkg:7', type: 'skill', kind: 'package', packageId: 7 });
});
it('maps an mcp package via waggle_install_type', () => {
const a = annotateEngineCandidate(cand({ name: 'pg-mcp' }), byName);
expect(a.install).toMatchObject({ mode: 'store', extensionId: 'pkg:9', type: 'mcp', packageId: 9 });
});
it('an installable starter skill installs via the starter-pack path', () => {
const a = annotateEngineCandidate(cand({ name: 'pdf', type: 'skill', source: 'starter-pack' }), byName);
expect(a.install).toEqual({ mode: 'starter-pack', name: 'pdf' });
});
it('a native tool is active (already available)', () => {
const a = annotateEngineCandidate(cand({ name: 'web_search', type: 'native', availability: 'active', installAction: null }), byName);
expect(a.install).toEqual({ mode: 'active' });
});
});
describe('pickThreeUp', () => {
it('returns one of each kind, highest-scored first', () => {
const mk = (over: Partial<AgentSearchCandidate>): AgentSearchCandidate => ({
name: 'n', type: 'skill', availability: 'installable', description: '', source: 's',
matchScore: 0.5, matchReason: 'why', installAction: null, install: { mode: 'active' }, ...over,
});
const all: AgentSearchCandidate[] = [
mk({ name: 'Slack', type: 'connector', matchScore: 0.9 }),
mk({ name: 'pdf-skill', type: 'skill', matchScore: 0.7 }),
mk({ name: 'web_search', type: 'native', matchScore: 0.6 }),
mk({ name: 'other-skill', type: 'skill', matchScore: 0.3 }),
];
const picks = pickThreeUp(all);
expect(picks.connector?.name).toBe('Slack');
expect(picks.skill?.name).toBe('pdf-skill'); // first skill-ish by order
expect(picks.tool?.name).toBe('web_search');
});
it('never places one candidate in two slots (an mcp-package fills skill OR tool, not both)', () => {
const mcpPkg: AgentSearchCandidate = {
name: 'pg-mcp', type: 'marketplace', availability: 'installable', description: '', source: 'marketplace',
matchScore: 0.8, matchReason: 'why', installAction: 'install_capability',
install: { mode: 'store', extensionId: 'pkg:9', type: 'mcp', kind: 'package', packageId: 9 },
};
const picks = pickThreeUp([mcpPkg]);
expect(picks.skill?.name).toBe('pg-mcp'); // satisfies the skill predicate first
expect(picks.tool).toBeUndefined(); // already used → not duplicated into tool
});
});

View File

@@ -0,0 +1,307 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildServer } from '../../src/index.js';
import { users, agents, agentGroups, agentGroupMembers, agentJobs, teams } from '../../src/db/schema.js';
import { sql, eq } from 'drizzle-orm';
describe('Agent API', () => {
let server: Awaited<ReturnType<typeof buildServer>>;
let user1Id: string;
let user2Id: string;
let teamId: string;
let agent1Id: string;
let agent2Id: string;
beforeAll(async () => {
server = await buildServer();
// Clean up leftover test data
await server.db.execute(sql`DELETE FROM agent_group_members WHERE group_id IN (SELECT id FROM agent_groups WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'agtest_%'))`);
await server.db.execute(sql`DELETE FROM agent_jobs WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'agtest_%')`);
await server.db.execute(sql`DELETE FROM agent_groups WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'agtest_%')`);
await server.db.execute(sql`DELETE FROM agents WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'agtest_%')`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'agtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'agtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'agtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'agtest-%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'agtest-%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'agtest_%'`);
// Create test users
const [u1] = await server.db.insert(users).values({
clerkId: 'agtest_user1',
displayName: 'Agent User 1',
email: 'agentuser1@test.com',
}).returning();
user1Id = u1.id;
const [u2] = await server.db.insert(users).values({
clerkId: 'agtest_user2',
displayName: 'Agent User 2',
email: 'agentuser2@test.com',
}).returning();
user2Id = u2.id;
// Create a team for job tests
const [team] = await server.db.insert(teams).values({
name: 'Agent Test Team',
slug: 'agtest-team',
ownerId: user1Id,
}).returning();
teamId = team.id;
// Override auth handler for tests
server._authHandler.fn = async function (request, reply) {
const testUserId = request.headers['x-test-user-id'] as string;
if (!testUserId) {
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
}
request.userId = testUserId;
request.clerkId = 'test';
};
});
afterAll(async () => {
await server.db.execute(sql`DELETE FROM agent_group_members WHERE group_id IN (SELECT id FROM agent_groups WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'agtest_%'))`);
await server.db.execute(sql`DELETE FROM agent_jobs WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'agtest_%')`);
await server.db.execute(sql`DELETE FROM agent_groups WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'agtest_%')`);
await server.db.execute(sql`DELETE FROM agents WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'agtest_%')`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'agtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'agtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'agtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'agtest-%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'agtest-%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'agtest_%'`);
await server.close();
});
it('creates an agent with custom model, tools, and system prompt', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/agents',
headers: { 'x-test-user-id': user1Id },
payload: {
name: 'Research Agent',
role: 'researcher',
systemPrompt: 'You are a research agent.',
model: 'claude-sonnet-4-20250514',
tools: ['web_search', 'read_file'],
config: { maxTokens: 4096 },
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Research Agent');
expect(body.role).toBe('researcher');
expect(body.systemPrompt).toBe('You are a research agent.');
expect(body.model).toBe('claude-sonnet-4-20250514');
expect(body.tools).toEqual(['web_search', 'read_file']);
expect(body.config).toEqual({ maxTokens: 4096 });
expect(body.userId).toBe(user1Id);
agent1Id = body.id;
});
it('creates a second agent for user1', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/agents',
headers: { 'x-test-user-id': user1Id },
payload: {
name: 'Coding Agent',
model: 'claude-haiku-4-5',
tools: ['execute_code'],
},
});
expect(response.statusCode).toBe(201);
agent2Id = JSON.parse(response.body).id;
});
it('lists agents returns only the user\'s agents', async () => {
// Create an agent for user2
await server.inject({
method: 'POST',
url: '/api/agents',
headers: { 'x-test-user-id': user2Id },
payload: { name: 'User2 Agent' },
});
const response = await server.inject({
method: 'GET',
url: '/api/agents',
headers: { 'x-test-user-id': user1Id },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(2);
expect(body.every((a: { userId: string }) => a.userId === user1Id)).toBe(true);
});
it('updates agent model and config', async () => {
const response = await server.inject({
method: 'PATCH',
url: `/api/agents/${agent1Id}`,
headers: { 'x-test-user-id': user1Id },
payload: {
model: 'claude-haiku-4-5',
config: { maxTokens: 2048, temperature: 0.7 },
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.model).toBe('claude-haiku-4-5');
expect(body.config).toEqual({ maxTokens: 2048, temperature: 0.7 });
});
it('user cannot update another user\'s agent', async () => {
const response = await server.inject({
method: 'PATCH',
url: `/api/agents/${agent1Id}`,
headers: { 'x-test-user-id': user2Id },
payload: { model: 'hacked-model' },
});
expect(response.statusCode).toBe(403);
});
it('user cannot delete another user\'s agent', async () => {
const response = await server.inject({
method: 'DELETE',
url: `/api/agents/${agent1Id}`,
headers: { 'x-test-user-id': user2Id },
});
expect(response.statusCode).toBe(403);
});
it('creates an agent group with parallel strategy and 2 members', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/agent-groups',
headers: { 'x-test-user-id': user1Id },
payload: {
name: 'Research Squad',
description: 'Parallel research team',
strategy: 'parallel',
members: [
{ agentId: agent1Id, roleInGroup: 'worker', executionOrder: 0 },
{ agentId: agent2Id, roleInGroup: 'worker', executionOrder: 0 },
],
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Research Squad');
expect(body.strategy).toBe('parallel');
expect(body.members).toHaveLength(2);
});
it('creates a group with coordinator strategy and a lead agent', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/agent-groups',
headers: { 'x-test-user-id': user1Id },
payload: {
name: 'Coordinated Team',
strategy: 'coordinator',
members: [
{ agentId: agent1Id, roleInGroup: 'lead', executionOrder: 0 },
{ agentId: agent2Id, roleInGroup: 'worker', executionOrder: 1 },
],
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.strategy).toBe('coordinator');
const lead = body.members.find((m: { roleInGroup: string; agentId: string }) => m.roleInGroup === 'lead');
expect(lead).toBeDefined();
expect(lead.agentId).toBe(agent1Id);
});
it('gets group with members', async () => {
// List groups to get an ID
const listResponse = await server.inject({
method: 'GET',
url: '/api/agent-groups',
headers: { 'x-test-user-id': user1Id },
});
expect(listResponse.statusCode).toBe(200);
const groups = JSON.parse(listResponse.body);
expect(groups.length).toBeGreaterThanOrEqual(2);
const groupId = groups[0].id;
const response = await server.inject({
method: 'GET',
url: `/api/agent-groups/${groupId}`,
headers: { 'x-test-user-id': user1Id },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(groupId);
expect(body.members).toBeDefined();
expect(Array.isArray(body.members)).toBe(true);
});
it('executes group run — queues a job and returns 202 with jobId', async () => {
const listResponse = await server.inject({
method: 'GET',
url: '/api/agent-groups',
headers: { 'x-test-user-id': user1Id },
});
const groups = JSON.parse(listResponse.body);
const groupId = groups[0].id;
const response = await server.inject({
method: 'POST',
url: `/api/agent-groups/${groupId}/run`,
headers: { 'x-test-user-id': user1Id },
payload: {
task: 'Research the latest AI papers',
teamId,
},
});
expect(response.statusCode).toBe(202);
const body = JSON.parse(response.body);
expect(body.jobId).toBeDefined();
expect(typeof body.jobId).toBe('string');
// Verify the job was created in the database
const [job] = await server.db
.select()
.from(agentJobs)
.where(eq(agentJobs.id, body.jobId))
.limit(1);
expect(job).toBeDefined();
expect(job.status).toBe('queued');
expect(job.jobType).toBe('group');
expect(job.userId).toBe(user1Id);
});
it('deletes an agent', async () => {
const response = await server.inject({
method: 'DELETE',
url: `/api/agents/${agent2Id}`,
headers: { 'x-test-user-id': user1Id },
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const listResponse = await server.inject({
method: 'GET',
url: '/api/agents',
headers: { 'x-test-user-id': user1Id },
});
const remaining = JSON.parse(listResponse.body);
const ids = remaining.map((a: { id: string }) => a.id);
expect(ids).not.toContain(agent2Id);
});
});

View File

@@ -0,0 +1,261 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildServer } from '../../src/index.js';
import { users, teams, teamMembers, agentAuditLog, agentJobs, teamCapabilityRequests } from '../../src/db/schema.js';
import { sql } from 'drizzle-orm';
describe('Analytics API', () => {
let server: Awaited<ReturnType<typeof buildServer>>;
let ownerId: string;
let memberId: string;
let teamId: string;
beforeAll(async () => {
server = await buildServer();
// Clean up any leftover test data
await server.db.execute(sql`DELETE FROM agent_audit_log WHERE agent_name = 'analytics-test'`);
await server.db.execute(sql`DELETE FROM agent_jobs WHERE job_type = 'analytics-test'`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE justification = 'analytics-test'`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug = 'test-analytics')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug = 'test-analytics')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug = 'test-analytics'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'antest_%'`);
// Create test users
const [owner] = await server.db.insert(users).values({
clerkId: 'antest_owner',
displayName: 'Analytics Owner',
email: 'analytics-owner@test.com',
}).returning();
ownerId = owner.id;
const [member] = await server.db.insert(users).values({
clerkId: 'antest_member',
displayName: 'Analytics Member',
email: 'analytics-member@test.com',
}).returning();
memberId = member.id;
// Create team
const [team] = await server.db.insert(teams).values({
name: 'Analytics Test Team',
slug: 'test-analytics',
ownerId,
}).returning();
teamId = team.id;
// Add members
await server.db.insert(teamMembers).values([
{ teamId, userId: ownerId, role: 'owner' },
{ teamId, userId: memberId, role: 'member' },
]);
// Override auth handler for tests
server._authHandler.fn = async function (request, reply) {
const testUserId = request.headers['x-test-user-id'] as string;
if (!testUserId) {
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
}
request.userId = testUserId;
request.clerkId = 'test';
};
});
afterAll(async () => {
await server.db.execute(sql`DELETE FROM agent_audit_log WHERE agent_name = 'analytics-test'`);
await server.db.execute(sql`DELETE FROM agent_jobs WHERE job_type = 'analytics-test'`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE justification = 'analytics-test'`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug = 'test-analytics')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug = 'test-analytics')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug = 'test-analytics'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'antest_%'`);
await server.close();
});
it('GET /api/admin/teams/:slug/analytics returns correct shape with zeroes for empty data', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/admin/teams/test-analytics/analytics',
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
// Verify top-level shape
expect(body).toHaveProperty('activeUsers');
expect(body).toHaveProperty('tokenUsage');
expect(body).toHaveProperty('topTools');
expect(body).toHaveProperty('topCommands');
expect(body).toHaveProperty('capabilityGaps');
expect(body).toHaveProperty('performanceTrends');
// activeUsers shape
expect(body.activeUsers).toHaveProperty('daily');
expect(body.activeUsers).toHaveProperty('weekly');
expect(body.activeUsers).toHaveProperty('monthly');
expect(typeof body.activeUsers.daily).toBe('number');
expect(typeof body.activeUsers.weekly).toBe('number');
expect(typeof body.activeUsers.monthly).toBe('number');
// tokenUsage shape
expect(typeof body.tokenUsage.total).toBe('number');
expect(Array.isArray(body.tokenUsage.byUser)).toBe(true);
// topTools and topCommands are arrays
expect(Array.isArray(body.topTools)).toBe(true);
expect(Array.isArray(body.topCommands)).toBe(true);
expect(Array.isArray(body.capabilityGaps)).toBe(true);
// performanceTrends shape
expect(typeof body.performanceTrends.correctionRate).toBe('number');
expect(typeof body.performanceTrends.correctionTrend).toBe('number');
expect(typeof body.performanceTrends.avgResponseTime).toBe('number');
// With no activity, everything should be zeroes
expect(body.activeUsers.daily).toBe(0);
expect(body.activeUsers.weekly).toBe(0);
expect(body.activeUsers.monthly).toBe(0);
expect(body.tokenUsage.total).toBe(0);
expect(body.topTools).toHaveLength(0);
expect(body.topCommands).toHaveLength(0);
expect(body.capabilityGaps).toHaveLength(0);
expect(body.performanceTrends.correctionRate).toBe(0);
expect(body.performanceTrends.avgResponseTime).toBe(0);
});
it('returns 401 without auth header', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/admin/teams/test-analytics/analytics',
});
expect(response.statusCode).toBe(401);
});
it('returns 403 for non-admin member', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/admin/teams/test-analytics/analytics',
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(403);
});
it('returns 404 for non-existent team', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/admin/teams/nonexistent-team/analytics',
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(404);
});
it('returns data when audit log entries exist', async () => {
// Insert some audit log entries
await server.db.insert(agentAuditLog).values([
{
userId: ownerId,
teamId,
agentName: 'analytics-test',
actionType: 'web_search',
description: 'test search 1',
},
{
userId: ownerId,
teamId,
agentName: 'analytics-test',
actionType: 'web_search',
description: 'test search 2',
},
{
userId: ownerId,
teamId,
agentName: 'analytics-test',
actionType: 'save_memory',
description: 'test memory save',
},
{
userId: ownerId,
teamId,
agentName: 'analytics-test',
actionType: 'command:/research',
description: 'test command',
},
]);
const response = await server.inject({
method: 'GET',
url: '/api/admin/teams/test-analytics/analytics',
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
// Should detect the active user
expect(body.activeUsers.daily).toBeGreaterThanOrEqual(1);
expect(body.activeUsers.weekly).toBeGreaterThanOrEqual(1);
expect(body.activeUsers.monthly).toBeGreaterThanOrEqual(1);
// Should show top tools
expect(body.topTools.length).toBeGreaterThanOrEqual(1);
const webSearch = body.topTools.find((t: { name: string }) => t.name === 'web_search');
expect(webSearch).toBeDefined();
expect(webSearch.invocations).toBeGreaterThanOrEqual(2);
// Should show top commands
expect(body.topCommands.length).toBeGreaterThanOrEqual(1);
const research = body.topCommands.find((c: { name: string }) => c.name === '/research');
expect(research).toBeDefined();
expect(research.count).toBeGreaterThanOrEqual(1);
});
it('returns capability gaps from pending requests', async () => {
// Insert a pending capability request
await server.db.insert(teamCapabilityRequests).values({
teamId,
requestedBy: memberId,
capabilityName: 'email_send',
capabilityType: 'tool',
justification: 'analytics-test',
status: 'pending',
});
const response = await server.inject({
method: 'GET',
url: '/api/admin/teams/test-analytics/analytics',
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.capabilityGaps.length).toBeGreaterThanOrEqual(1);
const emailGap = body.capabilityGaps.find((g: { tool: string }) => g.tool === 'email_send');
expect(emailGap).toBeDefined();
expect(emailGap.requestCount).toBeGreaterThanOrEqual(1);
expect(emailGap.suggestion).toContain('email_send');
});
it('includes byUser token estimates in tokenUsage', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/admin/teams/test-analytics/analytics',
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
// byUser should include team members
expect(body.tokenUsage.byUser.length).toBeGreaterThanOrEqual(1);
const ownerEntry = body.tokenUsage.byUser.find((u: { userId: string }) => u.userId === ownerId);
expect(ownerEntry).toBeDefined();
expect(ownerEntry.name).toBe('Analytics Owner');
expect(typeof ownerEntry.tokens).toBe('number');
expect(typeof ownerEntry.cost).toBe('number');
});
});

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
/**
* Tests the server-side approval flow:
* pending approval → POST approve → promise resolves
*
* This proves the HTTP POST path that the UI's approveAction() calls.
*/
describe('Approval Flow — Server Side', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-approval-test-'));
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('POST /api/approval/:id resolves a pending approval promise', async () => {
const requestId = 'test-approval-123';
let resolved = false;
let approvedValue: boolean | undefined;
// Simulate what the chat route does: create a pending approval
const approvalPromise = new Promise<boolean>((resolve) => {
server.agentState.pendingApprovals.set(requestId, {
resolve,
toolName: 'install_capability',
input: { name: 'risk-assessment', source: 'starter-pack' },
timestamp: Date.now(),
});
});
approvalPromise.then((v) => { resolved = true; approvedValue = v; });
// Verify it's pending
expect(server.agentState.pendingApprovals.has(requestId)).toBe(true);
// Simulate what the UI does: POST approval
const res = await injectWithAuth(server, {
method: 'POST',
url: `/api/approval/${requestId}`,
payload: { approved: true },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.ok).toBe(true);
expect(body.approved).toBe(true);
// Wait for the promise to resolve
const result = await approvalPromise;
expect(result).toBe(true);
expect(resolved).toBe(true);
expect(approvedValue).toBe(true);
// Pending approval should be cleaned up
expect(server.agentState.pendingApprovals.has(requestId)).toBe(false);
});
it('POST /api/approval/:id with approved=false denies', async () => {
const requestId = 'test-deny-456';
const approvalPromise = new Promise<boolean>((resolve) => {
server.agentState.pendingApprovals.set(requestId, {
resolve,
toolName: 'install_capability',
input: { name: 'risk-assessment', source: 'starter-pack' },
timestamp: Date.now(),
});
});
const res = await injectWithAuth(server, {
method: 'POST',
url: `/api/approval/${requestId}`,
payload: { approved: false },
});
expect(res.statusCode).toBe(200);
const result = await approvalPromise;
expect(result).toBe(false);
});
it('POST /api/approval/:id returns 404 for unknown requestId', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/approval/nonexistent-id',
payload: { approved: true },
});
expect(res.statusCode).toBe(404);
});
it('GET /api/approval/pending lists pending approvals', async () => {
const requestId = 'test-pending-789';
server.agentState.pendingApprovals.set(requestId, {
resolve: () => {},
toolName: 'install_capability',
input: { name: 'daily-plan', source: 'starter-pack' },
timestamp: Date.now(),
});
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/approval/pending',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.count).toBeGreaterThanOrEqual(1);
const found = body.pending.find((p: { requestId: string }) => p.requestId === requestId);
expect(found).toBeDefined();
expect(found.toolName).toBe('install_capability');
// Cleanup
server.agentState.pendingApprovals.delete(requestId);
});
});

View File

@@ -0,0 +1,250 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB, SessionStore, FrameStore } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
describe('Capabilities Route', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-caps-test-'));
// Create personal.mind (required by buildLocalServer)
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const sessions = new SessionStore(mind);
const frames = new FrameStore(mind);
const s1 = sessions.create('test');
frames.createIFrame(s1.gop_id, 'Test content', 'normal');
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('GET /api/capabilities/status returns 200', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
expect(res.statusCode).toBe(200);
});
it('returns correct structure with empty plugins/MCP and populated commands', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
const body = JSON.parse(res.body);
expect(body).toHaveProperty('plugins');
expect(body).toHaveProperty('mcpServers');
expect(body).toHaveProperty('skills');
expect(body).toHaveProperty('tools');
expect(body).toHaveProperty('commands');
expect(Array.isArray(body.plugins)).toBe(true);
expect(Array.isArray(body.mcpServers)).toBe(true);
expect(Array.isArray(body.skills)).toBe(true);
expect(Array.isArray(body.commands)).toBe(true);
expect(body.plugins).toEqual([]);
expect(body.mcpServers).toEqual([]);
// Commands populated by registerWorkflowCommands + registerMarketplaceCommands at startup
expect(body.commands.length).toBeGreaterThanOrEqual(13);
});
it('tools summary has count, native, plugin, mcp fields', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
const body = JSON.parse(res.body);
expect(body.tools).toHaveProperty('count');
expect(body.tools).toHaveProperty('native');
expect(body.tools).toHaveProperty('plugin');
expect(body.tools).toHaveProperty('mcp');
expect(typeof body.tools.count).toBe('number');
expect(typeof body.tools.native).toBe('number');
expect(body.tools.plugin).toBe(0);
expect(body.tools.mcp).toBe(0);
});
it('native tool count equals total when no plugins or MCP', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
const body = JSON.parse(res.body);
expect(body.tools.native).toBe(body.tools.count);
expect(body.tools.count).toBeGreaterThan(0); // at least some native tools exist
});
it('returns plugin data when pluginRuntimeManager is present', async () => {
// Mock a pluginRuntimeManager on agentState
const mockManager = {
getPluginStates: () => ({ 'test-plugin': 'active', 'other-plugin': 'disabled' }),
getAllTools: () => [
{ name: 'test-plugin:tool1' },
{ name: 'test-plugin:tool2' },
],
getAllSkills: () => ['test-plugin:summarize'],
};
(server.agentState as Record<string, unknown>).pluginRuntimeManager = mockManager;
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
const body = JSON.parse(res.body);
expect(body.plugins).toHaveLength(2);
expect(body.plugins[0].name).toBe('test-plugin');
expect(body.plugins[0].state).toBe('active');
expect(body.plugins[0].tools).toBe(2);
expect(body.plugins[0].skills).toBe(1);
expect(body.plugins[1].name).toBe('other-plugin');
expect(body.plugins[1].state).toBe('disabled');
expect(body.plugins[1].tools).toBe(0);
expect(body.tools.plugin).toBe(2);
// Cleanup
(server.agentState as Record<string, unknown>).pluginRuntimeManager = null;
});
it('returns MCP data when mcpRuntime is present', async () => {
const mockMcp = {
getServerStates: () => ({ 'fs-server': 'ready', 'db-server': 'error' }),
getAllTools: () => [
{ name: 'fs-server:readFile' },
{ name: 'fs-server:writeFile' },
{ name: 'db-server:query' },
],
getHealthy: () => [{ config: { name: 'fs-server' } }],
};
(server.agentState as Record<string, unknown>).mcpRuntime = mockMcp;
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
const body = JSON.parse(res.body);
expect(body.mcpServers).toHaveLength(2);
const fsServer = body.mcpServers.find((s: { name: string }) => s.name === 'fs-server');
expect(fsServer).toBeDefined();
expect(fsServer.state).toBe('ready');
expect(fsServer.healthy).toBe(true);
expect(fsServer.tools).toBe(2);
const dbServer = body.mcpServers.find((s: { name: string }) => s.name === 'db-server');
expect(dbServer).toBeDefined();
expect(dbServer.state).toBe('error');
expect(dbServer.healthy).toBe(false);
expect(dbServer.tools).toBe(1);
expect(body.tools.mcp).toBe(3);
// Cleanup
(server.agentState as Record<string, unknown>).mcpRuntime = null;
});
it('returns workflow + marketplace commands from the wired CommandRegistry', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
const body = JSON.parse(res.body);
expect(body.commands.length).toBeGreaterThanOrEqual(13);
// Verify all expected workflow commands are present
const commandNames = body.commands.map((c: { name: string }) => c.name);
expect(commandNames).toContain('catchup');
expect(commandNames).toContain('now');
expect(commandNames).toContain('research');
expect(commandNames).toContain('draft');
expect(commandNames).toContain('decide');
expect(commandNames).toContain('review');
expect(commandNames).toContain('spawn');
expect(commandNames).toContain('skills');
expect(commandNames).toContain('status');
expect(commandNames).toContain('memory');
expect(commandNames).toContain('plan');
expect(commandNames).toContain('focus');
expect(commandNames).toContain('help');
// Each command has the expected shape
for (const cmd of body.commands) {
expect(cmd).toHaveProperty('name');
expect(cmd).toHaveProperty('description');
expect(cmd).toHaveProperty('usage');
expect(typeof cmd.name).toBe('string');
expect(typeof cmd.description).toBe('string');
expect(cmd.usage).toMatch(/^\//); // usage starts with /
}
});
it('returns hooks object with registered count and recentActivity array', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
const body = JSON.parse(res.body);
expect(body).toHaveProperty('hooks');
expect(body.hooks).toHaveProperty('registered');
expect(body.hooks).toHaveProperty('recentActivity');
expect(body.hooks.registered).toBe(10);
expect(Array.isArray(body.hooks.recentActivity)).toBe(true);
});
it('returns workflows array with 5 built-in templates', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
const body = JSON.parse(res.body);
expect(body).toHaveProperty('workflows');
expect(Array.isArray(body.workflows)).toBe(true);
expect(body.workflows).toHaveLength(5);
const names = body.workflows.map((w: { name: string }) => w.name);
expect(names).toContain('research-team');
expect(names).toContain('review-pair');
expect(names).toContain('plan-execute');
expect(names).toContain('ticket-resolve');
expect(names).toContain('content-pipeline');
for (const wf of body.workflows) {
expect(wf).toHaveProperty('name');
expect(wf).toHaveProperty('description');
expect(wf).toHaveProperty('steps');
expect(typeof wf.name).toBe('string');
expect(typeof wf.description).toBe('string');
expect(typeof wf.steps).toBe('number');
expect(wf.steps).toBeGreaterThan(0);
expect(wf.description.length).toBeGreaterThan(0);
}
});
it('tool count breakdown is correct with both plugins and MCP', async () => {
const mockManager = {
getPluginStates: () => ({ 'p1': 'active' }),
getAllTools: () => [{ name: 'p1:t1' }, { name: 'p1:t2' }, { name: 'p1:t3' }],
getAllSkills: () => [],
};
const mockMcp = {
getServerStates: () => ({ 's1': 'ready' }),
getAllTools: () => [{ name: 's1:read' }, { name: 's1:write' }],
getHealthy: () => [{ config: { name: 's1' } }],
};
(server.agentState as Record<string, unknown>).pluginRuntimeManager = mockManager;
(server.agentState as Record<string, unknown>).mcpRuntime = mockMcp;
const res = await injectWithAuth(server, { method: 'GET', url: '/api/capabilities/status' });
const body = JSON.parse(res.body);
const totalNative = server.agentState.allTools.length;
const nativeCount = totalNative - 3 - 2;
expect(body.tools.native).toBe(nativeCount);
expect(body.tools.plugin).toBe(3);
expect(body.tools.mcp).toBe(2);
expect(body.tools.count).toBe(nativeCount + 3 + 2);
// Cleanup
(server.agentState as Record<string, unknown>).pluginRuntimeManager = null;
(server.agentState as Record<string, unknown>).mcpRuntime = null;
});
});

View File

@@ -0,0 +1,9 @@
import { describe, it, expect } from 'vitest';
describe('Capability Governance Routes', () => {
it('exports capabilityGovernanceRoutes function', async () => {
const mod = await import('../../src/routes/capability-governance.js');
expect(mod.capabilityGovernanceRoutes).toBeDefined();
expect(typeof mod.capabilityGovernanceRoutes).toBe('function');
});
});

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB, SessionStore, FrameStore } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
describe('Capability Packs API', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-packs-'));
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const sessions = new SessionStore(mind);
const frames = new FrameStore(mind);
const s1 = sessions.create('test');
frames.createIFrame(s1.gop_id, 'Test content', 'normal');
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('GET /api/skills/capability-packs/catalog returns packs with skill states', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/skills/capability-packs/catalog' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.packs).toBeDefined();
expect(body.packs.length).toBeGreaterThanOrEqual(5);
const research = body.packs.find((p: { id: string }) => p.id === 'research-workflow');
expect(research).toBeDefined();
expect(research.name).toBe('Research Workflow');
expect(research.skills).toHaveLength(3);
expect(research.skillStates).toBeDefined();
// Pack may be 'available' or 'complete' depending on whether starter skills were auto-installed
expect(['available', 'complete', 'partial']).toContain(research.packState);
});
it('POST /api/skills/capability-packs/:id installs all skills in pack', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/skills/capability-packs/writing-suite',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
// Skills may already be installed from auto-install; installed = newly installed in this call
// After install, all 3 skills should exist on disk regardless
expect(body.ok).toBe(true);
});
it('re-installing pack skips already-installed skills', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/skills/capability-packs/writing-suite',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.installed).toHaveLength(0);
expect(body.skipped).toHaveLength(3);
});
it('pack state reflects installed skills', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/skills/capability-packs/catalog' });
const body = JSON.parse(res.body);
const writing = body.packs.find((p: { id: string }) => p.id === 'writing-suite');
expect(writing.packState).toBe('complete');
expect(writing.installedCount).toBe(3);
});
it('POST nonexistent pack returns 404', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/skills/capability-packs/nonexistent-pack',
});
expect(res.statusCode).toBe(404);
});
it('packs with path traversal return 400', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/skills/capability-packs/..%2F..%2Fetc',
});
// URL-encoded path traversal should be caught by validation
expect([400, 404]).toContain(res.statusCode);
});
});

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB, SessionStore } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
describe('POST /api/command/interpret', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-interpret-test-'));
const mind = new MindDB(path.join(tmpDir, 'personal.mind'));
const sessions = new SessionStore(mind);
sessions.create('test');
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* OS cleans temp */ }
});
it('400s when text is missing', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/command/interpret',
payload: {},
});
expect(res.statusCode).toBe(400);
});
it('degrades gracefully to a Tier-0 fallback when no model key is configured', async () => {
// No anthropic key in the test vault → the resolver returns the structured
// fallback (HTTP 200) so the palette can fall back to Tier 0 cleanly.
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/command/interpret',
payload: { text: 'make a new workspace for the Phoenix project', workspaceId: 'default' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.kind).toBe('none');
expect(body.fallback).toBe(true);
});
});

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB, SessionStore, FrameStore } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
describe('Command Execution Route', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-cmd-test-'));
// Create personal.mind (required by buildLocalServer)
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const sessions = new SessionStore(mind);
const frames = new FrameStore(mind);
const s1 = sessions.create('test');
frames.createIFrame(s1.gop_id, 'Test memory about architecture decisions', 'normal');
frames.createIFrame(s1.gop_id, 'Another memory about deployment', 'important');
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Non-critical — OS will clean temp dir
}
});
it('POST /api/commands/execute with /help returns markdown command list', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/help' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.command).toBe('/help');
expect(body.result).toContain('Available Commands');
expect(body.result).toContain('/catchup');
expect(body.result).toContain('/memory');
expect(body.result).toContain('/skills');
});
it('POST /api/commands/execute with /skills returns skill list', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/skills' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.command).toBe('/skills');
expect(body.result).toContain('Active Skills');
// Server starts with loaded skills — at least the result should be well-formed
expect(typeof body.result).toBe('string');
});
it('POST /api/commands/execute with /memory and query returns search results', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/memory architecture decisions' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.command).toBe('/memory architecture decisions');
expect(body.result).toContain('Memory Search');
expect(body.result).toContain('architecture decisions');
});
it('POST /api/commands/execute with /catchup returns workspace state', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/catchup' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.command).toBe('/catchup');
expect(body.result).toContain('Catch-Up Briefing');
});
it('POST /api/commands/execute with empty command returns 400', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '' },
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toBe('command is required');
});
it('POST /api/commands/execute with /research (no runWorkflow) returns agent-loop reroute', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/research quantum computing' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// B2: Without runWorkflow, /research returns agent-loop reroute instruction
expect(body.result).toContain('Research the following topic');
expect(body.result).toContain('quantum computing');
});
it('POST /api/commands/execute with /decide (no runWorkflow) returns agent-loop reroute', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/decide Should we use PostgreSQL or MongoDB?' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// B7: Without runWorkflow, /decide returns agent-loop reroute instruction
expect(body.result).toContain('decision');
expect(body.result).toContain('PostgreSQL or MongoDB');
});
it('POST /api/commands/execute with unknown command returns error', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/nonexistent' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.result).toContain('Unknown command');
});
it('POST /api/commands/execute with /spawn (no spawnAgent) returns agent-loop reroute', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/spawn researcher find papers' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// B4: Without spawnAgent, /spawn returns agent-loop reroute instruction
expect(body.result).toContain('specialist researcher');
});
it('POST /api/commands/execute persists and applies CLI allowlist changes', async () => {
const allowRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/cli allow node' },
});
expect(allowRes.statusCode).toBe(200);
expect(JSON.parse(allowRes.body).result).toContain('Allowed "node"');
const listRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/cli' },
});
expect(JSON.parse(listRes.body).result).toContain('Allowed CLI tools: node');
const denyRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/commands/execute',
payload: { command: '/cli deny node' },
});
expect(JSON.parse(denyRes.body).result).toContain('Denied "node"');
});
});

View File

@@ -0,0 +1,33 @@
/**
* Connector tier cap (review LOW #1, founder-requested enforcement). After the
* Solo/Team collapse EVERY tier has an unlimited connectorLimit (-1), so
* connectorCapExceeded never caps a real tier. These tests pin that unlimited
* contract; re-connecting an already-connected connector (token refresh) is a
* no-op regardless.
*/
import { describe, it, expect } from 'vitest';
import { getCapabilities } from '@waggle/shared';
import { connectorCapExceeded } from '../../src/local/routes/connectors.js';
describe('connectorCapExceeded', () => {
it('FREE (Solo) is unlimited (-1) — never capped', () => {
expect(getCapabilities('FREE').connectorLimit).toBe(-1);
const many = Array.from({ length: 50 }, (_, i) => `c${i}`);
expect(connectorCapExceeded('FREE', many, 'new-one')).toBeNull();
});
it('FREE re-connecting an already-connected connector → allowed (token refresh)', () => {
const many = Array.from({ length: 50 }, (_, i) => `c${i}`);
expect(connectorCapExceeded('FREE', many, 'c0')).toBeNull();
});
it('TEAMS is unlimited — never capped', () => {
const many = Array.from({ length: 50 }, (_, i) => `c${i}`);
expect(connectorCapExceeded('TEAMS', many, 'new-one')).toBeNull();
});
it('TRIAL is unlimited — never capped', () => {
const many = Array.from({ length: 50 }, (_, i) => `c${i}`);
expect(connectorCapExceeded('TRIAL', many, 'new-one')).toBeNull();
});
});

View File

@@ -0,0 +1,265 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB } from '@waggle/core';
import {
buildWorkspaceNowBlock,
formatWorkspaceNowPrompt,
} from '../../src/local/routes/workspace-context.js';
/**
* Tests for workspace context injection into the agent system prompt.
*
* The actual `buildSystemPrompt` function is a closure inside the chat route plugin,
* so we test the injection logic at the boundary: buildWorkspaceNowBlock + formatWorkspaceNowPrompt
* produce the correct content, and verify the integration contract (what gets appended to the prompt).
*
* The chat route code:
* if (workspaceId) {
* const nowBlock = buildWorkspaceNowBlock({ ... });
* if (nowBlock) prompt += '\n\n' + formatWorkspaceNowPrompt(nowBlock);
* }
*/
describe('Context Injection — system prompt integration', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ctx-inject-'));
});
afterEach(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Non-critical on Windows
}
});
// ── Helpers ───────────────────────────────────────────────────────────
function setupWorkspace(id: string, opts?: {
frames?: Array<{ content: string; importance: string }>;
sessions?: Array<{ title: string; messages: Array<{ role: string; content: string }> }>;
}) {
const wsDir = path.join(tmpDir, 'workspaces', id);
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(
path.join(wsDir, 'workspace.json'),
JSON.stringify({ id, name: `Test ${id}`, group: 'test', created: new Date().toISOString() }),
);
const mindPath = path.join(wsDir, 'workspace.mind');
if (opts?.frames && opts.frames.length > 0) {
const db = new MindDB(mindPath);
const raw = db.getDatabase();
raw.prepare(
`INSERT INTO sessions (gop_id, status, started_at) VALUES ('session:test', 'active', datetime('now'))`,
).run();
for (const frame of opts.frames) {
raw.prepare(
`INSERT INTO memory_frames (gop_id, frame_type, content, importance, created_at) VALUES ('session:test', 'I', ?, ?, datetime('now'))`,
).run(frame.content, frame.importance);
}
db.close();
}
if (opts?.sessions) {
const sessDir = path.join(wsDir, 'sessions');
fs.mkdirSync(sessDir, { recursive: true });
for (let i = 0; i < opts.sessions.length; i++) {
const sess = opts.sessions[i];
const lines: string[] = [
JSON.stringify({ type: 'meta', title: sess.title, created: new Date().toISOString() }),
];
for (const msg of sess.messages) {
lines.push(JSON.stringify({ role: msg.role, content: msg.content }));
}
fs.writeFileSync(path.join(sessDir, `session-${i}.jsonl`), lines.join('\n') + '\n');
}
}
}
function makeManager(workspaces: Map<string, { id: string; name: string }>) {
return {
get: (id: string) => workspaces.get(id) ?? null,
getMindPath: (id: string) => path.join(tmpDir, 'workspaces', id, 'workspace.mind'),
};
}
const noopActivate = (_id: string) => true;
/**
* Simulate what buildSystemPrompt does: build a base prompt, then conditionally
* append the workspace context. This mirrors the injection logic in chat.ts.
*/
function simulatePromptBuild(basePrompt: string, workspaceId?: string, wsManager?: ReturnType<typeof makeManager>) {
let prompt = basePrompt;
if (workspaceId && wsManager) {
try {
const nowBlock = buildWorkspaceNowBlock({
dataDir: tmpDir,
workspaceId,
wsManager,
activateWorkspaceMind: noopActivate,
});
if (nowBlock) {
prompt += '\n\n' + formatWorkspaceNowPrompt(nowBlock);
}
} catch {
// Non-blocking — mirrors the try/catch in chat.ts
}
}
return prompt;
}
// ── Test 1: No workspaceId → no "Workspace Now" section ─────────────
it('buildSystemPrompt without workspaceId produces no "Workspace Now" section', () => {
const basePrompt = '# Who You Are\n\nYou are Waggle.';
const result = simulatePromptBuild(basePrompt);
expect(result).not.toContain('# Workspace Now');
expect(result).toBe(basePrompt);
});
// ── Test 2: With workspaceId + data → includes "Workspace Now" ──────
it('buildSystemPrompt with workspaceId for workspace with data includes "Workspace Now"', () => {
const wsManager = makeManager(new Map([
['rich-ws', { id: 'rich-ws', name: 'Rich Project' }],
]));
setupWorkspace('rich-ws', {
frames: [
{ content: 'Working on the Waggle platform, a workspace-native AI agent.', importance: 'important' },
{ content: 'Decision: Use SQLite for local persistence.', importance: 'critical' },
],
sessions: [
{
title: 'Architecture Planning',
messages: [
{ role: 'user', content: 'Plan the architecture' },
{ role: 'assistant', content: 'Here is a plan.' },
],
},
],
});
const basePrompt = '# Who You Are\n\nYou are Waggle.';
const result = simulatePromptBuild(basePrompt, 'rich-ws', wsManager);
expect(result).toContain('# Workspace Now');
expect(result).toContain('Rich Project');
// Should still contain the base prompt
expect(result).toContain('# Who You Are');
// Workspace Now should be appended after the base prompt
const baseEnd = result.indexOf('# Who You Are');
const wsNowStart = result.indexOf('# Workspace Now');
expect(wsNowStart).toBeGreaterThan(baseEnd);
});
// ── Test 3: With workspaceId for empty/missing workspace → graceful ─
it('buildSystemPrompt with workspaceId for empty workspace has no "Workspace Now" (graceful)', () => {
const wsManager = makeManager(new Map([
['empty-ws', { id: 'empty-ws', name: 'Empty Project' }],
]));
// Create workspace dir but no mind file
const wsDir = path.join(tmpDir, 'workspaces', 'empty-ws');
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(
path.join(wsDir, 'workspace.json'),
JSON.stringify({ id: 'empty-ws', name: 'Empty Project', group: 'test', created: new Date().toISOString() }),
);
const basePrompt = '# Who You Are\n\nYou are Waggle.';
const result = simulatePromptBuild(basePrompt, 'empty-ws', wsManager);
expect(result).not.toContain('# Workspace Now');
expect(result).toBe(basePrompt);
});
it('buildSystemPrompt with workspaceId for non-existent workspace has no "Workspace Now"', () => {
const wsManager = makeManager(new Map());
const basePrompt = '# Who You Are\n\nYou are Waggle.';
const result = simulatePromptBuild(basePrompt, 'does-not-exist', wsManager);
expect(result).not.toContain('# Workspace Now');
expect(result).toBe(basePrompt);
});
// ── Test 4: Cache invalidation — different workspaceId → different prompt ─
it('different workspaceId produces different prompt content', () => {
const wsManager = makeManager(new Map([
['ws-a', { id: 'ws-a', name: 'Project Alpha' }],
['ws-b', { id: 'ws-b', name: 'Project Beta' }],
]));
setupWorkspace('ws-a', {
frames: [
{ content: 'Alpha is a front-end framework project.', importance: 'important' },
{ content: 'Decision: Use React for Alpha.', importance: 'critical' },
],
});
setupWorkspace('ws-b', {
frames: [
{ content: 'Beta is a backend API project.', importance: 'important' },
{ content: 'Decision: Use Rust for Beta.', importance: 'critical' },
],
});
const basePrompt = '# Who You Are\n\nYou are Waggle.';
const promptA = simulatePromptBuild(basePrompt, 'ws-a', wsManager);
const promptB = simulatePromptBuild(basePrompt, 'ws-b', wsManager);
// Both should have Workspace Now
expect(promptA).toContain('# Workspace Now');
expect(promptB).toContain('# Workspace Now');
// But with different workspace names
expect(promptA).toContain('Project Alpha');
expect(promptA).not.toContain('Project Beta');
expect(promptB).toContain('Project Beta');
expect(promptB).not.toContain('Project Alpha');
// Prompts should be different
expect(promptA).not.toBe(promptB);
});
// ── Test 5: Verify the formatted block is positioned correctly ──────
it('workspace context is appended after the base prompt, not prepended', () => {
const wsManager = makeManager(new Map([
['pos-ws', { id: 'pos-ws', name: 'Position Test' }],
]));
setupWorkspace('pos-ws', {
frames: [
{ content: 'Testing prompt positioning.', importance: 'important' },
],
});
const basePrompt = 'BASE_PROMPT_START\nSome agent instructions.\nBASE_PROMPT_END';
const result = simulatePromptBuild(basePrompt, 'pos-ws', wsManager);
// Base prompt should appear first
expect(result.startsWith('BASE_PROMPT_START')).toBe(true);
// Workspace Now should come after base prompt
const baseEndIdx = result.indexOf('BASE_PROMPT_END');
const wsNowIdx = result.indexOf('# Workspace Now');
expect(wsNowIdx).toBeGreaterThan(baseEndIdx);
// Separated by double newline
const between = result.slice(baseEndIdx + 'BASE_PROMPT_END'.length, wsNowIdx);
expect(between).toBe('\n\n');
});
});

View File

@@ -0,0 +1,231 @@
/**
* Cron REST API Route Tests
*
* Validates CRUD operations and manual trigger for the Solo cron service.
* Uses the same pattern as trust-wiring.test.ts — tmpDir + buildLocalServer + server.inject().
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
describe('Cron REST API Routes', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-cron-api-'));
// Create personal.mind
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('POST /api/cron creates a schedule and returns camelCase response', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/cron',
payload: {
name: 'Test job',
cronExpr: '*/5 * * * *',
jobType: 'memory_consolidation',
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Test job');
expect(body.cronExpr).toBe('*/5 * * * *');
expect(body.nextRunAt).toBeDefined();
expect(body.enabled).toBe(true);
});
it('POST /api/cron with invalid cron expression returns 400', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/cron',
payload: {
name: 'Bad cron',
cronExpr: 'not-a-valid-cron',
jobType: 'memory_consolidation',
},
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
});
it('GET /api/cron lists schedules including seeded defaults', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/cron',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.schedules).toBeDefined();
expect(Array.isArray(body.schedules)).toBe(true);
// Should have at least the 2 seeded defaults + 1 created in previous test
expect(body.schedules.length).toBeGreaterThanOrEqual(3);
// Check that seeded defaults are present
const names = body.schedules.map((s: { name: string }) => s.name);
expect(names).toContain('Memory consolidation');
expect(names).toContain('Workspace health check');
});
it('PATCH /api/cron/:id updates name and enabled flag', async () => {
// First create a schedule to update
const createRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/cron',
payload: {
name: 'To be updated',
cronExpr: '0 12 * * *',
jobType: 'workspace_health',
},
});
const created = JSON.parse(createRes.body);
// Update it
const res = await injectWithAuth(server, {
method: 'PATCH',
url: `/api/cron/${created.id}`,
payload: {
name: 'Updated name',
enabled: false,
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.name).toBe('Updated name');
expect(body.enabled).toBe(false);
});
it('DELETE /api/cron/:id removes schedule; subsequent GET returns 404', async () => {
// Create a schedule to delete
const createRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/cron',
payload: {
name: 'To be deleted',
cronExpr: '0 0 * * *',
jobType: 'memory_consolidation',
},
});
const created = JSON.parse(createRes.body);
// Delete it
const delRes = await injectWithAuth(server, {
method: 'DELETE',
url: `/api/cron/${created.id}`,
});
expect(delRes.statusCode).toBe(200);
// Verify it's gone
const getRes = await injectWithAuth(server, {
method: 'GET',
url: `/api/cron/${created.id}`,
});
expect(getRes.statusCode).toBe(404);
});
it('POST /api/cron/:id/trigger manually triggers a schedule', async () => {
// Create a schedule to trigger
const createRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/cron',
payload: {
name: 'Manual trigger test',
cronExpr: '0 0 1 1 *', // yearly — not naturally due
jobType: 'memory_consolidation',
},
});
const created = JSON.parse(createRes.body);
const res = await injectWithAuth(server, {
method: 'POST',
url: `/api/cron/${created.id}/trigger`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.triggered).toBe(true);
});
// M-43 / P25 regression: triggering a disabled job must auto-enable it
// before executing and return the enabled state to the client so the UI
// toggle stops visually snapping back to off.
it('POST /api/cron/:id/trigger auto-enables a disabled schedule', async () => {
const createRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/cron',
payload: {
name: 'Auto-enable on trigger',
cronExpr: '0 0 1 1 *',
jobType: 'memory_consolidation',
enabled: false,
},
});
const created = JSON.parse(createRes.body);
expect(created.enabled).toBe(false);
const triggerRes = await injectWithAuth(server, {
method: 'POST',
url: `/api/cron/${created.id}/trigger`,
});
expect(triggerRes.statusCode).toBe(200);
const body = JSON.parse(triggerRes.body);
expect(body.triggered).toBe(true);
expect(body.autoEnabled).toBe(true);
expect(body.schedule).toBeDefined();
expect(body.schedule.enabled).toBe(true);
// Persisted: subsequent GET reflects enabled state.
const getRes = await injectWithAuth(server, {
method: 'GET',
url: `/api/cron/${created.id}`,
});
expect(getRes.statusCode).toBe(200);
const fresh = JSON.parse(getRes.body);
expect(fresh.enabled).toBe(true);
});
it('POST /api/cron/:id/trigger does not set autoEnabled when job is already enabled', async () => {
const createRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/cron',
payload: {
name: 'Already enabled trigger',
cronExpr: '0 0 1 1 *',
jobType: 'memory_consolidation',
enabled: true,
},
});
const created = JSON.parse(createRes.body);
const triggerRes = await injectWithAuth(server, {
method: 'POST',
url: `/api/cron/${created.id}/trigger`,
});
const body = JSON.parse(triggerRes.body);
expect(body.triggered).toBe(true);
expect(body.autoEnabled).toBe(false);
expect(body.schedule?.enabled).toBe(true);
});
});

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
describe('Health Endpoint', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-health-test-'));
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns structured health with llm and database status', async () => {
const res = await server.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.mode).toBe('local');
expect(body.timestamp).toBeTruthy();
// LLM section
expect(body.llm).toBeDefined();
expect(body.llm.provider).toMatch(/^(litellm|anthropic-proxy|ollama)$/);
expect(body.llm.health).toMatch(/^(healthy|degraded|unavailable)$/);
expect(body.llm.detail).toBeTruthy();
expect(body.llm.checkedAt).toBeTruthy();
// Database section
expect(body.database).toBeDefined();
expect(body.database.healthy).toBe(true);
});
it('overall status reflects LLM health', async () => {
// Default: llmProvider was set to unavailable (no init via startService)
const res = await server.inject({ method: 'GET', url: '/health' });
const body = JSON.parse(res.payload);
// Since we didn't go through startService, llmProvider defaults to unavailable
// Overall status should be 'unavailable' or 'degraded', not 'ok'
expect(body.status).not.toBe('ok');
});
it('reports healthy when LLM provider is marked healthy', async () => {
// Simulate a healthy provider
server.agentState.llmProvider = {
provider: 'litellm',
health: 'healthy',
detail: 'LiteLLM on port 4000',
checkedAt: new Date().toISOString(),
};
const res = await server.inject({ method: 'GET', url: '/health' });
const body = JSON.parse(res.payload);
expect(body.status).toBe('ok');
expect(body.llm.provider).toBe('litellm');
expect(body.llm.health).toBe('healthy');
expect(body.llm.reachable).toBe(true);
});
it('reports a healthy resolved provider as reachable even when the offline probe is stale', async () => {
const offlineProbe = server.offlineManager as unknown as { _offline: boolean };
const previousOffline = offlineProbe._offline;
offlineProbe._offline = true;
server.agentState.llmProvider = {
provider: 'ollama',
health: 'healthy',
detail: 'Local Ollama model',
checkedAt: new Date().toISOString(),
};
try {
const res = await server.inject({ method: 'GET', url: '/health' });
const body = JSON.parse(res.payload);
expect(body.status).toBe('ok');
expect(body.llm.health).toBe('healthy');
expect(body.llm.reachable).toBe(true);
} finally {
offlineProbe._offline = previousOffline;
}
});
it('reports degraded when LLM provider is configured but not verified', async () => {
server.agentState.llmProvider = {
provider: 'anthropic-proxy',
health: 'degraded',
detail: 'Built-in Anthropic proxy (no API key)',
checkedAt: new Date().toISOString(),
};
const res = await server.inject({ method: 'GET', url: '/health' });
const body = JSON.parse(res.payload);
expect(body.status).toBe('degraded');
expect(body.llm.health).toBe('degraded');
});
it('reports unavailable when no LLM path works', async () => {
server.agentState.llmProvider = {
provider: 'anthropic-proxy',
health: 'unavailable',
detail: 'No working LLM path',
checkedAt: new Date().toISOString(),
};
const res = await server.inject({ method: 'GET', url: '/health' });
const body = JSON.parse(res.payload);
expect(body.status).toBe('unavailable');
});
});

View File

@@ -0,0 +1,291 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildServer } from '../../src/index.js';
import { users, teams, teamMembers, teamEntities, teamRelations } from '../../src/db/schema.js';
import { sql } from 'drizzle-orm';
describe('Team Knowledge Graph API', () => {
let server: Awaited<ReturnType<typeof buildServer>>;
let ownerId: string;
let memberId: string;
let outsiderId: string;
let teamSlug: string;
let teamId: string;
beforeAll(async () => {
server = await buildServer();
// Clean up any leftover test data
await server.db.execute(sql`DELETE FROM team_relations WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_entities WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'kgtest-%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'kgtest_%'`);
// Create test users
const [owner] = await server.db.insert(users).values({
clerkId: 'kgtest_owner',
displayName: 'KG Owner',
email: 'kgtest_owner@test.com',
}).returning();
ownerId = owner.id;
const [member] = await server.db.insert(users).values({
clerkId: 'kgtest_member',
displayName: 'KG Member',
email: 'kgtest_member@test.com',
}).returning();
memberId = member.id;
const [outsider] = await server.db.insert(users).values({
clerkId: 'kgtest_outsider',
displayName: 'KG Outsider',
email: 'kgtest_outsider@test.com',
}).returning();
outsiderId = outsider.id;
// Create a team with owner + member
const [team] = await server.db.insert(teams).values({
name: 'KG Test Team',
slug: 'kgtest-knowledge',
ownerId,
}).returning();
teamId = team.id;
teamSlug = team.slug;
await server.db.insert(teamMembers).values([
{ teamId, userId: ownerId, role: 'owner' },
{ teamId, userId: memberId, role: 'member' },
]);
// Override auth handler
server._authHandler.fn = async function (request, reply) {
const testUserId = request.headers['x-test-user-id'] as string;
if (!testUserId) {
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
}
request.userId = testUserId;
request.clerkId = 'test';
};
});
afterAll(async () => {
await server.db.execute(sql`DELETE FROM team_relations WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_entities WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'kgtest-%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'kgtest-%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'kgtest_%'`);
await server.close();
});
it('creates an entity with properties and valid_from/valid_to', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': ownerId },
payload: {
entityType: 'concept',
name: 'Machine Learning',
properties: { domain: 'AI', level: 'advanced' },
validFrom: '2026-01-01T00:00:00.000Z',
validTo: '2027-01-01T00:00:00.000Z',
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.entityType).toBe('concept');
expect(body.name).toBe('Machine Learning');
expect(body.properties).toEqual({ domain: 'AI', level: 'advanced' });
expect(body.sharedBy).toBe(ownerId);
expect(body.teamId).toBe(teamId);
expect(body.validFrom).toBeTruthy();
expect(body.validTo).toBeTruthy();
});
it('searches entities by type', async () => {
// Create entities of different types
await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': ownerId },
payload: { entityType: 'person', name: 'Alice' },
});
await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': ownerId },
payload: { entityType: 'person', name: 'Bob' },
});
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/entities?type=person`,
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
expect(body.every((e: { entityType: string }) => e.entityType === 'person')).toBe(true);
});
it('searches entities by name (ILIKE)', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/entities?search=machine`,
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.length).toBeGreaterThanOrEqual(1);
expect(body.some((e: { name: string }) => e.name === 'Machine Learning')).toBe(true);
});
it('creates a relation with confidence score', async () => {
// Create two entities to relate
const res1 = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': ownerId },
payload: { entityType: 'tool', name: 'TensorFlow' },
});
const entity1 = JSON.parse(res1.body);
const res2 = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': ownerId },
payload: { entityType: 'concept', name: 'Deep Learning' },
});
const entity2 = JSON.parse(res2.body);
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/relations`,
headers: { 'x-test-user-id': ownerId },
payload: {
sourceId: entity1.id,
targetId: entity2.id,
relationType: 'implements',
confidence: 0.95,
properties: { since: '2015' },
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.sourceId).toBe(entity1.id);
expect(body.targetId).toBe(entity2.id);
expect(body.relationType).toBe('implements');
expect(body.confidence).toBeCloseTo(0.95);
expect(body.teamId).toBe(teamId);
});
it('graph traversal returns connected entities up to depth N', async () => {
// Create a chain: A -> B -> C
const resA = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': ownerId },
payload: { entityType: 'node', name: 'Node-A' },
});
const nodeA = JSON.parse(resA.body);
const resB = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': ownerId },
payload: { entityType: 'node', name: 'Node-B' },
});
const nodeB = JSON.parse(resB.body);
const resC = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': ownerId },
payload: { entityType: 'node', name: 'Node-C' },
});
const nodeC = JSON.parse(resC.body);
// A -> B
await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/relations`,
headers: { 'x-test-user-id': ownerId },
payload: { sourceId: nodeA.id, targetId: nodeB.id, relationType: 'links_to' },
});
// B -> C
await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/relations`,
headers: { 'x-test-user-id': ownerId },
payload: { sourceId: nodeB.id, targetId: nodeC.id, relationType: 'links_to' },
});
// Depth 1: should find A and B only
const res1 = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/graph?startId=${nodeA.id}&depth=1`,
headers: { 'x-test-user-id': ownerId },
});
expect(res1.statusCode).toBe(200);
const graph1 = JSON.parse(res1.body);
const entityIds1 = graph1.entities.map((e: { id: string }) => e.id);
expect(entityIds1).toContain(nodeA.id);
expect(entityIds1).toContain(nodeB.id);
expect(entityIds1).not.toContain(nodeC.id);
// Depth 2: should find A, B, and C
const res2 = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/graph?startId=${nodeA.id}&depth=2`,
headers: { 'x-test-user-id': ownerId },
});
expect(res2.statusCode).toBe(200);
const graph2 = JSON.parse(res2.body);
const entityIds2 = graph2.entities.map((e: { id: string }) => e.id);
expect(entityIds2).toContain(nodeA.id);
expect(entityIds2).toContain(nodeB.id);
expect(entityIds2).toContain(nodeC.id);
expect(graph2.relations.length).toBeGreaterThanOrEqual(2);
});
it('shared_by tracks who contributed', async () => {
// Owner creates entity
const res1 = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': ownerId },
payload: { entityType: 'doc', name: 'Owner Doc' },
});
expect(JSON.parse(res1.body).sharedBy).toBe(ownerId);
// Member creates entity
const res2 = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': memberId },
payload: { entityType: 'doc', name: 'Member Doc' },
});
expect(JSON.parse(res2.body).sharedBy).toBe(memberId);
});
it('non-member gets 403', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/entities`,
headers: { 'x-test-user-id': outsiderId },
});
expect(response.statusCode).toBe(403);
});
});

View File

@@ -0,0 +1,392 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildServer } from '../../src/index.js';
import { users, teams, teamMembers, teamEntities, tasks, messages } from '../../src/db/schema.js';
import { sql } from 'drizzle-orm';
describe('Waggle Dance Messages API', () => {
let server: Awaited<ReturnType<typeof buildServer>>;
let ownerId: string;
let memberId: string;
let outsiderId: string;
let teamSlug: string;
let teamId: string;
const SLUG_PREFIX = 'msgtest-';
beforeAll(async () => {
server = await buildServer();
// Clean up any leftover test data (order matters for FK constraints)
await server.db.execute(sql`DELETE FROM messages WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_entities WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM tasks WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'}`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'msgtest_%'`);
// Create test users
const [owner] = await server.db.insert(users).values({
clerkId: 'msgtest_owner',
displayName: 'Msg Owner',
email: 'msgtest_owner@test.com',
}).returning();
ownerId = owner.id;
const [member] = await server.db.insert(users).values({
clerkId: 'msgtest_member',
displayName: 'Msg Member',
email: 'msgtest_member@test.com',
}).returning();
memberId = member.id;
const [outsider] = await server.db.insert(users).values({
clerkId: 'msgtest_outsider',
displayName: 'Msg Outsider',
email: 'msgtest_outsider@test.com',
}).returning();
outsiderId = outsider.id;
// Create a team with owner + member
const [team] = await server.db.insert(teams).values({
name: 'Msg Test Team',
slug: `${SLUG_PREFIX}waggle`,
ownerId,
}).returning();
teamId = team.id;
teamSlug = team.slug;
await server.db.insert(teamMembers).values([
{ teamId, userId: ownerId, role: 'owner' },
{ teamId, userId: memberId, role: 'member' },
]);
// Seed some team_entities and tasks for hive-check tests
await server.db.insert(teamEntities).values([
{ teamId, entityType: 'concept', name: 'Machine Learning', properties: { domain: 'AI' }, sharedBy: ownerId },
{ teamId, entityType: 'tool', name: 'TensorFlow', properties: { lang: 'python' }, sharedBy: memberId },
{ teamId, entityType: 'concept', name: 'Database Design', properties: { domain: 'engineering' }, sharedBy: ownerId },
]);
await server.db.insert(tasks).values([
{ teamId, title: 'Implement ML pipeline', description: 'Build a machine learning pipeline', status: 'open', createdBy: ownerId },
{ teamId, title: 'Fix login bug', description: 'Login page crashes on mobile', status: 'done', createdBy: memberId },
]);
// Override auth handler
server._authHandler.fn = async function (request, reply) {
const testUserId = request.headers['x-test-user-id'] as string;
if (!testUserId) {
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
}
request.userId = testUserId;
request.clerkId = 'test';
};
});
afterAll(async () => {
await server.db.execute(sql`DELETE FROM messages WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_entities WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM tasks WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'}`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'msgtest_%'`);
await server.close();
});
it('sends a broadcast message and returns 201', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': ownerId },
payload: {
type: 'broadcast',
subtype: 'discovery',
content: { topic: 'New skill available', details: 'web-scraper v2' },
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.type).toBe('broadcast');
expect(body.subtype).toBe('discovery');
expect(body.content).toEqual({ topic: 'New skill available', details: 'web-scraper v2' });
expect(body.senderId).toBe(ownerId);
expect(body.teamId).toBe(teamId);
expect(body.id).toBeDefined();
});
it('sends a request message with valid type-subtype combo', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': memberId },
payload: {
type: 'request',
subtype: 'knowledge_check',
content: { query: 'Who knows about React?' },
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.type).toBe('request');
expect(body.subtype).toBe('knowledge_check');
});
it('rejects invalid type-subtype combination with 400', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': ownerId },
payload: {
type: 'request',
subtype: 'discovery', // discovery is a broadcast subtype, not request
content: { query: 'test' },
},
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Invalid type-subtype combination');
});
it('stores routing field when provided', async () => {
const routing = [
{ userId: memberId, reason: 'Expert in ML' },
];
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': ownerId },
payload: {
type: 'broadcast',
subtype: 'routed_share',
content: { data: 'ML model results' },
routing,
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.routing).toEqual(routing);
});
it('stores referenceId when provided', async () => {
// First create a message to reference
const firstRes = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': memberId },
payload: {
type: 'request',
subtype: 'task_delegation',
content: { task: 'Review PR #42' },
},
});
const firstMsg = JSON.parse(firstRes.body);
// Send a response referencing the first message
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': ownerId },
payload: {
type: 'response',
subtype: 'task_claim',
content: { accepted: true },
referenceId: firstMsg.id,
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.referenceId).toBe(firstMsg.id);
});
it('lists messages for the team', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1);
});
it('filters messages by type', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/messages?type=broadcast`,
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.every((m: { type: string }) => m.type === 'broadcast')).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1);
});
it('filters messages by subtype', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/messages?subtype=discovery`,
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.every((m: { subtype: string }) => m.subtype === 'discovery')).toBe(true);
});
it('publishes message to Redis channel on send', async () => {
// Subscribe to the team channel before sending
const receivedMessages: string[] = [];
await server.redisSub.subscribe(`team:${teamId}:waggle`);
server.redisSub.on('message', (_channel: string, message: string) => {
receivedMessages.push(message);
});
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': ownerId },
payload: {
type: 'broadcast',
subtype: 'skill_share',
content: { skill: 'data-analysis', version: '1.0' },
},
});
expect(response.statusCode).toBe(201);
// Give Redis a moment to deliver
await new Promise((resolve) => setTimeout(resolve, 200));
expect(receivedMessages.length).toBeGreaterThanOrEqual(1);
const published = JSON.parse(receivedMessages[receivedMessages.length - 1]);
expect(published.subtype).toBe('skill_share');
expect(published.content).toEqual({ skill: 'data-analysis', version: '1.0' });
// Cleanup subscription
await server.redisSub.unsubscribe(`team:${teamId}:waggle`);
server.redisSub.removeAllListeners('message');
});
it('non-member gets 403 when sending messages', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': outsiderId },
payload: {
type: 'broadcast',
subtype: 'discovery',
content: { test: true },
},
});
expect(response.statusCode).toBe(403);
});
it('non-member gets 403 when listing messages', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/messages`,
headers: { 'x-test-user-id': outsiderId },
});
expect(response.statusCode).toBe(403);
});
describe('hive-check', () => {
it('finds matching entities by topic', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages/hive-check`,
headers: { 'x-test-user-id': ownerId },
payload: { topic: 'Machine Learning' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.entities.length).toBeGreaterThanOrEqual(1);
expect(body.entities.some((e: { name: string }) => e.name === 'Machine Learning')).toBe(true);
});
it('finds matching tasks by topic', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages/hive-check`,
headers: { 'x-test-user-id': memberId },
payload: { topic: 'ML pipeline' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.relatedTasks.length).toBeGreaterThanOrEqual(1);
expect(body.relatedTasks.some((t: { title: string }) => t.title.includes('ML pipeline'))).toBe(true);
});
it('finds matching broadcast messages', async () => {
// We already sent broadcast messages earlier in the test suite
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages/hive-check`,
headers: { 'x-test-user-id': ownerId },
payload: { topic: 'anything' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.relatedMessages).toBeDefined();
expect(Array.isArray(body.relatedMessages)).toBe(true);
});
it('returns empty results for non-matching topic', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages/hive-check`,
headers: { 'x-test-user-id': ownerId },
payload: { topic: 'xyzzy_nonexistent_topic_12345' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.entities).toHaveLength(0);
expect(body.relatedTasks).toHaveLength(0);
});
it('non-member gets 403 on hive-check', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages/hive-check`,
headers: { 'x-test-user-id': outsiderId },
payload: { topic: 'test' },
});
expect(response.statusCode).toBe(403);
});
it('validates hive-check input', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/messages/hive-check`,
headers: { 'x-test-user-id': ownerId },
payload: {}, // missing topic
});
expect(response.statusCode).toBe(400);
});
});
});

View File

@@ -0,0 +1,254 @@
import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
/** Skill ID used for persistence tests */
const TEST_SKILL = 'retrospective';
/** Minimal shape of a catalog/skills-list entry as returned over the JSON API. */
type CatalogSkillEntry = { id: string; name: string };
describe('Capability Persistence & Cross-Surface Agreement', () => {
const tmpDirs: string[] = [];
function makeTmpDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-persist-test-'));
// Create skills dir with marker to prevent auto-install of starter skills
const skillsDir = path.join(dir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(path.join(skillsDir, '.starter-installed'), 'test');
tmpDirs.push(dir);
return dir;
}
function initMind(dataDir: string): void {
const personalPath = path.join(dataDir, 'personal.mind');
const mind = new MindDB(personalPath);
mind.close();
}
afterAll(() => {
for (const dir of tmpDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// ── Restart Coherence ──────────────────────────────────────────────
it('installed skill survives server restart', async () => {
const dataDir = makeTmpDir();
initMind(dataDir);
// 1. Build server, install a skill
const server1 = await buildLocalServer({ dataDir });
const installRes = await injectWithAuth(server1, {
method: 'POST',
url: `/api/skills/starter-pack/${TEST_SKILL}`,
});
expect(installRes.statusCode).toBe(200);
// Verify skill file exists on disk
const skillPath = path.join(dataDir, 'skills', `${TEST_SKILL}.md`);
expect(fs.existsSync(skillPath)).toBe(true);
// 2. Close server
await server1.close();
// 3. Rebuild with same dataDir
const server2 = await buildLocalServer({ dataDir });
// 4. Verify skill is active after restart
const catalogRes = await injectWithAuth(server2, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
expect(catalogRes.statusCode).toBe(200);
const catalog = catalogRes.json();
const skill = catalog.skills.find((s: CatalogSkillEntry) => s.id === TEST_SKILL);
expect(skill, `${TEST_SKILL} should exist in catalog after restart`).toBeDefined();
expect(skill.state).toBe('active');
await server2.close();
});
it('multiple installed skills all survive restart', async () => {
const dataDir = makeTmpDir();
initMind(dataDir);
const skillsToInstall = ['retrospective', 'daily-plan', 'brainstorm'];
// Install skills
const server1 = await buildLocalServer({ dataDir });
for (const id of skillsToInstall) {
const res = await injectWithAuth(server1, {
method: 'POST',
url: `/api/skills/starter-pack/${id}`,
});
expect(res.statusCode).toBe(200);
}
await server1.close();
// Restart and verify all are active
const server2 = await buildLocalServer({ dataDir });
const catalogRes = await injectWithAuth(server2, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
const catalog = catalogRes.json();
for (const id of skillsToInstall) {
const skill = catalog.skills.find((s: CatalogSkillEntry) => s.id === id);
expect(skill, `${id} should exist after restart`).toBeDefined();
expect(skill.state, `${id} should be active after restart`).toBe('active');
}
await server2.close();
});
// ── Cross-Surface Agreement ────────────────────────────────────────
it('all endpoints agree on skill state after install', async () => {
const dataDir = makeTmpDir();
initMind(dataDir);
const server = await buildLocalServer({ dataDir });
// Install a skill
const installRes = await injectWithAuth(server, {
method: 'POST',
url: `/api/skills/starter-pack/${TEST_SKILL}`,
});
expect(installRes.statusCode).toBe(200);
// Surface 1: Catalog endpoint
const catalogRes = await injectWithAuth(server, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
const catalog = catalogRes.json();
const catalogSkill = catalog.skills.find((s: CatalogSkillEntry) => s.id === TEST_SKILL);
expect(catalogSkill, 'catalog should contain installed skill').toBeDefined();
expect(catalogSkill.state).toBe('active');
// Surface 2: Skills list endpoint
const skillsRes = await injectWithAuth(server, {
method: 'GET',
url: '/api/skills',
});
const skillsList = skillsRes.json();
const listedSkill = skillsList.skills.find((s: CatalogSkillEntry) => s.name === TEST_SKILL);
expect(listedSkill, '/api/skills should contain installed skill').toBeDefined();
// Surface 3: Capabilities status endpoint
const capsRes = await injectWithAuth(server, {
method: 'GET',
url: '/api/capabilities/status',
});
const caps = capsRes.json();
const capSkill = caps.skills.find((s: CatalogSkillEntry) => s.name === TEST_SKILL);
expect(capSkill, '/api/capabilities/status should contain installed skill').toBeDefined();
// Surface 4: In-memory agentState
const agentSkill = server.agentState.skills.find(s => s.name === TEST_SKILL);
expect(agentSkill, 'agentState.skills should contain installed skill').toBeDefined();
await server.close();
});
it('all endpoints agree after restart', async () => {
const dataDir = makeTmpDir();
initMind(dataDir);
// Install, then restart
const server1 = await buildLocalServer({ dataDir });
await injectWithAuth(server1, {
method: 'POST',
url: `/api/skills/starter-pack/${TEST_SKILL}`,
});
await server1.close();
// Verify agreement after restart
const server2 = await buildLocalServer({ dataDir });
const [catalogRes, skillsRes, capsRes] = await Promise.all([
injectWithAuth(server2, { method: 'GET', url: '/api/skills/starter-pack/catalog' }),
injectWithAuth(server2, { method: 'GET', url: '/api/skills' }),
injectWithAuth(server2, { method: 'GET', url: '/api/capabilities/status' }),
]);
const catalog = catalogRes.json();
const skills = skillsRes.json();
const caps = capsRes.json();
// All surfaces report the skill
expect(catalog.skills.find((s: CatalogSkillEntry) => s.id === TEST_SKILL)?.state).toBe('active');
expect(skills.skills.find((s: CatalogSkillEntry) => s.name === TEST_SKILL)).toBeDefined();
expect(caps.skills.find((s: CatalogSkillEntry) => s.name === TEST_SKILL)).toBeDefined();
expect(server2.agentState.skills.find(s => s.name === TEST_SKILL)).toBeDefined();
await server2.close();
});
// ── dataDir Isolation ──────────────────────────────────────────────
it('different dataDirs have independent skill state', async () => {
const dataDir1 = makeTmpDir();
const dataDir2 = makeTmpDir();
initMind(dataDir1);
initMind(dataDir2);
// Install a skill in dataDir1 only
const server1 = await buildLocalServer({ dataDir: dataDir1 });
await injectWithAuth(server1, {
method: 'POST',
url: `/api/skills/starter-pack/${TEST_SKILL}`,
});
// Verify installed in dataDir1
const cat1 = await injectWithAuth(server1, { method: 'GET', url: '/api/skills/starter-pack/catalog' });
expect(cat1.json().skills.find((s: CatalogSkillEntry) => s.id === TEST_SKILL)?.state).toBe('active');
await server1.close();
// Verify NOT installed in dataDir2
const server2 = await buildLocalServer({ dataDir: dataDir2 });
const cat2 = await injectWithAuth(server2, { method: 'GET', url: '/api/skills/starter-pack/catalog' });
expect(cat2.json().skills.find((s: CatalogSkillEntry) => s.id === TEST_SKILL)?.state).toBe('available');
await server2.close();
});
// ── Filesystem is canonical ────────────────────────────────────────
it('deleting skill file from disk makes it unavailable after restart', async () => {
const dataDir = makeTmpDir();
initMind(dataDir);
// Install
const server1 = await buildLocalServer({ dataDir });
await injectWithAuth(server1, {
method: 'POST',
url: `/api/skills/starter-pack/${TEST_SKILL}`,
});
await server1.close();
// Delete the file manually
const skillPath = path.join(dataDir, 'skills', `${TEST_SKILL}.md`);
expect(fs.existsSync(skillPath)).toBe(true);
fs.unlinkSync(skillPath);
// Restart — skill should be back to available
const server2 = await buildLocalServer({ dataDir });
const catalogRes = await injectWithAuth(server2, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
const skill = catalogRes.json().skills.find((s: CatalogSkillEntry) => s.id === TEST_SKILL);
expect(skill?.state).toBe('available');
await server2.close();
});
});

View File

@@ -0,0 +1,226 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildServer } from '../../src/index.js';
import { users, teams, teamMembers, teamResources } from '../../src/db/schema.js';
import { sql } from 'drizzle-orm';
describe('Team Resources API', () => {
let server: Awaited<ReturnType<typeof buildServer>>;
let ownerId: string;
let memberId: string;
let outsiderId: string;
let teamSlug: string;
let teamId: string;
beforeAll(async () => {
server = await buildServer();
// Clean up any leftover test data
await server.db.execute(sql`DELETE FROM team_resources WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'restest-%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'restest_%'`);
// Create test users
const [owner] = await server.db.insert(users).values({
clerkId: 'restest_owner',
displayName: 'Res Owner',
email: 'restest_owner@test.com',
}).returning();
ownerId = owner.id;
const [member] = await server.db.insert(users).values({
clerkId: 'restest_member',
displayName: 'Res Member',
email: 'restest_member@test.com',
}).returning();
memberId = member.id;
const [outsider] = await server.db.insert(users).values({
clerkId: 'restest_outsider',
displayName: 'Res Outsider',
email: 'restest_outsider@test.com',
}).returning();
outsiderId = outsider.id;
// Create a team with owner + member
const [team] = await server.db.insert(teams).values({
name: 'Res Test Team',
slug: 'restest-resources',
ownerId,
}).returning();
teamId = team.id;
teamSlug = team.slug;
await server.db.insert(teamMembers).values([
{ teamId, userId: ownerId, role: 'owner' },
{ teamId, userId: memberId, role: 'member' },
]);
// Override auth handler
server._authHandler.fn = async function (request, reply) {
const testUserId = request.headers['x-test-user-id'] as string;
if (!testUserId) {
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
}
request.userId = testUserId;
request.clerkId = 'test';
};
});
afterAll(async () => {
await server.db.execute(sql`DELETE FROM team_resources WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'restest-%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'restest-%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'restest_%'`);
await server.close();
});
it('shares a model_recipe resource', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/resources`,
headers: { 'x-test-user-id': ownerId },
payload: {
resourceType: 'model_recipe',
name: 'GPT-4 Coding Recipe',
description: 'Optimized for code generation',
config: { model: 'gpt-4', temperature: 0.2 },
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.resourceType).toBe('model_recipe');
expect(body.name).toBe('GPT-4 Coding Recipe');
expect(body.description).toBe('Optimized for code generation');
expect(body.config).toEqual({ model: 'gpt-4', temperature: 0.2 });
expect(body.sharedBy).toBe(ownerId);
expect(body.rating).toBe(0);
expect(body.useCount).toBe(0);
});
it('shares a skill resource', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/resources`,
headers: { 'x-test-user-id': memberId },
payload: {
resourceType: 'skill',
name: 'Code Review Skill',
description: 'Automated code review with best practices',
config: { language: 'typescript', rules: ['no-any', 'strict-null'] },
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.resourceType).toBe('skill');
expect(body.name).toBe('Code Review Skill');
expect(body.sharedBy).toBe(memberId);
});
it('rates a resource and updates running average', async () => {
// Create a resource first
const createRes = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/resources`,
headers: { 'x-test-user-id': ownerId },
payload: {
resourceType: 'prompt_template',
name: 'Summarizer Prompt',
config: { template: 'Summarize: {{input}}' },
},
});
const resource = JSON.parse(createRes.body);
// First rating: should be the rating itself (useCount was 0)
const rate1 = await server.inject({
method: 'PATCH',
url: `/api/teams/${teamSlug}/resources/${resource.id}`,
headers: { 'x-test-user-id': ownerId },
payload: { rating: 4 },
});
expect(rate1.statusCode).toBe(200);
const body1 = JSON.parse(rate1.body);
// After first rate: rating was set to 4 (useCount was 0), then useCount incremented to 1
expect(body1.useCount).toBe(1);
// Second rating: running average = (4 * 1 + 2) / 2 = 3
const rate2 = await server.inject({
method: 'PATCH',
url: `/api/teams/${teamSlug}/resources/${resource.id}`,
headers: { 'x-test-user-id': memberId },
payload: { rating: 2 },
});
expect(rate2.statusCode).toBe(200);
const body2 = JSON.parse(rate2.body);
expect(body2.useCount).toBe(2);
// rating = (4 * 1 + 2) / 2 = 3
expect(body2.rating).toBeCloseTo(3, 1);
});
it('increments use_count on rate', async () => {
const createRes = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/resources`,
headers: { 'x-test-user-id': ownerId },
payload: {
resourceType: 'tool_config',
name: 'Linter Config',
config: { tool: 'eslint', extends: 'recommended' },
},
});
const resource = JSON.parse(createRes.body);
expect(resource.useCount).toBe(0);
// Rate it
const rateRes = await server.inject({
method: 'PATCH',
url: `/api/teams/${teamSlug}/resources/${resource.id}`,
headers: { 'x-test-user-id': memberId },
payload: { rating: 5 },
});
const rated = JSON.parse(rateRes.body);
expect(rated.useCount).toBe(1);
// Rate again
const rateRes2 = await server.inject({
method: 'PATCH',
url: `/api/teams/${teamSlug}/resources/${resource.id}`,
headers: { 'x-test-user-id': ownerId },
payload: { rating: 3 },
});
const rated2 = JSON.parse(rateRes2.body);
expect(rated2.useCount).toBe(2);
});
it('filters resources by resource_type', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/resources?type=skill`,
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1);
expect(body.every((r: { resourceType: string }) => r.resourceType === 'skill')).toBe(true);
});
it('non-member gets 403', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/resources`,
headers: { 'x-test-user-id': outsiderId },
});
expect(response.statusCode).toBe(403);
});
});

View File

@@ -0,0 +1,497 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
extractOpenQuestions,
classifyThreads,
extractSessionOutcome,
persistSessionOutcome,
type OpenQuestion,
type ThreadInfo,
type SessionOutcome,
} from '../../src/local/routes/sessions.js';
describe('extractOpenQuestions', () => {
let tmpDir: string;
let sessionsDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-oq-test-'));
sessionsDir = path.join(tmpDir, 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
});
function writeSession(id: string, messages: Array<{ role: string; content: string }>) {
const meta = JSON.stringify({ type: 'meta', title: `Session ${id}`, created: new Date().toISOString() });
const lines = [meta, ...messages.map(m => JSON.stringify(m))];
fs.writeFileSync(path.join(sessionsDir, `${id}.jsonl`), lines.join('\n') + '\n');
}
it('returns empty for non-existent dir', () => {
expect(extractOpenQuestions('/nonexistent/path')).toEqual([]);
});
it('returns empty for sessions with no questions', () => {
writeSession('s1', [
{ role: 'user', content: 'Hello, how are you today?' },
{ role: 'assistant', content: 'I am doing well, thank you.' },
{ role: 'user', content: 'Great, let us continue working.' },
]);
expect(extractOpenQuestions(sessionsDir)).toEqual([]);
});
it('detects literal question-form open questions', () => {
writeSession('s1', [
{ role: 'user', content: 'What should we use for the database layer in this project?' },
{ role: 'assistant', content: 'There are several options to consider.' },
{ role: 'user', content: 'Should we go with PostgreSQL or SQLite for local storage?' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.some(q => q.content.toLowerCase().includes('database') || q.content.toLowerCase().includes('postgresql'))).toBe(true);
});
it('detects "we still need to decide" pattern', () => {
writeSession('s1', [
{ role: 'user', content: 'We made progress on the API.' },
{ role: 'assistant', content: 'Yes, but we still need to decide on the authentication approach for the API layer.' },
{ role: 'user', content: 'Good point, let us think about it.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.some(q => q.content.toLowerCase().includes('authentication'))).toBe(true);
});
it('detects "not yet clear" pattern', () => {
writeSession('s1', [
{ role: 'assistant', content: 'The deployment strategy is not yet clear whether we should use containers or bare metal.' },
{ role: 'user', content: 'Right, we need to figure that out.' },
{ role: 'assistant', content: 'Let me research both options.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.some(q => q.content.toLowerCase().includes('container') || q.content.toLowerCase().includes('deployment'))).toBe(true);
});
it('detects "pending decision" and "TBD" patterns', () => {
writeSession('s1', [
{ role: 'user', content: 'The hosting provider is TBD: we are evaluating AWS vs GCP for this.' },
{ role: 'assistant', content: 'There is also a pending decision on the CI pipeline tool selection.' },
{ role: 'user', content: 'Yes, both are important.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
});
it('detects "need to figure out" pattern', () => {
writeSession('s1', [
{ role: 'user', content: 'We need to figure out how to handle rate limiting in the proxy layer.' },
{ role: 'assistant', content: 'That is an important consideration.' },
{ role: 'user', content: 'Agreed.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.some(q => q.content.toLowerCase().includes('rate limiting'))).toBe(true);
});
it('excludes greeting-style questions', () => {
writeSession('s1', [
{ role: 'user', content: 'Hello there!' },
{ role: 'assistant', content: 'Hi! How are you doing today?' },
{ role: 'user', content: 'Good thanks.' },
]);
expect(extractOpenQuestions(sessionsDir)).toEqual([]);
});
it('deduplicates similar questions across sessions', () => {
writeSession('s1', [
{ role: 'user', content: 'What should we use for the caching layer in this system?' },
{ role: 'assistant', content: 'Good question.' },
{ role: 'user', content: 'Let me think about it.' },
]);
writeSession('s2', [
{ role: 'user', content: 'What should we use for the caching layer in this system?' },
{ role: 'assistant', content: 'Redis or Memcached are popular choices.' },
{ role: 'user', content: 'Thanks for the info.' },
]);
const result = extractOpenQuestions(sessionsDir);
// Should deduplicate — only one entry for the same question
const cachingQuestions = result.filter(q => q.content.toLowerCase().includes('caching'));
expect(cachingQuestions.length).toBeLessThanOrEqual(1);
});
it('skips sessions with fewer than 3 lines', () => {
const meta = JSON.stringify({ type: 'meta', title: 'Short', created: new Date().toISOString() });
fs.writeFileSync(
path.join(sessionsDir, 'short.jsonl'),
meta + '\n' + JSON.stringify({ role: 'user', content: 'What should we do about the API design?' }) + '\n',
);
expect(extractOpenQuestions(sessionsDir)).toEqual([]);
});
it('includes sessionId and date in results', () => {
writeSession('test-session-42', [
{ role: 'user', content: 'Should we use WebSocket or SSE for the streaming transport?' },
{ role: 'assistant', content: 'Both have tradeoffs.' },
{ role: 'user', content: 'Let me think.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result[0].sessionId).toBe('test-session-42');
expect(result[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('detects "remains open" and "left open" patterns', () => {
writeSession('s1', [
{ role: 'assistant', content: 'The question of whether to use monorepo or polyrepo remains open for the new project.' },
{ role: 'user', content: 'Yes it does.' },
{ role: 'assistant', content: 'We should revisit this.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
});
});
describe('classifyThreads', () => {
let tmpDir: string;
let sessionsDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-thread-test-'));
sessionsDir = path.join(tmpDir, 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
});
function writeSession(
id: string,
messages: Array<{ role: string; content: string }>,
opts?: { title?: string; ageMs?: number },
) {
const created = new Date(Date.now() - (opts?.ageMs ?? 0)).toISOString();
const meta = JSON.stringify({ type: 'meta', title: opts?.title ?? null, created });
const lines = [meta, ...messages.map(m => JSON.stringify(m))];
const filePath = path.join(sessionsDir, `${id}.jsonl`);
fs.writeFileSync(filePath, lines.join('\n') + '\n');
// Backdate the file's mtime if ageMs is provided
if (opts?.ageMs) {
const pastTime = new Date(Date.now() - opts.ageMs);
fs.utimesSync(filePath, pastTime, pastTime);
}
}
it('returns empty for non-existent dir', () => {
expect(classifyThreads('/nonexistent/path')).toEqual([]);
});
it('returns empty for empty dir', () => {
expect(classifyThreads(sessionsDir)).toEqual([]);
});
it('classifies a recent session as fresh', () => {
writeSession('s1', [
{ role: 'user', content: 'Let us work on the dashboard.' },
{ role: 'assistant', content: 'Sure, I will help with that.' },
], { title: 'Dashboard work' });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(1);
expect(threads[0].title).toBe('Dashboard work');
expect(threads[0].freshness).toBe('fresh');
expect(threads[0].messageCount).toBe(2);
expect(threads[0].sessionId).toBe('s1');
});
it('classifies 3-day-old session as aging', () => {
const threeDays = 3 * 24 * 60 * 60 * 1000;
writeSession('s1', [
{ role: 'user', content: 'Working on auth.' },
{ role: 'assistant', content: 'Let me help.' },
], { title: 'Auth work', ageMs: threeDays });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(1);
expect(threads[0].freshness).toBe('aging');
});
it('classifies 10-day-old session as stale', () => {
const tenDays = 10 * 24 * 60 * 60 * 1000;
writeSession('s1', [
{ role: 'user', content: 'Planning the architecture.' },
{ role: 'assistant', content: 'Here are my thoughts.' },
], { title: 'Architecture planning', ageMs: tenDays });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(1);
expect(threads[0].freshness).toBe('stale');
// Stale means "not recently touched" — it says nothing about importance
expect(threads[0].title).toBe('Architecture planning');
});
it('skips sessions with fewer than 2 messages', () => {
const meta = JSON.stringify({ type: 'meta', title: 'Short', created: new Date().toISOString() });
fs.writeFileSync(
path.join(sessionsDir, 'short.jsonl'),
meta + '\n' + JSON.stringify({ role: 'user', content: 'Hi' }) + '\n',
);
expect(classifyThreads(sessionsDir)).toEqual([]);
});
it('falls back to first user message for title', () => {
writeSession('s1', [
{ role: 'user', content: 'Implementing the notification system for the app' },
{ role: 'assistant', content: 'Great, let me help with that.' },
]);
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(1);
expect(threads[0].title).toContain('notification system');
});
it('sorts threads by recency (most recent first)', () => {
const oneDay = 1 * 24 * 60 * 60 * 1000;
const fiveDays = 5 * 24 * 60 * 60 * 1000;
writeSession('old', [
{ role: 'user', content: 'Old thread content here.' },
{ role: 'assistant', content: 'Old response.' },
], { title: 'Old thread', ageMs: fiveDays });
writeSession('recent', [
{ role: 'user', content: 'Recent thread content here.' },
{ role: 'assistant', content: 'Recent response.' },
], { title: 'Recent thread', ageMs: oneDay });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(2);
expect(threads[0].title).toBe('Recent thread');
expect(threads[1].title).toBe('Old thread');
});
it('respects maxSessions limit', () => {
for (let i = 0; i < 5; i++) {
writeSession(`s${i}`, [
{ role: 'user', content: `Thread ${i} content here.` },
{ role: 'assistant', content: `Response ${i}.` },
], { title: `Thread ${i}` });
}
const threads = classifyThreads(sessionsDir, 3);
expect(threads).toHaveLength(3);
});
it('includes lastActive as ISO string', () => {
writeSession('s1', [
{ role: 'user', content: 'Working on tests.' },
{ role: 'assistant', content: 'Let me help.' },
], { title: 'Test work' });
const threads = classifyThreads(sessionsDir);
expect(threads[0].lastActive).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('truncates long titles to 80 chars', () => {
const longContent = 'A'.repeat(200);
writeSession('s1', [
{ role: 'user', content: longContent },
{ role: 'assistant', content: 'Noted.' },
]);
const threads = classifyThreads(sessionsDir);
expect(threads[0].title.length).toBeLessThanOrEqual(80);
});
it('handles mixed freshness levels across threads', () => {
const oneHour = 60 * 60 * 1000;
const fourDays = 4 * 24 * 60 * 60 * 1000;
const fifteenDays = 15 * 24 * 60 * 60 * 1000;
writeSession('fresh', [
{ role: 'user', content: 'Fresh work happening now.' },
{ role: 'assistant', content: 'On it.' },
], { title: 'Fresh', ageMs: oneHour });
writeSession('aging', [
{ role: 'user', content: 'Aging work from a few days ago.' },
{ role: 'assistant', content: 'Noted.' },
], { title: 'Aging', ageMs: fourDays });
writeSession('stale', [
{ role: 'user', content: 'Stale work from two weeks ago.' },
{ role: 'assistant', content: 'Archived.' },
], { title: 'Stale', ageMs: fifteenDays });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(3);
const freshThread = threads.find(t => t.title === 'Fresh');
const agingThread = threads.find(t => t.title === 'Aging');
const staleThread = threads.find(t => t.title === 'Stale');
expect(freshThread?.freshness).toBe('fresh');
expect(agingThread?.freshness).toBe('aging');
expect(staleThread?.freshness).toBe('stale');
});
});
describe('extractSessionOutcome', () => {
function makeLines(messages: Array<{ role: string; content: string }>): string[] {
return messages.map(m => JSON.stringify(m));
}
it('returns null for fewer than 4 message lines', () => {
const lines = makeLines([
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
]);
expect(extractSessionOutcome(lines)).toBeNull();
});
it('extracts whatChanged from completion signals', () => {
const lines = makeLines([
{ role: 'user', content: 'Can you fix the login bug?' },
{ role: 'assistant', content: 'Looking into it now.' },
{ role: 'assistant', content: 'I found the issue in the auth handler.' },
{ role: 'assistant', content: 'Fixed the authentication token validation logic.' },
{ role: 'user', content: 'Great, thanks!' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.whatChanged.toLowerCase()).toContain('authentication');
});
it('extracts openItems from unresolved signals', () => {
const lines = makeLines([
{ role: 'user', content: 'Let us work on the API endpoints.' },
{ role: 'assistant', content: 'I completed the GET endpoints.' },
{ role: 'assistant', content: 'We still need to implement the rate limiting middleware.' },
{ role: 'user', content: 'OK, we can do that next time.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.openItems).not.toBeNull();
expect(outcome!.openItems!.toLowerCase()).toContain('rate limiting');
});
it('extracts nextStep from forward-looking signals', () => {
const lines = makeLines([
{ role: 'user', content: 'How is the migration going?' },
{ role: 'assistant', content: 'I finished migrating the user table.' },
{ role: 'assistant', content: 'Next step: migrate the permissions table and update foreign keys.' },
{ role: 'user', content: 'Sounds good.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.nextStep).not.toBeNull();
expect(outcome!.nextStep!.toLowerCase()).toContain('permissions');
});
it('falls back to first substantive user message for whatChanged', () => {
const lines = makeLines([
{ role: 'user', content: 'Can you review the database schema design for the new feature?' },
{ role: 'assistant', content: 'Sure, let me take a look.' },
{ role: 'assistant', content: 'The schema looks reasonable.' },
{ role: 'user', content: 'OK thanks.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.whatChanged.toLowerCase()).toContain('database schema');
});
it('returns null for all-greeting sessions', () => {
const lines = makeLines([
{ role: 'user', content: 'Hi' },
{ role: 'assistant', content: 'Hello!' },
{ role: 'user', content: 'Thanks' },
{ role: 'assistant', content: 'You are welcome.' },
]);
expect(extractSessionOutcome(lines)).toBeNull();
});
it('handles outcome with all three fields populated', () => {
const lines = makeLines([
{ role: 'user', content: 'Let us finish the notification system.' },
{ role: 'assistant', content: 'I implemented the email notification sender module.' },
{ role: 'assistant', content: 'We still need to add SMS notification support.' },
{ role: 'assistant', content: 'Next step: integrate the Twilio API for SMS delivery.' },
{ role: 'user', content: 'Perfect, let us do that tomorrow.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.whatChanged).toBeTruthy();
expect(outcome!.openItems).not.toBeNull();
expect(outcome!.nextStep).not.toBeNull();
});
it('truncates long content', () => {
const longTask = 'A'.repeat(200);
const lines = makeLines([
{ role: 'user', content: `Can you work on the feature?` },
{ role: 'assistant', content: `I completed ${longTask} successfully.` },
{ role: 'user', content: 'Great job.' },
{ role: 'assistant', content: 'All done.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.whatChanged.length).toBeLessThanOrEqual(120);
});
});
describe('persistSessionOutcome', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-outcome-test-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
});
it('writes outcome to meta line', () => {
const filePath = path.join(tmpDir, 'test.jsonl');
const meta = { type: 'meta', title: 'Test', created: new Date().toISOString() };
const msg = { role: 'user', content: 'Hello' };
fs.writeFileSync(filePath, JSON.stringify(meta) + '\n' + JSON.stringify(msg) + '\n');
const outcome: SessionOutcome = {
whatChanged: 'Fixed the auth bug',
openItems: 'Rate limiting still needed',
nextStep: 'Add rate limiter',
};
persistSessionOutcome(filePath, outcome);
// Read back and verify
const content = fs.readFileSync(filePath, 'utf-8').trim();
const lines = content.split('\n');
const updatedMeta = JSON.parse(lines[0]);
expect(updatedMeta.outcome).toEqual(outcome);
// Original message should still be there
expect(lines).toHaveLength(2);
const originalMsg = JSON.parse(lines[1]);
expect(originalMsg.content).toBe('Hello');
});
it('preserves existing meta fields', () => {
const filePath = path.join(tmpDir, 'test.jsonl');
const meta = { type: 'meta', title: 'My Session', summary: 'A good session', created: '2026-03-14T00:00:00Z' };
fs.writeFileSync(filePath, JSON.stringify(meta) + '\n');
persistSessionOutcome(filePath, { whatChanged: 'Updated config', openItems: null, nextStep: null });
const content = fs.readFileSync(filePath, 'utf-8').trim();
const updatedMeta = JSON.parse(content.split('\n')[0]);
expect(updatedMeta.title).toBe('My Session');
expect(updatedMeta.summary).toBe('A good session');
expect(updatedMeta.outcome.whatChanged).toBe('Updated config');
});
});

View File

@@ -0,0 +1,141 @@
/**
* PM-3: Session Timeline — server-side tests.
*
* Tests the parseSessionTimeline function that extracts tool events
* from session JSONL files.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
parseSessionTimeline,
type TimelineEvent,
} from '../../src/local/routes/sessions.js';
describe('parseSessionTimeline', () => {
let tmpDir: string;
let sessionsDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-timeline-test-'));
sessionsDir = path.join(tmpDir, 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
});
function writeSession(id: string, messages: Array<{ role: string; content: string }>) {
const meta = JSON.stringify({ type: 'meta', title: `Session ${id}`, created: '2026-03-19T10:00:00.000Z' });
const lines = [
meta,
...messages.map(m => JSON.stringify({ ...m, timestamp: '2026-03-19T10:05:00.000Z' })),
];
const filePath = path.join(sessionsDir, `${id}.jsonl`);
fs.writeFileSync(filePath, lines.join('\n') + '\n');
return filePath;
}
it('returns empty array for non-existent file', () => {
expect(parseSessionTimeline('/nonexistent/file.jsonl')).toEqual([]);
});
it('returns empty array for empty session file', () => {
const filePath = path.join(sessionsDir, 'empty.jsonl');
fs.writeFileSync(filePath, '', 'utf-8');
expect(parseSessionTimeline(filePath)).toEqual([]);
});
it('extracts tool events from assistant messages with tool patterns', () => {
const filePath = writeSession('s1', [
{ role: 'user', content: 'Search the web for AI news' },
{ role: 'assistant', content: 'Searching the web for "AI news"... Found several results about recent developments.' },
{ role: 'assistant', content: 'Reading file: src/index.ts... The file contains the main entry point.' },
]);
const events = parseSessionTimeline(filePath);
expect(events.length).toBeGreaterThanOrEqual(2);
const webSearch = events.find(e => e.toolName === 'web_search');
expect(webSearch).toBeDefined();
expect(webSearch!.status).toBe('success');
expect(webSearch!.inputPreview).toContain('AI news');
const readFile = events.find(e => e.toolName === 'read_file');
expect(readFile).toBeDefined();
expect(readFile!.inputPreview).toContain('src/index.ts');
});
it('returns empty array for session with only user messages', () => {
const filePath = writeSession('s2', [
{ role: 'user', content: 'Hello' },
{ role: 'user', content: 'How are you?' },
]);
const events = parseSessionTimeline(filePath);
expect(events).toEqual([]);
});
it('detects sub-agent nesting via spawn_agent pattern', () => {
const filePath = writeSession('s3', [
{ role: 'user', content: 'Research this topic' },
{ role: 'assistant', content: 'Spawning sub-agent "researcher" (researcher)... to analyze the topic.' },
{ role: 'assistant', content: 'Searching the web for "topic analysis"... Found relevant results.' },
{ role: 'assistant', content: 'Saving to memory... Stored the research results.' },
]);
const events = parseSessionTimeline(filePath);
// The spawn_agent event should be a top-level event with children
const spawnEvent = events.find(e => e.toolName === 'spawn_agent');
expect(spawnEvent).toBeDefined();
expect(spawnEvent!.children).toBeDefined();
expect(spawnEvent!.children!.length).toBeGreaterThanOrEqual(1);
});
it('detects error status from error content', () => {
const filePath = writeSession('s4', [
{ role: 'user', content: 'Read a file' },
{ role: 'assistant', content: 'Reading file: missing.ts... Error: file not found, unable to read.' },
]);
const events = parseSessionTimeline(filePath);
expect(events.length).toBeGreaterThanOrEqual(1);
const errorEvent = events.find(e => e.status === 'error');
expect(errorEvent).toBeDefined();
expect(errorEvent!.toolName).toBe('read_file');
});
it('assigns sequential IDs to events', () => {
const filePath = writeSession('s5', [
{ role: 'user', content: 'Do some work' },
{ role: 'assistant', content: 'Searching the web for "test query"... done.' },
{ role: 'assistant', content: 'Saving to memory... saved.' },
]);
const events = parseSessionTimeline(filePath);
expect(events.length).toBeGreaterThanOrEqual(2);
const ids = events.map(e => e.id);
// All IDs should be unique
expect(new Set(ids).size).toBe(ids.length);
// IDs should follow tl-N pattern
expect(ids[0]).toBe('tl-0');
expect(ids[1]).toBe('tl-1');
});
it('includes timestamp from the session message', () => {
const filePath = writeSession('s6', [
{ role: 'user', content: 'Test' },
{ role: 'assistant', content: 'Checking git status... Clean working tree.' },
]);
const events = parseSessionTimeline(filePath);
expect(events.length).toBeGreaterThanOrEqual(1);
expect(events[0].timestamp).toBe('2026-03-19T10:05:00.000Z');
});
});

View File

@@ -0,0 +1,182 @@
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 { MindDB } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
/** Skill ID used for install tests — cleaned up between tests to avoid 409 conflicts */
const INSTALL_TEST_SKILL = 'retrospective';
describe('Starter Skill Catalog', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-catalog-test-'));
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('GET /api/skills/starter-pack/catalog returns all starter skills', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.skills).toBeDefined();
expect(Array.isArray(body.skills)).toBe(true);
expect(body.skills.length).toBe(18);
});
it('each skill has the correct shape', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
const body = res.json();
for (const skill of body.skills) {
expect(skill).toHaveProperty('id');
expect(skill).toHaveProperty('name');
expect(skill).toHaveProperty('description');
expect(skill).toHaveProperty('family');
expect(skill).toHaveProperty('familyLabel');
expect(skill).toHaveProperty('state');
expect(skill).toHaveProperty('isWorkflow');
expect(typeof skill.id).toBe('string');
expect(typeof skill.name).toBe('string');
expect(typeof skill.description).toBe('string');
expect(typeof skill.family).toBe('string');
expect(typeof skill.familyLabel).toBe('string');
expect(['active', 'installed', 'available']).toContain(skill.state);
expect(typeof skill.isWorkflow).toBe('boolean');
}
});
it('families array is populated with correct shape', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
const body = res.json();
expect(body.families).toBeDefined();
expect(Array.isArray(body.families)).toBe(true);
expect(body.families.length).toBeGreaterThan(0);
for (const family of body.families) {
expect(family).toHaveProperty('id');
expect(family).toHaveProperty('label');
expect(typeof family.id).toBe('string');
expect(typeof family.label).toBe('string');
}
});
it('workflow skills have isWorkflow: true', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
const body = res.json();
const workflowIds = ['research-team', 'review-pair', 'plan-execute'];
for (const wfId of workflowIds) {
const skill = body.skills.find((s: { id: string }) => s.id === wfId);
expect(skill, `workflow skill ${wfId} should exist`).toBeDefined();
expect(skill.isWorkflow).toBe(true);
}
// Non-workflow skills should have isWorkflow: false
const nonWorkflow = body.skills.filter(
(s: { id: string }) => !workflowIds.includes(s.id),
);
for (const skill of nonWorkflow) {
expect(skill.isWorkflow).toBe(false);
}
});
it('all skills map to a known family (no "other")', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
const body = res.json();
const knownFamilies = ['writing', 'research', 'decision', 'planning', 'communication', 'code', 'creative'];
for (const skill of body.skills) {
expect(
knownFamilies,
`skill "${skill.id}" has unknown family "${skill.family}"`,
).toContain(skill.family);
}
});
it('POST /api/skills/starter-pack/:id installs a single skill', async () => {
// Ensure the skill is not already installed (now writes to tmpDir/skills/)
const skillsDir = path.join(tmpDir, 'skills');
const targetPath = path.join(skillsDir, `${INSTALL_TEST_SKILL}.md`);
if (fs.existsSync(targetPath)) fs.unlinkSync(targetPath);
const res = await injectWithAuth(server, {
method: 'POST',
url: `/api/skills/starter-pack/${INSTALL_TEST_SKILL}`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.ok).toBe(true);
expect(body.skill.id).toBe(INSTALL_TEST_SKILL);
expect(['active', 'installed']).toContain(body.skill.state);
});
it('POST /api/skills/starter-pack/:id returns 409 for already installed', async () => {
// INSTALL_TEST_SKILL was installed in previous test
const res = await injectWithAuth(server, {
method: 'POST',
url: `/api/skills/starter-pack/${INSTALL_TEST_SKILL}`,
});
expect(res.statusCode).toBe(409);
});
it('POST /api/skills/starter-pack/:id returns 404 for nonexistent skill', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/skills/starter-pack/nonexistent-skill-xyz',
});
expect(res.statusCode).toBe(404);
});
it('POST /api/skills/starter-pack/:id returns 400 for path traversal', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/skills/starter-pack/..%2F..%2Fetc%2Fpasswd',
});
expect(res.statusCode).toBe(400);
});
it('catalog reflects installed state after single install', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
const body = JSON.parse(res.payload);
const installed = body.skills.find((s: { id: string }) => s.id === INSTALL_TEST_SKILL);
expect(installed).toBeDefined();
expect(['active', 'installed']).toContain(installed.state);
});
});

View File

@@ -0,0 +1,210 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildServer } from '../../src/index.js';
import { users, teams, teamMembers, tasks } from '../../src/db/schema.js';
import { sql } from 'drizzle-orm';
describe('Task Board API', () => {
let server: Awaited<ReturnType<typeof buildServer>>;
let ownerId: string;
let memberId: string;
let outsiderId: string;
let teamSlug: string;
let teamId: string;
let createdTaskId: string;
beforeAll(async () => {
server = await buildServer();
// Clean up any leftover test data
await server.db.execute(sql`DELETE FROM tasks WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'tstest-%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'tstest_%'`);
// Create test users
const [owner] = await server.db.insert(users).values({
clerkId: 'tstest_owner',
displayName: 'Task Owner',
email: 'tstest_owner@test.com',
}).returning();
ownerId = owner.id;
const [member] = await server.db.insert(users).values({
clerkId: 'tstest_member',
displayName: 'Task Member',
email: 'tstest_member@test.com',
}).returning();
memberId = member.id;
const [outsider] = await server.db.insert(users).values({
clerkId: 'tstest_outsider',
displayName: 'Task Outsider',
email: 'tstest_outsider@test.com',
}).returning();
outsiderId = outsider.id;
// Create a team with owner + member
const [team] = await server.db.insert(teams).values({
name: 'Task Test Team',
slug: 'tstest-tasks',
ownerId,
}).returning();
teamId = team.id;
teamSlug = team.slug;
await server.db.insert(teamMembers).values([
{ teamId, userId: ownerId, role: 'owner' },
{ teamId, userId: memberId, role: 'member' },
]);
// Override auth handler
server._authHandler.fn = async function (request, reply) {
const testUserId = request.headers['x-test-user-id'] as string;
if (!testUserId) {
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
}
request.userId = testUserId;
request.clerkId = 'test';
};
});
afterAll(async () => {
await server.db.execute(sql`DELETE FROM tasks WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'tstest-%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'tstest-%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'tstest_%'`);
await server.close();
});
it('creates a task on team board', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/tasks`,
headers: { 'x-test-user-id': ownerId },
payload: { title: 'Research competitors', description: 'Analyze top 5 competitors', priority: 'high' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.title).toBe('Research competitors');
expect(body.description).toBe('Analyze top 5 competitors');
expect(body.priority).toBe('high');
expect(body.status).toBe('open');
expect(body.createdBy).toBe(ownerId);
expect(body.teamId).toBe(teamId);
createdTaskId = body.id;
});
it('lists tasks on team board', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/tasks`,
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(1);
expect(body.some((t: { id: string }) => t.id === createdTaskId)).toBe(true);
});
it('lists tasks with status filter', async () => {
// Create a second task and mark it done
const createRes = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/tasks`,
headers: { 'x-test-user-id': ownerId },
payload: { title: 'Done task' },
});
const doneTask = JSON.parse(createRes.body);
await server.inject({
method: 'PATCH',
url: `/api/teams/${teamSlug}/tasks/${doneTask.id}`,
headers: { 'x-test-user-id': ownerId },
payload: { status: 'done' },
});
// Filter by status=open
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/tasks?status=open`,
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.every((t: { status: string }) => t.status === 'open')).toBe(true);
expect(body.some((t: { id: string }) => t.id === doneTask.id)).toBe(false);
});
it('claims a task — sets assignedTo and status to claimed', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/tasks/${createdTaskId}/claim`,
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.assignedTo).toBe(memberId);
expect(body.status).toBe('claimed');
});
it('completes a task — status changes to done', async () => {
const response = await server.inject({
method: 'PATCH',
url: `/api/teams/${teamSlug}/tasks/${createdTaskId}`,
headers: { 'x-test-user-id': memberId },
payload: { status: 'done' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.status).toBe('done');
});
it('creates a subtask with parentTaskId', async () => {
const response = await server.inject({
method: 'POST',
url: `/api/teams/${teamSlug}/tasks`,
headers: { 'x-test-user-id': ownerId },
payload: { title: 'Sub-research item', parentTaskId: createdTaskId },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.parentTaskId).toBe(createdTaskId);
expect(body.priority).toBe('normal');
});
it('non-team-member gets 403', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/tasks`,
headers: { 'x-test-user-id': outsiderId },
});
expect(response.statusCode).toBe(403);
});
it('gets a single task by id', async () => {
const response = await server.inject({
method: 'GET',
url: `/api/teams/${teamSlug}/tasks/${createdTaskId}`,
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(createdTaskId);
expect(body.title).toBe('Research competitors');
});
});

View File

@@ -0,0 +1,288 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildServer } from '../../src/index.js';
import { users, teams, teamMembers } from '../../src/db/schema.js';
import { sql } from 'drizzle-orm';
describe('Team API', () => {
let server: Awaited<ReturnType<typeof buildServer>>;
let ownerId: string;
let memberId: string;
let adminId: string;
beforeAll(async () => {
server = await buildServer();
// Clean up any leftover test data (use unique prefix to avoid collision with auth tests)
// Narrow to 'test-team%' — previously 'test-%' collided with analytics.test.ts's
// 'test-analytics' slug when files ran in parallel and wiped it mid-run, causing
// spurious 403s because the analytics team's owner was no longer a member.
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'test-team%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'tmtest_%'`);
// Create test users directly in DB (clerk IDs use 'tmtest_' prefix to avoid auth test cleanup)
const [owner] = await server.db.insert(users).values({
clerkId: 'tmtest_owner',
displayName: 'Team Owner',
email: 'teamowner@test.com',
}).returning();
ownerId = owner.id;
const [member] = await server.db.insert(users).values({
clerkId: 'tmtest_member',
displayName: 'Team Member',
email: 'teammember@test.com',
}).returning();
memberId = member.id;
const [admin] = await server.db.insert(users).values({
clerkId: 'tmtest_admin',
displayName: 'Team Admin',
email: 'teamadmin@test.com',
}).returning();
adminId = admin.id;
// Override auth handler to use x-test-user-id header (via indirection object)
server._authHandler.fn = async function (request, reply) {
const testUserId = request.headers['x-test-user-id'] as string;
if (!testUserId) {
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
}
request.userId = testUserId;
request.clerkId = 'test';
};
});
afterAll(async () => {
// Narrow to 'test-team%' — previously 'test-%' collided with analytics.test.ts's
// 'test-analytics' slug when files ran in parallel and wiped it mid-run, causing
// spurious 403s because the analytics team's owner was no longer a member.
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE 'test-team%')`);
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE 'test-team%'`);
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'tmtest_%'`);
await server.close();
});
it('creates a team and auto-adds owner as member with role owner', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/teams',
headers: { 'x-test-user-id': ownerId },
payload: { name: 'Test Team', slug: 'test-team-crud' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Test Team');
expect(body.slug).toBe('test-team-crud');
expect(body.ownerId).toBe(ownerId);
// Verify owner membership
const [membership] = await server.db
.select()
.from(teamMembers)
.where(sql`team_id = ${body.id} AND user_id = ${ownerId}`);
expect(membership).toBeDefined();
expect(membership.role).toBe('owner');
});
it('returns 409 on duplicate slug', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/teams',
headers: { 'x-test-user-id': ownerId },
payload: { name: 'Another Team', slug: 'test-team-crud' },
});
expect(response.statusCode).toBe(409);
});
it('lists only teams the user belongs to', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/teams',
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
const slugs = body.map((t: { slug: string }) => t.slug);
expect(slugs).toContain('test-team-crud');
});
it('non-member gets empty list', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/teams',
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
// memberId is not a member of any team with test- slug yet
const testTeams = body.filter((t: { slug: string }) => t.slug.startsWith('test-'));
expect(testTeams).toHaveLength(0);
});
it('gets team by slug with members', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/teams/test-team-crud',
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.slug).toBe('test-team-crud');
expect(body.members).toBeDefined();
expect(body.members.length).toBeGreaterThanOrEqual(1);
expect(body.members[0].displayName).toBe('Team Owner');
});
it('non-member gets 403 when accessing team', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/teams/test-team-crud',
headers: { 'x-test-user-id': memberId },
});
expect(response.statusCode).toBe(403);
});
it('adds a member via invite (admin+)', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/teams/test-team-crud/members',
headers: { 'x-test-user-id': ownerId },
payload: { email: 'teammember@test.com', role: 'member' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.userId).toBe(memberId);
expect(body.role).toBe('member');
});
it('returns 409 when inviting existing member', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/teams/test-team-crud/members',
headers: { 'x-test-user-id': ownerId },
payload: { email: 'teammember@test.com', role: 'member' },
});
expect(response.statusCode).toBe(409);
});
it('adds an admin member', async () => {
const response = await server.inject({
method: 'POST',
url: '/api/teams/test-team-crud/members',
headers: { 'x-test-user-id': ownerId },
payload: { email: 'teamadmin@test.com', role: 'admin' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.role).toBe('admin');
});
it('member can update own roleDescription and interests', async () => {
const response = await server.inject({
method: 'PATCH',
url: `/api/teams/test-team-crud/members/${memberId}`,
headers: { 'x-test-user-id': memberId },
payload: {
roleDescription: 'Frontend developer',
interests: ['React', 'TypeScript'],
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.roleDescription).toBe('Frontend developer');
expect(body.interests).toEqual(['React', 'TypeScript']);
});
it('member cannot change own role', async () => {
// Attempting to change role requires admin, but member is only 'member'
const response = await server.inject({
method: 'PATCH',
url: `/api/teams/test-team-crud/members/${memberId}`,
headers: { 'x-test-user-id': memberId },
payload: { role: 'admin' },
});
expect(response.statusCode).toBe(403);
});
it('admin can update team name', async () => {
const response = await server.inject({
method: 'PATCH',
url: '/api/teams/test-team-crud',
headers: { 'x-test-user-id': adminId },
payload: { name: 'Updated Team Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Team Name');
});
it('member cannot update team name', async () => {
const response = await server.inject({
method: 'PATCH',
url: '/api/teams/test-team-crud',
headers: { 'x-test-user-id': memberId },
payload: { name: 'Should Fail' },
});
expect(response.statusCode).toBe(403);
});
it('cannot remove the team owner', async () => {
const response = await server.inject({
method: 'DELETE',
url: `/api/teams/test-team-crud/members/${ownerId}`,
headers: { 'x-test-user-id': ownerId },
});
expect(response.statusCode).toBe(403);
});
it('admin can remove a member', async () => {
const response = await server.inject({
method: 'DELETE',
url: `/api/teams/test-team-crud/members/${memberId}`,
headers: { 'x-test-user-id': adminId },
});
expect(response.statusCode).toBe(204);
// Verify member is gone
const checkResponse = await server.inject({
method: 'GET',
url: '/api/teams/test-team-crud',
headers: { 'x-test-user-id': ownerId },
});
const body = JSON.parse(checkResponse.body);
const memberIds = body.members.map((m: { userId: string }) => m.userId);
expect(memberIds).not.toContain(memberId);
});
it('returns 401 without auth header', async () => {
const response = await server.inject({
method: 'GET',
url: '/api/teams',
});
expect(response.statusCode).toBe(401);
});
});

View File

@@ -0,0 +1,195 @@
/**
* Trust Model Last-Mile Wiring Tests
*
* Validates that:
* 1. InstallAuditStore is instantiated and wired at runtime
* 2. Install operations record audit entries in the .mind DB
* 3. Existing non-trust approvals remain backward-compatible
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import Database from 'better-sqlite3';
import { MindDB } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
describe('Trust Model Runtime Wiring', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-trust-wire-'));
// Create personal.mind
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
mind.close();
// Create skills dir with marker to prevent auto-install of starter skills
const skillsDir = path.join(tmpDir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(path.join(skillsDir, '.starter-installed'), 'test');
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('Audit store wiring', () => {
it('install_audit table exists after server startup', () => {
const personalPath = path.join(tmpDir, 'personal.mind');
const db = new Database(personalPath);
const table = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='install_audit'",
).get() as { name: string } | undefined;
db.close();
expect(table).toBeDefined();
expect(table!.name).toBe('install_audit');
});
it('installing a starter skill creates audit entries', async () => {
// Remove skill if auto-installed, then install fresh
await injectWithAuth(server, { method: 'DELETE', url: '/api/skills/brainstorm' });
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/skills/starter-pack/brainstorm',
});
expect(res.statusCode).toBe(200);
// Check audit trail — read through the server's own auditStore (the
// writer connection). A fresh better-sqlite3 reader can miss WAL rows the
// server hasn't checkpointed, which is flaky across platforms/boot timing.
const entries = server.auditStore.getByCapability('brainstorm');
// API route records 1 installed entry (agent tool path records approved + installed)
expect(entries.length).toBeGreaterThanOrEqual(1);
expect(entries.some(e => e.action === 'installed')).toBe(true);
// Verify trust metadata is populated
const installed = entries.find(e => e.action === 'installed')!;
expect(installed.risk_level).toBe('low');
expect(installed.trust_source).toBe('starter_pack');
expect(installed.approval_class).toBe('standard');
expect(installed.initiator).toBe('user');
expect(installed.detail).toContain('Install Center');
});
it('failed install records audit entry', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/skills/starter-pack/nonexistent-xyz',
});
expect(res.statusCode).toBe(404);
// The API route handler returns 404, but the skill-tools install_capability
// path records failures. Check if any failed entries exist.
// Read through the server's writer connection (avoids the cross-connection
// WAL visibility race a fresh reader hits).
const total = server.auditStore.getAll().length;
// At least the brainstorm entry from the previous test should exist
expect(total).toBeGreaterThanOrEqual(1);
});
it('duplicate install does not create new audit entries', async () => {
const personalPath = path.join(tmpDir, 'personal.mind');
const db1 = new Database(personalPath);
const before = (db1.prepare("SELECT count(*) as c FROM install_audit").get() as { c: number }).c;
db1.close();
// Try to install already-installed skill
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/skills/starter-pack/brainstorm',
});
expect(res.statusCode).toBe(409);
const db2 = new Database(personalPath);
const after = (db2.prepare("SELECT count(*) as c FROM install_audit").get() as { c: number }).c;
db2.close();
// Server route handles duplicate before reaching skill-tools,
// so no new audit entries should be created via the API route
expect(after).toBe(before);
});
});
describe('Audit REST endpoint', () => {
it('GET /api/audit/installs returns recent install events', async () => {
// brainstorm was already installed in the previous test block
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/audit/installs',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.entries).toBeDefined();
expect(body.entries.length).toBeGreaterThan(0);
expect(body.entries[0].capabilityName).toBeDefined();
expect(body.entries[0].riskLevel).toBeDefined();
expect(body.entries[0].action).toBeDefined();
expect(body.entries[0].trustSource).toBeDefined();
expect(body.entries[0].timestamp).toBeDefined();
});
it('respects limit parameter', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/audit/installs?limit=1',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.entries.length).toBeLessThanOrEqual(1);
});
it('caps limit at 100', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/audit/installs?limit=999',
});
expect(res.statusCode).toBe(200);
// Just verify it doesn't error — the cap is internal
const body = JSON.parse(res.body);
expect(body.entries).toBeDefined();
});
});
describe('Backward compatibility', () => {
it('starter-pack catalog still works', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/skills/starter-pack/catalog',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.skills).toBeDefined();
expect(body.skills.length).toBeGreaterThan(0);
});
it('capabilities status endpoint still works', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/api/capabilities/status',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.skills).toBeDefined();
});
it('health endpoint still works', async () => {
const res = await injectWithAuth(server, {
method: 'GET',
url: '/health',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.mode).toBe('local');
});
});
});

View File

@@ -0,0 +1,302 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB } from '@waggle/core';
import { buildWorkspaceNowBlock, formatWorkspaceNowPrompt, type WorkspaceNowBlock } from '../../src/local/routes/workspace-context.js';
describe('workspace-context', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ctx-test-'));
});
afterEach(() => {
// On Windows, SQLite WAL files may keep handles open briefly; best-effort cleanup
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Non-critical — OS will clean temp dir
}
});
// Helper: create a workspace directory structure with a mind DB
function setupWorkspace(id: string, opts?: {
frames?: Array<{ content: string; importance: string }>;
sessions?: Array<{ title: string; messages: Array<{ role: string; content: string }> }>;
}) {
const wsDir = path.join(tmpDir, 'workspaces', id);
fs.mkdirSync(wsDir, { recursive: true });
// Write workspace.json
fs.writeFileSync(
path.join(wsDir, 'workspace.json'),
JSON.stringify({ id, name: `Test Workspace ${id}`, group: 'test', created: new Date().toISOString() }),
);
// Create mind DB with frames
const mindPath = path.join(wsDir, 'workspace.mind');
if (opts?.frames && opts.frames.length > 0) {
const db = new MindDB(mindPath);
const raw = db.getDatabase();
// Insert a session first (frames require a session via gop_id FK)
raw.prepare(
`INSERT INTO sessions (gop_id, status, started_at)
VALUES ('session:test', 'active', datetime('now'))`
).run();
for (const frame of opts.frames) {
raw.prepare(
`INSERT INTO memory_frames (gop_id, frame_type, content, importance, created_at)
VALUES ('session:test', 'I', ?, ?, datetime('now'))`
).run(frame.content, frame.importance);
}
db.close();
}
// Create session files
if (opts?.sessions) {
const sessDir = path.join(wsDir, 'sessions');
fs.mkdirSync(sessDir, { recursive: true });
for (let i = 0; i < opts.sessions.length; i++) {
const sess = opts.sessions[i];
const sessionId = `session-${i}`;
const lines: string[] = [
JSON.stringify({ type: 'meta', title: sess.title, created: new Date().toISOString() }),
];
for (const msg of sess.messages) {
lines.push(JSON.stringify({ role: msg.role, content: msg.content }));
}
fs.writeFileSync(path.join(sessDir, `${sessionId}.jsonl`), lines.join('\n') + '\n');
}
}
return { mindPath, wsDir };
}
function makeManager(workspaces: Map<string, { id: string; name: string }>) {
return {
get: (id: string) => workspaces.get(id) ?? null,
getMindPath: (id: string) => path.join(tmpDir, 'workspaces', id, 'workspace.mind'),
};
}
const noopActivate = (_id: string) => true;
// ── Test 1: Returns null for non-existent workspace ──────────────
it('returns null for non-existent workspace', () => {
const wsManager = makeManager(new Map());
const result = buildWorkspaceNowBlock({
dataDir: tmpDir,
workspaceId: 'does-not-exist',
wsManager,
activateWorkspaceMind: noopActivate,
});
expect(result).toBeNull();
});
// ── Test 2: Returns null for workspace with no data ──────────────
it('returns null for workspace with no mind file', () => {
const wsDir = path.join(tmpDir, 'workspaces', 'empty-ws');
fs.mkdirSync(wsDir, { recursive: true });
const wsManager = makeManager(new Map([
['empty-ws', { id: 'empty-ws', name: 'Empty Workspace' }],
]));
const result = buildWorkspaceNowBlock({
dataDir: tmpDir,
workspaceId: 'empty-ws',
wsManager,
activateWorkspaceMind: noopActivate,
});
expect(result).toBeNull();
});
it('returns null for workspace with mind file but zero frames', () => {
const wsManager = makeManager(new Map([
['zero-ws', { id: 'zero-ws', name: 'Zero Frames' }],
]));
// Create a mind DB with no frames
setupWorkspace('zero-ws', { frames: [] });
// MindDB file exists but has no frames — we need the file to exist
const mindPath = path.join(tmpDir, 'workspaces', 'zero-ws', 'workspace.mind');
const db = new MindDB(mindPath);
db.close();
const result = buildWorkspaceNowBlock({
dataDir: tmpDir,
workspaceId: 'zero-ws',
wsManager,
activateWorkspaceMind: noopActivate,
});
expect(result).toBeNull();
});
// ── Test 3: Returns correctly shaped block ───────────────────────
it('returns correctly shaped block for workspace with data', () => {
const wsManager = makeManager(new Map([
['rich-ws', { id: 'rich-ws', name: 'Rich Workspace' }],
]));
setupWorkspace('rich-ws', {
frames: [
{ content: 'Working on the Waggle project, a workspace-native AI agent platform.', importance: 'important' },
{ content: 'Decision: Use SQLite for local storage instead of PostgreSQL.', importance: 'critical' },
{ content: 'Implemented the memory frame system with FTS5 search.', importance: 'normal' },
],
sessions: [
{
title: 'Planning session',
messages: [
{ role: 'user', content: 'Let us plan the architecture for the next milestone' },
{ role: 'assistant', content: 'I have reviewed the current state and here is my recommendation.' },
],
},
],
});
const result = buildWorkspaceNowBlock({
dataDir: tmpDir,
workspaceId: 'rich-ws',
wsManager,
activateWorkspaceMind: noopActivate,
});
expect(result).not.toBeNull();
expect(result!.workspaceName).toBe('Rich Workspace');
expect(result!.summary).toBeTruthy();
expect(result!.summary.length).toBeGreaterThan(10);
expect(Array.isArray(result!.recentDecisions)).toBe(true);
expect(Array.isArray(result!.activeThreads)).toBe(true);
expect(Array.isArray(result!.progressItems)).toBe(true);
expect(Array.isArray(result!.nextActions)).toBe(true);
// Should have at least one thread from the session
expect(result!.activeThreads.length).toBeGreaterThanOrEqual(1);
// Should have at least one decision (the critical frame)
expect(result!.recentDecisions.length).toBeGreaterThanOrEqual(1);
});
// ── Test 4: Caps at correct limits ───────────────────────────────
it('caps decisions at 3, threads at 3, progress at 5', () => {
const wsManager = makeManager(new Map([
['capped-ws', { id: 'capped-ws', name: 'Capped Workspace' }],
]));
// Create many decisions and sessions
const frames = [];
for (let i = 0; i < 10; i++) {
frames.push({ content: `Decision: chose option ${i} for the architecture component ${i}`, importance: 'critical' as const });
}
const sessions = [];
for (let i = 0; i < 8; i++) {
sessions.push({
title: `Thread ${i}: discussing component ${i}`,
messages: [
{ role: 'user', content: `Tell me about component ${i}` },
{ role: 'assistant', content: `Here is info about component ${i}` },
],
});
}
setupWorkspace('capped-ws', { frames, sessions });
const result = buildWorkspaceNowBlock({
dataDir: tmpDir,
workspaceId: 'capped-ws',
wsManager,
activateWorkspaceMind: noopActivate,
});
expect(result).not.toBeNull();
expect(result!.recentDecisions.length).toBeLessThanOrEqual(3);
expect(result!.activeThreads.length).toBeLessThanOrEqual(3);
expect(result!.progressItems.length).toBeLessThanOrEqual(5);
expect(result!.nextActions.length).toBeLessThanOrEqual(3);
});
// ── Test 5: formatWorkspaceNowPrompt produces clean markdown ─────
describe('formatWorkspaceNowPrompt', () => {
it('produces clean markdown with all sections', () => {
const block: WorkspaceNowBlock = {
workspaceName: 'My Project',
summary: 'Working on an AI agent platform. Active today with 42 memories across 5 sessions.',
recentDecisions: ['Use SQLite for local storage', 'Deploy via Tauri'],
activeThreads: ['Architecture planning (2h ago)', 'Memory model design (yesterday)'],
progressItems: ['[blocker] Waiting for API key', '[task] Implement search', '[completed] Setup CI'],
nextActions: ['Resolve: Waiting for API key', 'Implement search'],
};
const result = formatWorkspaceNowPrompt(block);
expect(result).toContain('# Workspace Now — My Project');
expect(result).toContain('Working on an AI agent platform');
expect(result).toContain('## Recent Decisions');
expect(result).toContain('- Use SQLite for local storage');
expect(result).toContain('- Deploy via Tauri');
expect(result).toContain('## Active Threads');
expect(result).toContain('- Architecture planning (2h ago)');
expect(result).toContain('## Progress');
expect(result).toContain('- [blocker] Waiting for API key');
expect(result).toContain('## Likely Next Actions');
expect(result).toContain('- Resolve: Waiting for API key');
// No trailing whitespace
expect(result).toBe(result.trimEnd());
});
it('omits empty sections', () => {
const block: WorkspaceNowBlock = {
workspaceName: 'Minimal',
summary: 'A minimal workspace.',
recentDecisions: [],
activeThreads: [],
progressItems: [],
nextActions: [],
};
const result = formatWorkspaceNowPrompt(block);
expect(result).toContain('# Workspace Now — Minimal');
expect(result).toContain('A minimal workspace.');
expect(result).not.toContain('## Recent Decisions');
expect(result).not.toContain('## Active Threads');
expect(result).not.toContain('## Progress');
expect(result).not.toContain('## Likely Next Actions');
});
it('omits summary section if empty', () => {
const block: WorkspaceNowBlock = {
workspaceName: 'No Summary',
summary: '',
recentDecisions: ['Some decision'],
activeThreads: [],
progressItems: [],
nextActions: [],
};
const result = formatWorkspaceNowPrompt(block);
expect(result).toContain('# Workspace Now — No Summary');
expect(result).toContain('## Recent Decisions');
// Should not have blank lines between heading and decisions (no empty summary block)
const lines = result.split('\n');
const headingIdx = lines.findIndex(l => l.startsWith('# Workspace Now'));
const decisionsIdx = lines.findIndex(l => l === '## Recent Decisions');
// There should be only one blank line between heading and decisions section
expect(decisionsIdx - headingIdx).toBeLessThanOrEqual(2);
});
});
});

View File

@@ -0,0 +1,432 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB } from '@waggle/core';
import {
buildWorkspaceState,
formatWorkspaceStatePrompt,
computeFreshness,
type WorkspaceState,
} from '../../src/local/workspace-state.js';
describe('workspace-state', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ws-state-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
});
function setupWorkspace(id: string, opts?: {
frames?: Array<{ content: string; importance: string; created_at?: string }>;
sessions?: Array<{
title: string;
messages: Array<{ role: string; content: string }>;
ageMs?: number;
}>;
awareness?: Array<{ category: string; content: string; priority: number }>;
}) {
const wsDir = path.join(tmpDir, 'workspaces', id);
fs.mkdirSync(wsDir, { recursive: true });
const mindPath = path.join(wsDir, 'workspace.mind');
if (opts?.frames && opts.frames.length > 0) {
const db = new MindDB(mindPath);
const raw = db.getDatabase();
raw.prepare(
`INSERT INTO sessions (gop_id, status, started_at)
VALUES ('session:test', 'active', datetime('now'))`,
).run();
for (const frame of opts.frames) {
const createdAt = frame.created_at ?? new Date().toISOString();
raw.prepare(
`INSERT INTO memory_frames (gop_id, frame_type, content, importance, created_at)
VALUES ('session:test', 'I', ?, ?, ?)`,
).run(frame.content, frame.importance, createdAt);
}
if (opts?.awareness) {
for (const item of opts.awareness) {
raw.prepare(
`INSERT INTO awareness (category, content, priority, created_at)
VALUES (?, ?, ?, datetime('now'))`,
).run(item.category, item.content, item.priority);
}
}
db.close();
}
if (opts?.sessions) {
const sessDir = path.join(wsDir, 'sessions');
fs.mkdirSync(sessDir, { recursive: true });
for (let i = 0; i < opts.sessions.length; i++) {
const sess = opts.sessions[i];
const sessionId = `session-${i}`;
const created = new Date(Date.now() - (sess.ageMs ?? 0)).toISOString();
const lines: string[] = [
JSON.stringify({ type: 'meta', title: sess.title, created }),
];
for (const msg of sess.messages) {
lines.push(JSON.stringify({ role: msg.role, content: msg.content }));
}
const filePath = path.join(sessDir, `${sessionId}.jsonl`);
fs.writeFileSync(filePath, lines.join('\n') + '\n');
if (sess.ageMs) {
const pastTime = new Date(Date.now() - sess.ageMs);
fs.utimesSync(filePath, pastTime, pastTime);
}
}
}
return { mindPath, wsDir };
}
function makeManager(workspaces: Map<string, { id: string; name: string }>) {
return {
get: (id: string) => workspaces.get(id) ?? null,
getMindPath: (id: string) => path.join(tmpDir, 'workspaces', id, 'workspace.mind'),
};
}
const noopActivate = (_id: string) => true;
// ── computeFreshness ──────────────────────────────────────────
describe('computeFreshness', () => {
it('returns fresh for today', () => {
expect(computeFreshness(new Date().toISOString())).toBe('fresh');
});
it('returns fresh for 1 day ago', () => {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
expect(computeFreshness(yesterday)).toBe('fresh');
});
it('returns aging for 3 days ago', () => {
const threeDays = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
expect(computeFreshness(threeDays)).toBe('aging');
});
it('returns stale for 10 days ago', () => {
const tenDays = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
expect(computeFreshness(tenDays)).toBe('stale');
});
});
// ── buildWorkspaceState ───────────────────────────────────────
describe('buildWorkspaceState', () => {
it('returns null for non-existent workspace', () => {
const result = buildWorkspaceState({
dataDir: tmpDir,
workspaceId: 'nonexistent',
wsManager: makeManager(new Map()),
activateWorkspaceMind: noopActivate,
});
expect(result).toBeNull();
});
it('returns null for workspace with no mind file', () => {
const wsDir = path.join(tmpDir, 'workspaces', 'empty');
fs.mkdirSync(wsDir, { recursive: true });
const result = buildWorkspaceState({
dataDir: tmpDir,
workspaceId: 'empty',
wsManager: makeManager(new Map([['empty', { id: 'empty', name: 'Empty' }]])),
activateWorkspaceMind: noopActivate,
});
expect(result).toBeNull();
});
it('returns null for workspace with zero memory frames', () => {
fs.mkdirSync(path.join(tmpDir, 'workspaces', 'noframes'), { recursive: true });
const db = new MindDB(path.join(tmpDir, 'workspaces', 'noframes', 'workspace.mind'));
db.close();
const result = buildWorkspaceState({
dataDir: tmpDir,
workspaceId: 'noframes',
wsManager: makeManager(new Map([['noframes', { id: 'noframes', name: 'No Frames' }]])),
activateWorkspaceMind: noopActivate,
});
expect(result).toBeNull();
});
it('returns state with decisions from memory frames', () => {
setupWorkspace('ws1', {
frames: [
{ content: 'Decision: Use SQLite for local storage', importance: 'critical' },
{ content: 'Working on the authentication module', importance: 'normal' },
],
});
const result = buildWorkspaceState({
dataDir: tmpDir,
workspaceId: 'ws1',
wsManager: makeManager(new Map([['ws1', { id: 'ws1', name: 'Project Alpha' }]])),
activateWorkspaceMind: noopActivate,
});
expect(result).not.toBeNull();
expect(result!.recentDecisions.length).toBeGreaterThanOrEqual(1);
expect(result!.recentDecisions[0].content).toContain('SQLite');
expect(result!.recentDecisions[0].source).toBe('memory');
});
it('extracts pending tasks from sessions', () => {
setupWorkspace('ws2', {
frames: [{ content: 'Project context established', importance: 'normal' }],
sessions: [{
title: 'Planning',
messages: [
{ role: 'user', content: 'We need to implement the rate limiting middleware for the API' },
{ role: 'assistant', content: 'I will add that to the plan.' },
{ role: 'user', content: 'Also, we should add comprehensive error handling to the service layer' },
],
}],
});
const result = buildWorkspaceState({
dataDir: tmpDir,
workspaceId: 'ws2',
wsManager: makeManager(new Map([['ws2', { id: 'ws2', name: 'API Project' }]])),
activateWorkspaceMind: noopActivate,
});
expect(result).not.toBeNull();
// pending items come from TASK_PATTERNS in extractProgressItems
// "need to" triggers task pattern
expect(result!.pending.length).toBeGreaterThanOrEqual(1);
});
it('extracts open questions from sessions', () => {
setupWorkspace('ws3', {
frames: [{ content: 'Working on infrastructure', importance: 'normal' }],
sessions: [{
title: 'Infra discussion',
messages: [
{ role: 'user', content: 'Should we use containers or bare metal for the deployment?' },
{ role: 'assistant', content: 'Both have tradeoffs.' },
{ role: 'user', content: 'We still need to decide on the hosting provider strategy.' },
],
}],
});
const result = buildWorkspaceState({
dataDir: tmpDir,
workspaceId: 'ws3',
wsManager: makeManager(new Map([['ws3', { id: 'ws3', name: 'Infra' }]])),
activateWorkspaceMind: noopActivate,
});
expect(result).not.toBeNull();
expect(result!.openQuestions.length).toBeGreaterThanOrEqual(1);
});
it('classifies fresh and stale threads', () => {
const tenDays = 10 * 24 * 60 * 60 * 1000;
setupWorkspace('ws4', {
frames: [{ content: 'Multi-thread workspace', importance: 'normal' }],
sessions: [
{
title: 'Fresh thread',
messages: [
{ role: 'user', content: 'Working on fresh stuff right now.' },
{ role: 'assistant', content: 'On it.' },
],
},
{
title: 'Old thread',
messages: [
{ role: 'user', content: 'This was from ten days ago.' },
{ role: 'assistant', content: 'Noted.' },
],
ageMs: tenDays,
},
],
});
const result = buildWorkspaceState({
dataDir: tmpDir,
workspaceId: 'ws4',
wsManager: makeManager(new Map([['ws4', { id: 'ws4', name: 'Multi' }]])),
activateWorkspaceMind: noopActivate,
});
expect(result).not.toBeNull();
// Fresh session should appear in active
expect(result!.active.some(a => a.content === 'Fresh thread')).toBe(true);
// Stale session should appear in stale
expect(result!.stale.some(s => s.content === 'Old thread')).toBe(true);
});
it('derives nextActions from blockers and pending items', () => {
setupWorkspace('ws5', {
frames: [{ content: 'Active project', importance: 'normal' }],
sessions: [{
title: 'Work session',
messages: [
{ role: 'user', content: 'We are blocked by the missing SSL certificate for the staging server' },
{ role: 'assistant', content: 'That is a critical blocker.' },
{ role: 'user', content: 'We need to update the CI pipeline configuration as well' },
],
}],
});
const result = buildWorkspaceState({
dataDir: tmpDir,
workspaceId: 'ws5',
wsManager: makeManager(new Map([['ws5', { id: 'ws5', name: 'Blocked' }]])),
activateWorkspaceMind: noopActivate,
});
expect(result).not.toBeNull();
expect(result!.nextActions.length).toBeGreaterThanOrEqual(1);
// Blockers should appear first in nextActions
if (result!.blocked.length > 0) {
expect(result!.nextActions[0]).toContain('Resolve:');
}
});
it('returns all expected fields', () => {
setupWorkspace('ws6', {
frames: [
{ content: 'Decision: Use React for frontend', importance: 'critical' },
{ content: 'Project initialized', importance: 'normal' },
],
sessions: [{
title: 'Setup session',
messages: [
{ role: 'user', content: 'Let us set up the project structure.' },
{ role: 'assistant', content: 'I will create the scaffold.' },
],
}],
});
const result = buildWorkspaceState({
dataDir: tmpDir,
workspaceId: 'ws6',
wsManager: makeManager(new Map([['ws6', { id: 'ws6', name: 'Full' }]])),
activateWorkspaceMind: noopActivate,
});
expect(result).not.toBeNull();
expect(result).toHaveProperty('active');
expect(result).toHaveProperty('openQuestions');
expect(result).toHaveProperty('pending');
expect(result).toHaveProperty('blocked');
expect(result).toHaveProperty('completed');
expect(result).toHaveProperty('stale');
expect(result).toHaveProperty('recentDecisions');
expect(result).toHaveProperty('nextActions');
expect(Array.isArray(result!.active)).toBe(true);
expect(Array.isArray(result!.nextActions)).toBe(true);
});
});
// ── formatWorkspaceStatePrompt ────────────────────────────────
describe('formatWorkspaceStatePrompt', () => {
it('formats empty state with just the header', () => {
const state: WorkspaceState = {
active: [],
openQuestions: [],
pending: [],
blocked: [],
completed: [],
stale: [],
recentDecisions: [],
nextActions: [],
};
const result = formatWorkspaceStatePrompt(state, 'Test Workspace');
expect(result).toContain('# Workspace Now — Test Workspace');
expect(result).not.toContain('## Active');
});
it('includes Active section when there are active items', () => {
const state: WorkspaceState = {
active: [{
content: 'Working on auth',
freshness: 'fresh',
source: 'session',
dateLastTouched: '2026-03-14',
}],
openQuestions: [],
pending: [],
blocked: [],
completed: [],
stale: [],
recentDecisions: [],
nextActions: [],
};
const result = formatWorkspaceStatePrompt(state, 'Auth Project');
expect(result).toContain('## Active');
expect(result).toContain('Working on auth');
});
it('annotates aging items', () => {
const state: WorkspaceState = {
active: [{
content: 'Database migration',
freshness: 'aging',
source: 'session',
dateLastTouched: '2026-03-10',
}],
openQuestions: [],
pending: [],
blocked: [],
completed: [],
stale: [],
recentDecisions: [],
nextActions: [],
};
const result = formatWorkspaceStatePrompt(state, 'DB');
expect(result).toContain('(aging)');
});
it('includes all populated sections', () => {
const state: WorkspaceState = {
active: [{ content: 'Thread A', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
openQuestions: [{ content: 'Which DB?', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
pending: [{ content: 'Add tests', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
blocked: [{ content: 'Missing API key', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
completed: [{ content: 'Setup done', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
stale: [{ content: 'Old thread', freshness: 'stale', source: 'session', dateLastTouched: '2026-03-01' }],
recentDecisions: [{ content: 'Use React', freshness: 'fresh', source: 'memory', dateLastTouched: '2026-03-14' }],
nextActions: ['Resolve: Missing API key', 'Add tests'],
};
const result = formatWorkspaceStatePrompt(state, 'Full');
expect(result).toContain('## Active');
expect(result).toContain('## Open Questions');
expect(result).toContain('## Blocked');
expect(result).toContain('## Pending');
expect(result).toContain('## Completed');
expect(result).toContain('## Needs Attention (stale)');
expect(result).toContain('## Recent Decisions');
expect(result).toContain('## Likely Next Actions');
});
it('marks stale items as not touched recently', () => {
const state: WorkspaceState = {
active: [],
openQuestions: [],
pending: [],
blocked: [],
completed: [],
stale: [{ content: 'Forgotten thread', freshness: 'stale', source: 'session', dateLastTouched: '2026-02-01' }],
recentDecisions: [],
nextActions: [],
};
const result = formatWorkspaceStatePrompt(state, 'Stale');
expect(result).toContain('not touched recently');
});
});
});