import { afterEach, describe, expect, it, vi } from 'vitest'; import Fastify from 'fastify'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { AgentResponse } from '@waggle/agent'; import { fleetRoutes } from '../../src/local/routes/fleet.js'; import { AgentRunRegistry } from '../../src/local/agent-run-registry.js'; const tempDirs: string[] = []; function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } async function waitFor(predicate: () => boolean, message: string): Promise { for (let attempt = 0; attempt < 100; attempt++) { if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, 5)); } throw new Error(message); } afterEach(() => { vi.restoreAllMocks(); for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); }); describe('isolated Fleet execution', () => { it('rejects an unavailable explicit model without creating a run and uses an available explicit model exactly', async () => { const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fleet-explicit-model-')); tempDirs.push(dataDir); const workspaceDir = path.join(dataDir, 'project'); fs.mkdirSync(workspaceDir); const registry = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json')); const runnerModels: string[] = []; vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'anthropic/claude-explicit' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } })); const server = Fastify({ logger: false }); server.decorate('localConfig', { dataDir, port: 0, host: '127.0.0.1', litellmUrl: 'http://llm.test', manageLiteLLM: false, }); server.decorate('agentRunRegistry', registry); server.decorate('vault', { get: (provider: string) => provider === 'anthropic' ? { value: 'anthropic-test-key' } : undefined, } as never); server.decorate('workspaceManager', { getDefault: () => 'workspace-1', list: () => [{ id: 'workspace-1' }], get: (id: string) => id === 'workspace-1' ? { id, name: 'Project', group: 'test', created: new Date().toISOString(), directory: workspaceDir, model: 'anthropic/workspace-default' } : undefined, } as never); server.decorate('sessionManager', { getMaxSessions: () => 10, size: 0, getActive: () => [] } as never); server.decorate('mindCache', { acquire: () => ({}), release: () => {} } as never); server.decorate('agentState', { currentModel: 'anthropic/current-fallback', litellmApiKey: 'test-key', createSessionOrchestrator: () => ({ setGoalAncestry: () => {}, buildSystemPrompt: () => 'system', buildAssembledPrompt: async () => ({ system: 'assembled', responseScaffold: '', debug: {} }), }), buildToolsForSession: () => [], } as never); server.decorate('agentRunner', async (config: { model: string }) => { runnerModels.push(config.model); return { content: 'Done', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 } }; }); server.decorate('fleetResultRecorder', async ({ run }) => ({ status: 'complete', personalFrameIds: [1], workspaceFrameIds: { [run.workspaceId]: [2] }, })); await server.register(fleetRoutes); const unavailable = await server.inject({ method: 'POST', url: '/api/fleet/spawn', payload: { task: 'Do not substitute', model: 'openai/gpt-explicit-missing', parentWorkspaceId: 'workspace-1' }, }); expect(unavailable.statusCode).toBe(409); expect(unavailable.json()).toMatchObject({ error: 'model_unavailable' }); expect(unavailable.json().message).toContain('openai/gpt-explicit-missing'); expect(registry.snapshot().runs).toHaveLength(0); expect(runnerModels).toHaveLength(0); const explicitModel = 'anthropic/claude-explicit'; const available = await server.inject({ method: 'POST', url: '/api/fleet/spawn', payload: { task: 'Use this exact model', model: explicitModel, parentWorkspaceId: 'workspace-1' }, }); expect(available.statusCode).toBe(202); const body = available.json() as { runId: string; model: string }; expect(body.model).toBe(explicitModel); await waitFor(() => runnerModels.length === 1, 'explicit model run did not start'); expect(runnerModels).toEqual([explicitModel]); expect(registry.get(body.runId)?.executor.model).toBe(explicitModel); await server.close(); }); it('runs two same-workspace agents with distinct orchestrators, tools, and abort signals', async () => { const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fleet-isolation-')); tempDirs.push(dataDir); const workspaceDir = path.join(dataDir, 'project'); fs.mkdirSync(workspaceDir); const registry = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json')); const releases: string[] = []; const orchestrators: unknown[] = []; const toolBuilds: Array<{ cwd: string; workspaceId?: string; tools: unknown[] }> = []; const calls: Array<{ signal?: AbortSignal; tools: unknown[]; finish: ReturnType> }> = []; const memoryRuns: string[] = []; const server = Fastify({ logger: false }); server.decorate('localConfig', { dataDir, port: 0, host: '127.0.0.1', litellmUrl: 'http://llm.test' }); server.decorate('agentRunRegistry', registry); server.decorate('workspaceManager', { getDefault: () => 'workspace-1', list: () => [{ id: 'workspace-1' }], get: (id: string) => id === 'workspace-1' ? { id, name: 'Project', group: 'test', created: new Date().toISOString(), directory: workspaceDir, model: 'test-model' } : undefined, } as never); server.decorate('sessionManager', { getMaxSessions: () => 10, size: 0, getActive: () => [] } as never); server.decorate('mindCache', { acquire: (workspaceId: string) => ({ workspaceId }), release: (workspaceId: string) => { releases.push(workspaceId); }, } as never); server.decorate('agentState', { currentModel: 'test-model', litellmApiKey: 'test-key', createSessionOrchestrator: () => { const orchestrator = { setGoalAncestry: () => {}, buildSystemPrompt: () => 'system', buildAssembledPrompt: async () => ({ system: 'assembled', responseScaffold: '', debug: {} }), }; orchestrators.push(orchestrator); return orchestrator; }, buildToolsForSession: (_orchestrator: unknown, cwd: string, workspaceId?: string) => { const tools = [{ name: `tool-${toolBuilds.length}`, description: '', parameters: {}, execute: async () => '' }]; toolBuilds.push({ cwd, workspaceId, tools }); return tools; }, } as never); server.decorate('agentRunner', (config: { signal?: AbortSignal; tools: unknown[] }) => { const finish = deferred(); calls.push({ signal: config.signal, tools: config.tools, finish }); config.signal?.addEventListener('abort', () => finish.resolve({ content: 'Cancelled', toolsUsed: [], usage: { inputTokens: 0, outputTokens: 0 }, }), { once: true }); return finish.promise; }); server.decorate('fleetResultRecorder', async ({ run }) => { memoryRuns.push(run.id); return { status: 'complete', personalFrameIds: [1], workspaceFrameIds: { [run.workspaceId]: [2] } }; }); await server.register(fleetRoutes); const firstResponse = await server.inject({ method: 'POST', url: '/api/fleet/spawn', payload: { task: 'First task', persona: 'researcher', parentWorkspaceId: 'workspace-1' }, }); const secondResponse = await server.inject({ method: 'POST', url: '/api/fleet/spawn', payload: { task: 'Second task', persona: 'writer', parentWorkspaceId: 'workspace-1' }, }); expect(firstResponse.statusCode).toBe(202); expect(secondResponse.statusCode).toBe(202); const first = firstResponse.json() as { runId: string; roomId: string; resumable: boolean; statusUrl: string }; const second = secondResponse.json() as { runId: string; roomId: string; resumable: boolean; statusUrl: string }; expect(first.runId).not.toBe(second.runId); expect(first.roomId).not.toBe(second.roomId); expect(first.resumable).toBe(false); expect(first.statusUrl).toBe(`/api/agent-runs/${first.runId}`); await waitFor(() => calls.length === 2, 'both agents did not enter the runner'); expect(orchestrators).toHaveLength(2); expect(orchestrators[0]).not.toBe(orchestrators[1]); expect(toolBuilds).toHaveLength(2); expect(toolBuilds.every((build) => build.cwd === fs.realpathSync(workspaceDir))).toBe(true); expect(toolBuilds.every((build) => build.workspaceId === 'workspace-1')).toBe(true); expect(calls[0].tools).not.toBe(calls[1].tools); expect(calls[0].signal).not.toBe(calls[1].signal); await registry.control(first.runId, 'cancel'); expect(registry.get(first.runId)?.status).toBe('cancelled'); expect(registry.get(second.runId)?.status).toBe('running'); calls[1].finish.resolve({ content: 'Second completed', toolsUsed: ['tool-1'], usage: { inputTokens: 3, outputTokens: 4 }, }); await waitFor(() => registry.get(second.runId)?.status === 'completed', 'second run did not complete'); expect(registry.get(second.runId)).toMatchObject({ status: 'completed', result: { summary: 'Second completed' }, metrics: { inputTokens: 3, outputTokens: 4 }, memoryRefs: { status: 'complete' }, }); expect(registry.get(second.roomId)).toMatchObject({ status: 'completed', result: { summary: 'Second completed' }, memoryRefs: { status: 'complete' }, }); expect(memoryRuns).toHaveLength(2); expect(releases).toEqual(['workspace-1', 'workspace-1']); const restored = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json')); expect(restored.get(first.runId)?.status).toBe('cancelled'); expect(restored.get(second.runId)?.status).toBe('completed'); await server.close(); }); it('enforces the shared concurrency cap before creating another run', async () => { const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fleet-cap-')); tempDirs.push(dataDir); const workspaceDir = path.join(dataDir, 'project'); fs.mkdirSync(workspaceDir); const registry = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json')); const server = Fastify({ logger: false }); server.decorate('localConfig', { dataDir, port: 0, host: '127.0.0.1', litellmUrl: '' }); server.decorate('agentRunRegistry', registry); server.decorate('workspaceManager', { getDefault: () => 'workspace-1', list: () => [{ id: 'workspace-1' }], get: () => ({ id: 'workspace-1', name: 'Project', group: 'test', created: new Date().toISOString(), directory: workspaceDir, model: 'test-model' }), } as never); server.decorate('sessionManager', { getMaxSessions: () => 1, size: 0, getActive: () => [] } as never); server.decorate('mindCache', { acquire: () => ({}), release: () => {} } as never); server.decorate('agentState', { currentModel: 'test-model', litellmApiKey: 'key', createSessionOrchestrator: () => ({ setGoalAncestry: () => {}, buildSystemPrompt: () => 'system' }), buildToolsForSession: () => [], } as never); server.decorate('agentRunner', ({ signal }: { signal?: AbortSignal }) => new Promise((resolve) => { signal?.addEventListener('abort', () => resolve({ content: 'cancelled', toolsUsed: [], usage: { inputTokens: 0, outputTokens: 0 } }), { once: true }); })); server.decorate('fleetResultRecorder', async ({ run }) => ({ status: 'complete', personalFrameIds: [], workspaceFrameIds: { [run.workspaceId]: [] } })); await server.register(fleetRoutes); const first = await server.inject({ method: 'POST', url: '/api/fleet/spawn', payload: { task: 'One' } }); expect(first.statusCode).toBe(202); await waitFor(() => registry.get((first.json() as { runId: string }).runId)?.status === 'running', 'first did not start'); const second = await server.inject({ method: 'POST', url: '/api/fleet/spawn', payload: { task: 'Two' } }); expect(second.statusCode).toBe(409); expect(second.json().error).toBe('fleet_capacity_reached'); await registry.control((first.json() as { runId: string }).runId, 'cancel'); await server.close(); }); });