This commit is contained in:
227
packages/server/tests/routes/acquisition-integration.test.ts
Normal file
227
packages/server/tests/routes/acquisition-integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
119
packages/server/tests/routes/agent-search.test.ts
Normal file
119
packages/server/tests/routes/agent-search.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
307
packages/server/tests/routes/agents.test.ts
Normal file
307
packages/server/tests/routes/agents.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
261
packages/server/tests/routes/analytics.test.ts
Normal file
261
packages/server/tests/routes/analytics.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
133
packages/server/tests/routes/approval-flow.test.ts
Normal file
133
packages/server/tests/routes/approval-flow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
250
packages/server/tests/routes/capabilities.test.ts
Normal file
250
packages/server/tests/routes/capabilities.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
95
packages/server/tests/routes/capability-packs.test.ts
Normal file
95
packages/server/tests/routes/capability-packs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
packages/server/tests/routes/command-interpret.test.ts
Normal file
50
packages/server/tests/routes/command-interpret.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
176
packages/server/tests/routes/commands.test.ts
Normal file
176
packages/server/tests/routes/commands.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
33
packages/server/tests/routes/connectors-tier.test.ts
Normal file
33
packages/server/tests/routes/connectors-tier.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
265
packages/server/tests/routes/context-injection.test.ts
Normal file
265
packages/server/tests/routes/context-injection.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
231
packages/server/tests/routes/cron-api.test.ts
Normal file
231
packages/server/tests/routes/cron-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
126
packages/server/tests/routes/health.test.ts
Normal file
126
packages/server/tests/routes/health.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
291
packages/server/tests/routes/knowledge.test.ts
Normal file
291
packages/server/tests/routes/knowledge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
392
packages/server/tests/routes/messages.test.ts
Normal file
392
packages/server/tests/routes/messages.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
254
packages/server/tests/routes/persistence.test.ts
Normal file
254
packages/server/tests/routes/persistence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
226
packages/server/tests/routes/resources.test.ts
Normal file
226
packages/server/tests/routes/resources.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
497
packages/server/tests/routes/session-state-extraction.test.ts
Normal file
497
packages/server/tests/routes/session-state-extraction.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
141
packages/server/tests/routes/session-timeline.test.ts
Normal file
141
packages/server/tests/routes/session-timeline.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
182
packages/server/tests/routes/starter-catalog.test.ts
Normal file
182
packages/server/tests/routes/starter-catalog.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
210
packages/server/tests/routes/tasks.test.ts
Normal file
210
packages/server/tests/routes/tasks.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
288
packages/server/tests/routes/teams.test.ts
Normal file
288
packages/server/tests/routes/teams.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
195
packages/server/tests/routes/trust-wiring.test.ts
Normal file
195
packages/server/tests/routes/trust-wiring.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
302
packages/server/tests/routes/workspace-context.test.ts
Normal file
302
packages/server/tests/routes/workspace-context.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
432
packages/server/tests/routes/workspace-state.test.ts
Normal file
432
packages/server/tests/routes/workspace-state.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user