Files
waggle-os/packages/server/tests/service.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

446 lines
16 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import os from 'node:os';
import { MindDB } from '@waggle/core';
import { startService } from '../src/local/service.js';
import { getLiteLLMStatus, selectLiteLLMPython } from '../src/local/lifecycle.js';
import { PROVIDER_ENV_NAMES } from '../src/local/provider-env.js';
import type { FastifyInstance } from 'fastify';
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-svc-test-'));
}
function randomPort(): number {
return 3333 + Math.floor(Math.random() * 1000);
}
function occupyLoopbackPort(server: net.Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Unable to resolve occupied test port'));
return;
}
resolve(address.port);
});
});
}
function clearProviderEnv(): void {
for (const envName of new Set(Object.values(PROVIDER_ENV_NAMES).flat())) {
vi.stubEnv(envName, '');
}
}
describe('Agent Service', () => {
const cleanups: Array<() => Promise<void>> = [];
const tmpDirs: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
// Close all servers
for (const cleanup of cleanups) {
await cleanup();
}
cleanups.length = 0;
// Remove temp dirs
for (const dir of tmpDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
tmpDirs.length = 0;
});
it('creates data dir if it does not exist', async () => {
const base = makeTmpDir();
tmpDirs.push(base);
const dataDir = path.join(base, 'nested', 'waggle-data');
const port = randomPort();
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
cleanups.push(async () => { await server.close(); });
expect(fs.existsSync(dataDir)).toBe(true);
});
it('runs migration when default.mind exists', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
// Create a default.mind (simulating pre-M4 layout)
const defaultPath = path.join(dataDir, 'default.mind');
const mind = new MindDB(defaultPath);
mind.close();
expect(fs.existsSync(defaultPath)).toBe(true);
const port = randomPort();
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
cleanups.push(async () => { await server.close(); });
// After migration: personal.mind exists, default.mind renamed to .bak
expect(fs.existsSync(path.join(dataDir, 'personal.mind'))).toBe(true);
expect(fs.existsSync(path.join(dataDir, 'default.mind.bak'))).toBe(true);
expect(fs.existsSync(defaultPath)).toBe(false);
});
it('creates personal.mind if fresh install (no default.mind)', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = randomPort();
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
cleanups.push(async () => { await server.close(); });
const personalPath = path.join(dataDir, 'personal.mind');
expect(fs.existsSync(personalPath)).toBe(true);
});
it('returns running server (health check works)', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = randomPort();
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
cleanups.push(async () => { await server.close(); });
// Use inject (no real HTTP needed)
const res = await server.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// With skipLiteLLM, health is degraded (no verified LLM), not 'ok'
expect(['ok', 'degraded', 'unavailable']).toContain(body.status);
expect(body.mode).toBe('local');
// Deep health: LLM provider and database status present
expect(body.llm).toBeDefined();
expect(body.llm.provider).toBeDefined();
expect(body.database).toBeDefined();
});
it('reports healthy when a fresh local install can use Ollama without a cloud key', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = randomPort();
const litellmPort = randomPort();
clearProviderEnv();
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input);
if (url.endsWith('/health/liveliness')) {
return { ok: false, status: 503 } as Response;
}
if (url.endsWith('/api/tags')) {
return {
ok: true,
json: async () => ({
models: [
{ name: 'nomic-embed-text:latest' },
{ name: 'llama3.2:latest' },
],
}),
} as Response;
}
return { ok: false, status: 404 } as Response;
});
const { server } = await startService({ dataDir, port, litellmPort, skipLiteLLM: true });
cleanups.push(async () => { await server.close(); });
const res = await server.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.status).toBe('ok');
expect(body.llm.provider).toBe('ollama');
expect(body.llm.health).toBe('healthy');
expect(body.defaultModel).toBe('ollama/llama3.2:latest');
});
it('does not let a pre-provider readiness probe overwrite fresh Ollama health', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = randomPort();
const litellmPort = randomPort();
let releaseStaleProbe!: () => void;
const staleProbeGate = new Promise<void>((resolve) => { releaseStaleProbe = resolve; });
let markInitialProbeConsumed!: () => void;
const initialProbeConsumed = new Promise<void>((resolve) => { markInitialProbeConsumed = resolve; });
let ollamaTagsCalls = 0;
clearProviderEnv();
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input);
if (url.endsWith('/health/liveliness')) {
return { ok: false, status: 503 } as Response;
}
if (url.endsWith('/health/readiness')) {
await staleProbeGate;
return {
get ok() {
markInitialProbeConsumed();
return false;
},
status: 404,
} as Response;
}
if (url.endsWith('/api/tags')) {
ollamaTagsCalls += 1;
if (ollamaTagsCalls === 1) setImmediate(releaseStaleProbe);
return {
ok: true,
json: async () => {
if (ollamaTagsCalls > 1) markInitialProbeConsumed();
return { models: [{ name: 'llama3.2:latest' }] };
},
} as Response;
}
return { ok: false, status: 404 } as Response;
});
const { server } = await startService({ dataDir, port, litellmPort, skipLiteLLM: true });
cleanups.push(async () => { await server.close(); });
await initialProbeConsumed;
await new Promise<void>((resolve) => { setImmediate(resolve); });
const res = await server.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
status: 'ok',
llm: {
provider: 'ollama',
health: 'healthy',
reachable: true,
},
defaultModel: 'ollama/llama3.2:latest',
});
});
it('reports the built-in provider proxy degraded until a configured key is verified', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = randomPort();
const litellmPort = randomPort();
clearProviderEnv();
vi.stubEnv('OPENAI_API_KEY', 'openai-solo-test-key');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input);
if (url.endsWith('/health/liveliness') || url.endsWith('/api/tags')) {
return { ok: false, status: 503 } as Response;
}
return { ok: false, status: 404 } as Response;
});
const { server } = await startService({ dataDir, port, litellmPort, skipLiteLLM: true });
cleanups.push(async () => { await server.close(); });
const res = await server.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
status: 'degraded',
llm: {
provider: 'anthropic-proxy',
health: 'degraded',
},
});
expect(server.agentState.llmProvider.detail).toContain('provider proxy');
expect(server.agentState.llmProvider.detail).toContain('verification pending');
expect(server.localConfig.manageLiteLLM).toBe(false);
});
it('does not probe or adopt an unrelated LiteLLM when explicitly skipped', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = randomPort();
const litellmPort = randomPort();
clearProviderEnv();
vi.stubEnv('OPENAI_API_KEY', 'openai-solo-test-key');
const workerRequests: Array<{ url: string; authorization: string | null }> = [];
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input);
if (url === `http://127.0.0.1:${port}/v1/chat/completions`) {
workerRequests.push({
url,
authorization: new Headers(init?.headers).get('authorization'),
});
return new Response(JSON.stringify({
choices: [{
message: { role: 'assistant', content: 'Isolated sub-agent response.' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 7, completion_tokens: 4 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response('{}', { status: 503 });
});
const { server, litellm } = await startService({ dataDir, port, litellmPort, skipLiteLLM: true });
cleanups.push(async () => { await server.close(); });
expect(litellm).toEqual({ status: 'error', port: litellmPort, error: 'Skipped' });
expect(server.agentState.llmProvider).toMatchObject({
provider: 'anthropic-proxy',
health: 'degraded',
});
expect(server.localConfig.manageLiteLLM).toBe(false);
expect(server.localConfig.useBuiltInProxy).toBe(true);
expect(server.localConfig.litellmUrl).toBe(`http://127.0.0.1:${port}/v1`);
expect(fetchSpy.mock.calls.some(([input]) => (
String(input) === `http://localhost:${litellmPort}/health/liveliness`
))).toBe(false);
const spawn = server.agentState.allTools.find(tool => tool.name === 'spawn_agent');
expect(spawn).toBeDefined();
const output = await spawn!.execute({
name: 'Isolation verifier',
role: 'custom',
task: 'Confirm the active provider route.',
tools: [],
model: 'openrouter/openai/gpt-5.3-codex',
max_turns: 1,
});
expect(output).toContain('Isolated sub-agent response.');
expect(workerRequests).toEqual([{
url: `http://127.0.0.1:${port}/v1/chat/completions`,
authorization: `Bearer ${server.agentState.wsSessionToken}`,
}]);
});
it('atomically falls back from an occupied desktop port and routes spawned agents to it', async () => {
const base = makeTmpDir();
tmpDirs.push(base);
const dataDir = path.join(base, 'data');
const readyFile = path.join(base, 'desktop-ready.json');
const blocker = net.createServer();
const preferredPort = await occupyLoopbackPort(blocker);
cleanups.push(() => new Promise<void>((resolve) => blocker.close(() => resolve())));
vi.stubEnv('WAGGLE_DESKTOP_PORT_FALLBACK', '1');
vi.stubEnv('WAGGLE_INSTANCE_ID', 'desktop-fallback-test');
vi.stubEnv('WAGGLE_READY_FILE', readyFile);
vi.stubEnv('WAGGLE_BIND_ALL', '1');
clearProviderEnv();
vi.stubEnv('OPENAI_API_KEY', 'openai-solo-test-key');
const workerRequests: Array<{ url: string; authorization: string | null }> = [];
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith('/health/liveliness') || url.endsWith('/api/tags')) {
return new Response('{}', { status: 503 });
}
if (url.endsWith('/v1/chat/completions')) {
workerRequests.push({
url,
authorization: new Headers(init?.headers).get('authorization'),
});
return new Response(JSON.stringify({
choices: [{
message: { role: 'assistant', content: 'Fallback sub-agent response.' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 7, completion_tokens: 4 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response('{}', { status: 503 });
});
const { server } = await startService({ dataDir, port: preferredPort, skipLiteLLM: true });
const address = server.server.address();
expect(address && typeof address === 'object').toBe(true);
const actualPort = address && typeof address === 'object' ? address.port : 0;
expect(address && typeof address === 'object' ? address.address : '').toBe('127.0.0.1');
expect(actualPort).toBeGreaterThan(0);
expect(actualPort).not.toBe(preferredPort);
expect(server.localConfig.port).toBe(actualPort);
expect(server.localConfig.litellmUrl).toBe(`http://127.0.0.1:${actualPort}/v1`);
expect(JSON.parse(fs.readFileSync(readyFile, 'utf8'))).toMatchObject({
schemaVersion: 1,
instanceId: 'desktop-fallback-test',
pid: process.pid,
preferredPort,
port: actualPort,
});
expect((await server.inject({ method: 'GET', url: '/health' })).json()).toMatchObject({
instanceId: 'desktop-fallback-test',
port: actualPort,
});
const spawn = server.agentState.allTools.find(tool => tool.name === 'spawn_agent');
expect(spawn).toBeDefined();
expect(await spawn!.execute({
name: 'Fallback verifier',
role: 'custom',
task: 'Confirm the fallback provider route.',
tools: [],
model: 'openrouter/openai/gpt-5.3-codex',
max_turns: 1,
})).toContain('Fallback sub-agent response.');
expect(workerRequests).toEqual([{
url: `http://127.0.0.1:${actualPort}/v1/chat/completions`,
authorization: `Bearer ${server.agentState.wsSessionToken}`,
}]);
await server.close();
expect(fs.existsSync(readyFile)).toBe(false);
});
it('server gracefully shuts down on close', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = randomPort();
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
// Close it
await server.close();
// After close, inject should fail or server should be closed
// Fastify sets server.server.listening to false after close
expect(server.server.listening).toBe(false);
});
});
describe('LiteLLM Lifecycle', () => {
it('skips a Hermes venv without LiteLLM and selects the next working interpreter', () => {
const hermesPython = 'C:\\Users\\test\\hermes\\venv\\Scripts\\python.exe';
const systemPython = 'C:\\Python311\\python.exe';
const probed: string[] = [];
const selected = selectLiteLLMPython(
[hermesPython, systemPython],
(candidate) => {
probed.push(candidate);
return candidate === systemPython;
},
);
expect(selected).toBe(systemPython);
expect(probed).toEqual([hermesPython, systemPython]);
});
it('returns null when no discovered interpreter can import LiteLLM', () => {
expect(selectLiteLLMPython(['python-a', 'python-b'], () => false)).toBeNull();
});
it('getLiteLLMStatus returns error when nothing is running', async () => {
// Use a very unlikely port
const status = await getLiteLLMStatus(59999);
expect(status.status).toBe('error');
expect(status.port).toBe(59999);
expect(status.error).toContain('not running');
});
});