Files
light 6ec9778d45 chore: initial commit
Laimiao agent v2 service: agent orchestration, sessions, billing,
gateway client, skills catalog, auth, config, plus tests.
2026-11-07 10:40:07 +08:00

217 lines
8.0 KiB
TypeScript

import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentService, ConfirmToolInput, RunInput, RunResult } from "../src/agent/service.js";
import { UsageLedger } from "../src/billing/ledger.js";
import { buildServer } from "../src/server.js";
import { SessionStore } from "../src/sessions/store.js";
import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js";
const apps: FastifyInstance[] = [];
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
});
async function makeApp(overrides: Record<string, string> = {}) {
const root = tempRoot();
const config = testConfig(root, overrides);
const catalog = await makeCatalog([skillFixture({ key: "demo", config: { pricePerImage: 10 } })]);
const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger);
const store = new SessionStore(join(root, "sessions.json"), join(root, "messages"), silentLogger);
const run = vi.fn(async (_input: RunInput): Promise<RunResult> => {
throw new Error("not used");
});
const confirmTool = vi.fn(async (_input: ConfirmToolInput) => ({
ok: true as const,
tool_call_id: "call-1",
approved: true,
}));
const service = {
isLlmConfigured: () => true,
run,
confirmTool,
} as unknown as AgentService;
const app = await buildServer({ config, logger: silentLogger, catalog, ledger, store, service });
apps.push(app);
return { app, ledger, store, root, run, confirmTool };
}
describe("HTTP API", () => {
it("exposes health, skills, and skill detail", async () => {
const { app } = await makeApp();
const health = await app.inject({ method: "GET", url: "/health" });
expect(health.statusCode).toBe(200);
expect(health.json()).toMatchObject({
ok: true,
billing_mode: "dry_run",
skills: 1,
gateway_mock: true,
image_models: ["gpt-image-2"],
});
const skills = await app.inject({ method: "GET", url: "/skills" });
expect(skills.json()).toMatchObject({ skills: [{ key: "demo", display_name: "Test demo" }] });
const detail = await app.inject({ method: "GET", url: "/skills/demo" });
expect(detail.statusCode).toBe(200);
expect(detail.json()).toMatchObject({ key: "demo", content: expect.stringContaining("Use this skill") });
const missing = await app.inject({ method: "GET", url: "/skills/nope" });
expect(missing.statusCode).toBe(404);
});
it("quotes images and validates input", async () => {
const { app } = await makeApp();
const quote = await app.inject({
method: "POST",
url: "/images/quote",
payload: { skill_id: "demo", n: 2 },
});
expect(quote.statusCode).toBe(200);
expect(quote.json()).toMatchObject({ total_credits: 20, quantity: 2, billing_mode: "dry_run" });
const invalid = await app.inject({ method: "POST", url: "/images/quote", payload: { skill_id: "demo", n: 0 } });
expect(invalid.statusCode).toBe(400);
const missing = await app.inject({ method: "POST", url: "/images/quote", payload: { skill_id: "nope" } });
expect(missing.statusCode).toBe(404);
});
it("passes an explicitly selected skill_id to the agent", async () => {
const { app, store, run } = await makeApp();
const session = await store.create({ uid: "1", team_id: "t1" });
run.mockResolvedValueOnce({
sessionId: session.session_id,
messageId: "msg-1",
answer: "ok",
media: [],
usage: { input: 0, output: 0, total: 0, cost: 0 },
skill_key: "demo",
});
const response = await app.inject({
method: "POST",
url: "/agent/run",
headers: { "x-uid": "1", "x-team-id": "t1" },
payload: { session_id: session.session_id, message: "做一张海报", skill_id: "demo" },
});
expect(response.statusCode).toBe(200);
expect(run.mock.calls[0]?.[0]).toMatchObject({ skillKey: "demo", message: "做一张海报" });
run.mockResolvedValueOnce({
sessionId: session.session_id,
messageId: "msg-2",
answer: "ok",
media: [],
usage: { input: 0, output: 0, total: 0, cost: 0 },
});
await app.inject({
method: "POST",
url: "/agent/run",
headers: { "x-uid": "1", "x-team-id": "t1" },
payload: { session_id: session.session_id, message: "直接画" },
});
expect(run.mock.calls[1]?.[0]).toMatchObject({ skillKey: undefined, message: "直接画" });
});
it("resolves a pending tool confirmation over HTTP", async () => {
const { app, store, confirmTool } = await makeApp();
const session = await store.create({ uid: "1", team_id: "t1" });
const response = await app.inject({
method: "POST",
url: `/sessions/${session.session_id}/tool-confirmations/call-1`,
headers: { "x-uid": "1", "x-team-id": "t1" },
payload: { approved: true },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ ok: true, approved: true, tool_call_id: "call-1" });
expect(confirmTool.mock.calls[0]?.[0]).toMatchObject({
sessionId: session.session_id,
toolCallId: "call-1",
approved: true,
});
});
it("creates and lists sessions scoped to the caller", async () => {
const { app } = await makeApp();
const created = await app.inject({
method: "POST",
url: "/sessions",
headers: { "x-uid": "1", "x-team-id": "t1" },
payload: { title: "first" },
});
expect(created.statusCode).toBe(200);
const session = created.json() as { session_id: string; uid: string };
expect(session.uid).toBe("1");
const list = await app.inject({ method: "GET", url: "/sessions", headers: { "x-uid": "1", "x-team-id": "t1" } });
expect(list.json()).toMatchObject({ total: 1, sessions: [{ session_id: session.session_id }] });
const detail = await app.inject({
method: "GET",
url: `/sessions/${session.session_id}`,
headers: { "x-uid": "1" },
});
expect(detail.statusCode).toBe(200);
const forbidden = await app.inject({
method: "GET",
url: `/sessions/${session.session_id}`,
headers: { "x-uid": "2" },
});
expect(forbidden.statusCode).toBe(403);
});
it("returns 400 for malformed JSON bodies", async () => {
const { app } = await makeApp();
const response = await app.inject({
method: "POST",
url: "/sessions",
headers: { "content-type": "application/json", "x-uid": "1" },
payload: "{bad json",
});
expect(response.statusCode).toBe(400);
});
it("requires a JWT when JWT_KEY is configured", async () => {
const { app } = await makeApp({ JWT_KEY: "secret" });
const response = await app.inject({ method: "POST", url: "/sessions", payload: {} });
expect(response.statusCode).toBe(401);
});
it("summarises usage for the caller", async () => {
const { app, ledger } = await makeApp();
const record = ledger.begin({
uid: "1",
team_id: "t1",
session_id: "s1",
tool_call_id: "call-1",
skill_id: "demo",
skill_name: "Demo",
model: "seedream@5.0",
size: "1024x1536",
quantity: 1,
unit_credits: 10,
size_multiplier: 1,
total_credits: 10,
currency: "credits",
billing_mode: "dry_run",
});
ledger.settle(record.id, "succeeded", { image_urls: ["https://img/1.png"] });
const summary = await app.inject({ method: "GET", url: "/usage/summary", headers: { "x-uid": "1" } });
expect(summary.statusCode).toBe(200);
expect(summary.json()).toMatchObject({
billing_mode: "dry_run",
totals: { records: 1, images: 1, credits_succeeded: 10, credits_pending: 0, credits_failed: 0 },
});
const usage = await app.inject({ method: "GET", url: "/usage", headers: { "x-uid": "1" } });
expect(usage.json()).toMatchObject({ total: 1, records: [{ skill_id: "demo", status: "succeeded" }] });
const billing = await app.inject({ method: "GET", url: "/billing" });
expect(billing.json()).toMatchObject({ mode: "dry_run", deduction_enabled: false });
});
});