SPB Git

Repository Public

kernel-adapter.test.ts 7.7 KB 121 lines typescript

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
Files main
Menu

Theme