Repository Public
1/**2 * The adapters against the REAL `@aos/kernel` and `@aos/providers` (vitest resolves both to their3 * sources, see vitest.config.ts): migrate, create mission, live events, steering, wait resolution,4 * artifact download, cancel. No model is called.5 */6import fs from 'node:fs';7import os from 'node:os';8import path from 'node:path';9import { afterAll, beforeAll, describe, expect, it } from 'vitest';10import type WebSocket from 'ws';11import { createKernel, putArtifact } from '@aos/kernel';12import { AosEvent, MissionDto, ModelInfo, WaitDto } from '@aos/shared';13import { buildServer, type Server } from './app.js';14import { loadConfig } from './config.js';15import { kernelMigrate, loadKernel, type LoadedKernel } from './kernel-adapter.js';16import { loadProviders } from './providers-adapter.js';17import { Client, FAKE_ANTHROPIC_KEY, ORIGIN, createTestDb, dropTestDb, openWs, setupAdmin, until, upgradeStatus, type Harness } from './testkit/harness.js';1819describe('adapters against the real kernel and providers packages', () => {20 let dbName = '';21 let dataDir = '';22 let workspace = '';23 let kernel: LoadedKernel;24 let server: Server;25 let admin: Client;26 let addr = '';2728 beforeAll(async () => {29 const db = createTestDb();30 dbName = db.dbName;31 dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-kdata-'));32 workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'aos-kws-')));33 await kernelMigrate(db.databaseUrl);34 await kernelMigrate(db.databaseUrl); // idempotent35 kernel = loadKernel({ databaseUrl: db.databaseUrl, dataDir });36 const real = loadProviders();37 // The real catalogue, but no network: the live listing is replaced by a failure (⇒ catalogue fallback).38 const providers = { catalogue: () => real.catalogue(), listModels: () => Promise.reject(new Error('offline in this test')) };39 const config = loadConfig({40 NODE_ENV: 'test', AOS_PUBLIC_ORIGIN: ORIGIN, AOS_DATA_DIR: dataDir, AOS_WORKSPACE_ROOT: workspace, DATABASE_URL: db.databaseUrl,41 AOS_SECRET_KEY: 'ab'.repeat(32), AOS_LOG_LEVEL: 'silent', AOS_WEB_DIR: path.join(dataDir, 'no-web'), ANTHROPIC_API_KEY: FAKE_ANTHROPIC_KEY,42 });43 config.passwordCost = { argonMemoryKiB: 1024, argonPasses: 2, scryptLogN: 10 };44 server = await buildServer(config, { kernel: kernel.port, providers });45 await server.startBackground({ timers: false });46 const h = { server, config, dataDir, clock: { offsetMs: 0 }, setupToken: () => fs.readFileSync(path.join(dataDir, 'setup-token'), 'utf8') } as unknown as Harness;47 ({ client: admin } = await setupAdmin(h));48 await server.app.listen({ port: 0, host: '127.0.0.1' });49 addr = `127.0.0.1:${(server.app.server.address() as { port: number }).port}`;50 });51 afterAll(async () => {52 try {53 await server?.close();54 await kernel?.close();55 } finally {56 if (dbName) dropTestDb(dbName);57 for (const d of [dataDir, workspace]) if (d) fs.rmSync(d, { recursive: true, force: true });58 }59 });6061 it('providers: the real static catalogue is served, unavailable, when no live listing can be made', async () => {62 const models = (await admin.get('/models')).json<ModelInfo[]>();63 expect(models.every((m) => ModelInfo.safeParse(m).success)).toBe(true);64 expect(models.filter((m) => m.provider === 'openai').length).toBeGreaterThan(0);65 expect(models.every((m) => m.available === false)).toBe(true); // openai: no key in this config; anthropic: listing failed66 expect(models.map((m) => m.id)).toEqual(expect.arrayContaining(['claude-opus-5-5', 'claude-haiku-5-5', 'gpt-6.1-sol', 'gpt-6-luna']));67 });6869 it('create → events over the WebSocket → steering → wait resolve → artifact → cancel, all through the real kernel', async () => {70 const ws: WebSocket = openWs(addr, '/events?cursor=0', { cookie: admin.cookieHeader });71 const events: AosEvent[] = [];72 ws.on('message', (d) => events.push(AosEvent.parse(JSON.parse(String(d)))));73 expect(await upgradeStatus(ws)).toBe(101);7475 const p = (await admin.post('/projects', { name: 'Real', mode: 'create' })).json<{ id: string }>();76 // Creation only needs the catalogue + a configured key.77 const created = await admin.post('/missions', { projectId: p.id, objective: 'Integration objective', doneCriteria: [], budgetUsd: 1 });78 expect(created.statusCode, created.body).toBe(201);79 const m = MissionDto.parse(created.json());80 expect(m).toMatchObject({ status: 'queued', doneCriteria: [], runs: 1, budgetUsd: 1, title: 'Integration objective' });81 const runId = m.rootRunId as string;82 const run = await server.ctx.db.one<{ contract: { objective: string; doneCriteria: string[] }; workspace_abs: string; budget: { usd: number }; provider: string; model: string }>(83 'SELECT contract, workspace_abs, budget, provider, model FROM runs WHERE id = $1', [runId]);84 expect(run).toMatchObject({ contract: { objective: 'Integration objective', doneCriteria: [] }, workspace_abs: path.join(workspace, 'real'), budget: { usd: 1 }, provider: 'anthropic', model: 'claude-opus-5-5' });85 await until(() => events.filter((e) => e.missionId === m.id).length >= 2, 8000);86 expect(events.filter((e) => e.missionId === m.id).map((e) => e.kind)).toEqual(['mission.created', 'run.created']);8788 expect((await admin.post(`/runs/${runId}/message`, { text: 'steer' })).statusCode).toBe(200);89 await until(() => events.some((e) => e.kind === 'owner.message'), 8000);90 expect(events.find((e) => e.kind === 'owner.message')).toMatchObject({ runId, trust: 'owner', payload: { text: 'steer' } });9192 // A wait as the kernel's ctx.wait() leaves it (run waiting + pending row), resolved through the API.93 await server.ctx.db.q("UPDATE runs SET status = 'waiting' WHERE id = $1", [runId]);94 const w = await server.ctx.db.one<{ id: string }>(95 `INSERT INTO waits (run_id, step_seq, mission_id, kind, payload) VALUES ($1, 0, $2, 'question', '{"text":"?"}') RETURNING id`, [runId, m.id]);96 const resolved = await admin.post(`/waits/${w?.id}/resolve`, { kind: 'question', answer: 'yes' });97 expect(resolved.statusCode, resolved.body).toBe(200);98 expect(WaitDto.parse(resolved.json())).toMatchObject({ status: 'resolved', resolution: { answer: 'yes' } });99 expect((await server.ctx.db.one<{ status: string }>('SELECT status FROM runs WHERE id = $1', [runId]))?.status).toBe('queued');100 const again = await admin.post(`/waits/${w?.id}/resolve`, { kind: 'question', answer: 'no' });101 expect([again.statusCode, again.json().error.code]).toEqual([409, 'wait_not_pending']);102 await until(() => events.some((e) => e.kind === 'wait.resolved'), 8000);103104 // Artifact stored by the kernel's putArtifact, downloaded through the port's openArtifact.105 const put = await putArtifact('artifact-bytes', { name: 'out.txt', mime: 'text/plain', runId, stepSeq: 0 }, createKernel({ pool: server.ctx.db.pool, dataDir }));106 const dl = await admin.get(`/artifacts/${put.sha256}`);107 expect([dl.statusCode, dl.body, dl.headers['content-disposition']]).toEqual([200, 'artifact-bytes', `attachment; filename="out.txt"; filename*=UTF-8''out.txt`]);108109 const cancelled = await admin.post(`/missions/${m.id}/cancel`);110 expect(cancelled.statusCode, cancelled.body).toBe(200);111 expect(cancelled.json().status).toBe('cancelled');112 await until(() => events.some((e) => e.kind === 'mission.status' && e.payload.status === 'cancelled'), 8000);113 const late = await admin.post(`/runs/${runId}/message`, { text: 'late' });114 expect([late.statusCode, late.json().error.code]).toEqual([409, 'run_ended']);115 // ids strictly increasing, nothing twice116 const ids = events.filter((e) => e.id >= 0).map((e) => e.id);117 expect(ids).toEqual([...new Set(ids)].sort((a, b) => a - b));118 ws.terminate();119 });120});121