chore: initial commit
Laimiao agent v2 service: agent orchestration, sessions, billing, gateway client, skills catalog, auth, config, plus tests.
This commit is contained in:
commit
6ec9778d45
53 files changed
+12340
No files matched your search
@@ -0,0 +1,216 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user