moving
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
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 } from '../src/local/lifecycle.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 {
|
||||
@@ -15,6 +17,26 @@ 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[] = [];
|
||||
@@ -107,7 +129,7 @@ describe('Agent Service', () => {
|
||||
const port = randomPort();
|
||||
const litellmPort = randomPort();
|
||||
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', '');
|
||||
clearProviderEnv();
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/health/liveliness')) {
|
||||
@@ -139,6 +161,242 @@ describe('Agent Service', () => {
|
||||
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);
|
||||
@@ -156,6 +414,27 @@ describe('Agent Service', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user