chore: initial commit

Laimiao agent v2 service: agent orchestration, sessions, billing,
gateway client, skills catalog, auth, config, plus tests.
This commit is contained in:
light committed 2026-11-07 10:40:07 +08:00
commit 6ec9778d45
53 files changed
+12340

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import { AuthError, resolveIdentity, verifyJwt } from "../src/auth/jwt.js";
function sign(payload: Record<string, unknown>, secret = "secret", alg = "HS256"): string {
const header = Buffer.from(JSON.stringify({ alg, typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url");
return `${header}.${body}.${signature}`;
}
describe("auth", () => {
it("verifies a valid HS256 token and resolves identity", () => {
const token = sign({ uid: 42, nickname: "小明", exp: Math.floor(Date.now() / 1000) + 60 });
const identity = resolveIdentity(
{ jwtKey: "secret", jwtAlgorithm: "HS256" },
{ headers: { authorization: `Bearer ${token}`, "x-team-id": "9" } },
);
expect(identity).toMatchObject({ uid: "42", nickname: "小明", team_id: "9" });
});
it("rejects missing, expired, tampered, and wrong-algorithm tokens", () => {
const auth = { jwtKey: "secret", jwtAlgorithm: "HS256" };
expect(() => resolveIdentity(auth, { headers: {} })).toThrow(AuthError);
const expired = sign({ uid: 1, exp: Math.floor(Date.now() / 1000) - 1 });
expect(() => verifyJwt(expired, "secret", "HS256")).toThrow(/过期/);
const valid = sign({ uid: 1, exp: Math.floor(Date.now() / 1000) + 60 });
const tampered = `${valid.split(".").slice(0, 2).join(".")}.bad`;
expect(() => verifyJwt(tampered, "secret", "HS256")).toThrow(/签名/);
const wrongAlg = sign({ uid: 1, exp: Math.floor(Date.now() / 1000) + 60 }, "secret", "HS512");
expect(() => verifyJwt(wrongAlg, "secret", "HS256")).toThrow(/算法/);
});
it("uses x-uid headers in development mode", () => {
const identity = resolveIdentity(
{ jwtAlgorithm: "HS256" },
{ headers: { "x-uid": "7", "x-team-id": "8", "x-nickname": "dev" } },
);
expect(identity).toEqual({ uid: "7", team_id: "8", nickname: "dev" });
});
});
+203
View File
@@ -0,0 +1,203 @@
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createAgentTools } from "../src/agent/tools.js";
import { UsageLedger } from "../src/billing/ledger.js";
import { GatewayClient } from "../src/gateway/client.js";
import type { Quote } from "../src/types.js";
import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js";
interface BatchDetails {
ok: boolean;
quote?: Quote;
images?: string[];
error?: string;
idempotent?: boolean;
}
interface TestTool {
name: string;
execute: (
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<{ content: Array<{ type: string; text: string }>; details: BatchDetails }>;
}
async function setup(options: { content?: string } = {}) {
const root = tempRoot();
const config = testConfig(root);
const catalog = await makeCatalog([
skillFixture({ key: "suite", config: { pricePerImage: 20 }, content: options.content }),
]);
const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger);
const gateway = new GatewayClient(config.gateway, silentLogger);
const captured: string[] = [];
const tools = createAgentTools({
config,
catalog,
ledger,
gateway,
logger: silentLogger,
identity: { uid: "1", team_id: "1" },
sessionId: "session-1",
allowedSkillKeys: ["suite"],
onImages: (urls) => { captured.push(...urls); },
});
const find = (name: string): TestTool => {
const tool = tools.find((item) => item.name === name);
if (!tool) throw new Error(`missing tool ${name}`);
return tool as unknown as TestTool;
};
return { find, ledger, gateway, captured };
}
const style = "统一风格:轻奢商业摄影,暖白背景,柔和侧光,低饱和暖棕色调,衬线标题字,大量留白";
const images = [
{ content: "主图,产品正面全身展示" },
{ content: "主图,佩戴场景特写" },
{ content: "详情图,材质细节" },
{ content: "详情图,规格参数" },
];
describe("generate_images_batch", () => {
it("submits every image as its own request but all concurrently", async () => {
const { find, gateway, ledger, captured } = await setup();
const prompts: string[] = [];
let call = 0;
const spy = vi.spyOn(gateway, "generateImage").mockImplementation(async (input) => {
const index = call++;
prompts.push(input.prompt);
expect(input.n).toBe(1);
await new Promise((resolve) => setTimeout(resolve, 120));
return {
taskId: `task-${index}`,
urls: [`https://img/${index}.png`],
status: "success",
raw: {},
elapsedMs: 120,
};
});
const t0 = Date.now();
const result = await find("generate_images_batch").execute("batch-1", {
skill_id: "suite",
style,
images,
});
const elapsed = Date.now() - t0;
expect(spy).toHaveBeenCalledTimes(4);
expect(prompts.every((prompt) => prompt.startsWith(style))).toBe(true);
expect(prompts[0]).toContain("图1:主图,产品正面全身展示");
expect(prompts[3]).toContain("图4:详情图,规格参数");
expect(result.details.ok).toBe(true);
expect(result.details.images).toHaveLength(4);
expect(captured).toHaveLength(4);
expect(result.details.quote?.total_credits).toBe(80);
expect(ledger.list()).toHaveLength(4);
expect(ledger.list().every((record) => record.status === "succeeded")).toBe(true);
// 4 张并发:总耗时应接近单张耗时,而不是 4 倍
expect(elapsed).toBeLessThan(320);
});
it("supports a different size per image", async () => {
const { find, gateway } = await setup();
const sizes: string[] = [];
vi.spyOn(gateway, "generateImage").mockImplementation(async (input) => {
sizes.push(input.size);
return {
taskId: `task-${sizes.length}`,
urls: [`https://img/${sizes.length}.png`],
status: "success",
raw: {},
elapsedMs: 5,
};
});
const result = await find("generate_images_batch").execute("batch-size", {
skill_id: "suite",
style,
images: [
{ content: "主图01", size: "800x800" },
{ content: "详情图01", size: "790x1200" },
],
});
expect(result.details.ok).toBe(true);
expect(sizes).toEqual(["800x800", "790x1200"]);
});
it("keeps successful images when one item fails", async () => {
const { find, gateway, ledger } = await setup();
let call = 0;
vi.spyOn(gateway, "generateImage").mockImplementation(async () => {
const index = call++;
if (index === 1) throw new Error("gateway timeout");
await new Promise((resolve) => setTimeout(resolve, 20));
return {
taskId: `task-${index}`,
urls: [`https://img/${index}.png`],
status: "success",
raw: {},
elapsedMs: 20,
};
});
const result = await find("generate_images_batch").execute("batch-2", {
skill_id: "suite",
style,
images,
});
expect(result.details.ok).toBe(true);
expect(result.details.images).toHaveLength(3);
expect(result.content[0]?.text).toContain("失败 1 张");
expect(ledger.list().filter((record) => record.status === "failed")).toHaveLength(1);
expect(ledger.list().filter((record) => record.status === "succeeded")).toHaveLength(3);
});
it("replays a batch without re-generating or re-billing", async () => {
const { find, gateway } = await setup();
let call = 0;
const spy = vi.spyOn(gateway, "generateImage").mockImplementation(async () => {
const index = call++;
return {
taskId: `task-${index}`,
urls: [`https://img/${index}.png`],
status: "success",
raw: {},
elapsedMs: 5,
};
});
await find("generate_images_batch").execute("batch-3", { skill_id: "suite", style, images });
expect(spy).toHaveBeenCalledTimes(4);
const replay = await find("generate_images_batch").execute("batch-3", { skill_id: "suite", style, images });
expect(spy).toHaveBeenCalledTimes(4);
expect(replay.details.images).toHaveLength(4);
});
it("requires one overall style for a multi-image set", async () => {
const { find, gateway } = await setup();
const spy = vi.spyOn(gateway, "generateImage");
const result = await find("generate_images_batch").execute("batch-no-style", {
skill_id: "suite",
images,
});
expect(result.details.ok).toBe(false);
expect(result.content[0]?.text).toContain("总体风格");
expect(spy).not.toHaveBeenCalled();
});
it("rejects batches larger than the skill max_images", async () => {
const { find, gateway } = await setup({ content: "---\nmax_images: 2\n---\n# suite\n" });
const spy = vi.spyOn(gateway, "generateImage");
const result = await find("generate_images_batch").execute("batch-4", {
skill_id: "suite",
style,
images,
});
expect(result.details.ok).toBe(false);
expect(result.content[0]?.text).toContain("超过技能");
expect(spy).not.toHaveBeenCalled();
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { SkillCatalog } from "../src/skills/catalog.js";
import { FakeSkillRepository, makeCatalog, silentLogger, skillFixture } from "./helpers.js";
describe("SkillCatalog (database)", () => {
it("loads valid skills and exposes pricing metadata", async () => {
const catalog = await makeCatalog([
skillFixture({
key: "poster",
config: { pricePerImage: 30 },
content: "---\ndefault_size: 1024x1536\ndefault_n: 2\nwatermark: false\n---\n# Skill",
}),
skillFixture({ key: "space", config: { pricePerImage: 50 } }),
]);
expect(catalog.diagnostics()).toEqual([]);
expect(catalog.list().map((skill) => skill.key)).toEqual(["poster", "space"]);
expect(catalog.get("poster")?.displayName).toBe("Test poster");
expect(catalog.toPublic(catalog.get("poster")!)).toMatchObject({
key: "poster",
display_name: "Test poster",
price: { per_image: 30 },
defaults: { size: "1024x1536", n: 2, watermark: false },
});
expect(catalog.promptCatalog()).toContain("skill_id: poster");
expect(catalog.promptCatalog()).toContain("单价: 30 credits/张");
});
it("parses size, quantity, watermark and tags from skill body", async () => {
const catalog = await makeCatalog([
skillFixture({
key: "body-params",
content: "---\ndefault_size: 1536x864\ndefault_n: 1\nmax_images: 3\ntags: [旅游, 海报]\n---\n# Body",
}),
skillFixture({
key: "inline-params",
content: "# 技能\n默认尺寸:1024x1820\n生成数量:2\n水印:否\n标签:电商、海报",
}),
]);
const body = catalog.get("body-params");
expect(body?.manifest.defaults).toMatchObject({ size: "1536x864", n: 1 });
expect(body?.manifest.max_images).toBe(3);
expect(body?.manifest.tags).toEqual(["旅游", "海报"]);
const inline = catalog.get("inline-params");
expect(inline?.manifest.defaults).toMatchObject({ size: "1024x1820", n: 2, watermark: false });
expect(inline?.manifest.tags).toEqual(["电商", "海报"]);
});
it("falls back to defaults when agent_skill_config is absent", async () => {
const catalog = await makeCatalog([skillFixture({ key: "no-config", config: null })], { defaultPrice: 17 });
const skill = catalog.get("no-config");
expect(skill?.usesDefaults).toBe(true);
expect(skill?.manifest.price.per_image).toBe(17);
expect(skill?.manifest.model).toBe("gpt-image-2");
expect(catalog.diagnostics().join("\n")).toContain("no agent_skill_config");
});
it("rejects disabled, missing content, and invalid keys", async () => {
const catalog = await makeCatalog([
skillFixture({ key: "good" }),
skillFixture({ key: "no-description", description: "" }),
skillFixture({ key: "disabled", config: { enabled: false } }),
skillFixture({ key: "library-disabled", libraryEnabled: false }),
skillFixture({ key: "empty", content: "" }),
skillFixture({ key: "bad_name" }),
]);
expect(catalog.list().map((skill) => skill.key)).toEqual(["good", "no-description"]);
expect(catalog.get("disabled")).toBeUndefined();
expect(catalog.get("library-disabled")).toBeUndefined();
const diagnostics = catalog.diagnostics().join("\n");
expect(diagnostics).toContain("content is empty");
expect(diagnostics).toContain("description is empty");
expect(diagnostics).toContain("lowercase kebab-case");
});
it("rejects invalid database config without crashing", async () => {
const catalog = await makeCatalog([
skillFixture({ key: "bad-price", config: { pricePerImage: -1 } }),
]);
expect(catalog.list()).toHaveLength(0);
expect(catalog.diagnostics().join("\n")).toContain("bad-price");
});
it("refreshes the in-memory catalog from the repository", async () => {
const repository = new FakeSkillRepository([skillFixture({ key: "first" })]);
const catalog = new SkillCatalog({
repository,
defaultPrice: 10,
defaultModel: "seedream@5.0",
defaultSize: "1024x1024",
currency: "credits",
logger: silentLogger,
});
await catalog.load();
expect(catalog.list().map((skill) => skill.key)).toEqual(["first"]);
repository.setSkills([skillFixture({ key: "second", config: { pricePerImage: 20 } })]);
await catalog.refresh();
expect(catalog.list().map((skill) => skill.key)).toEqual(["second"]);
expect(catalog.get("first")).toBeUndefined();
});
it("requires at least one enabled skill", async () => {
const catalog = await makeCatalog([]);
expect(() => catalog.requireReady()).toThrow(/no enabled skills/);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { join } from "node:path";
import { loadConfig } from "../src/config.js";
import { tempRoot } from "./helpers.js";
describe("loadConfig", () => {
it("applies safe defaults", () => {
const root = tempRoot();
const config = loadConfig({ APP_ROOT: root });
expect(config.port).toBe(8100);
expect(config.billing.mode).toBe("live");
expect(config.billing.currency).toBe("credits");
expect(config.gateway.mock).toBe(false);
expect(config.gateway.pollTimeoutMs).toBe(900_000);
expect(config.llm.modelId).toBe("deepseek-chat");
expect(config.dataDir).toBe(join(root, "data"));
expect(config.mysql.host).toBe("127.0.0.1");
expect(config.mysql.database).toBe("laimiaodb");
expect(config.skillDefaults.model).toBe("gpt-image-2");
expect(config.toolConfirmation.enabled).toBe(true);
expect(config.toolConfirmation.tools).toEqual(["generate_image"]);
expect(config.toolConfirmation.timeoutMs).toBe(300_000);
expect(config.modelsFile).toBe(join(root, "data", "pi", "models.json"));
});
it("parses booleans and integers from env strings", () => {
const config = loadConfig({
APP_ROOT: tempRoot(),
PORT: "9001",
GATEWAY_MOCK: "TRUE",
BILLING_MODE: "off",
LAIMIAO_LLM_REASONING: "1",
GATEWAY_POLL_INTERVAL_MS: "250",
TOOL_CONFIRMATION_TOOLS: "generate_image, quote_image",
});
expect(config.port).toBe(9001);
expect(config.gateway.mock).toBe(true);
expect(config.billing.mode).toBe("off");
expect(config.llm.reasoning).toBe(true);
expect(config.gateway.pollIntervalMs).toBe(250);
expect(config.toolConfirmation.tools).toEqual(["generate_image", "quote_image"]);
});
it("treats an empty integer as the default", () => {
const config = loadConfig({ APP_ROOT: tempRoot(), PORT: "" });
expect(config.port).toBe(8100);
});
it("rejects invalid port and billing mode", () => {
expect(() => loadConfig({ APP_ROOT: tempRoot(), PORT: "0" })).toThrow(/PORT/);
expect(() => loadConfig({ APP_ROOT: tempRoot(), BILLING_MODE: "always" })).toThrow(/BILLING_MODE/);
});
it("rejects malformed gateway and LLM URLs", () => {
expect(() => loadConfig({ APP_ROOT: tempRoot(), GATEWAY_CN_URL: "not-a-url" })).toThrow(/GATEWAY_CN_URL/);
expect(() => loadConfig({ APP_ROOT: tempRoot(), LAIMIAO_LLM_BASE_URL: "ftp://x" })).toThrow(
/LAIMIAO_LLM_BASE_URL/,
);
});
it("enforces production security requirements", () => {
const base = {
APP_ROOT: tempRoot(),
NODE_ENV: "production",
JWT_KEY: "secret",
CORS_ORIGIN: "https://app.example.com",
FILE_FETCH_ALLOWED_HOSTS: "cdn.example.com,*.oss.example.com",
};
expect(() => loadConfig({ ...base, JWT_KEY: "" })).toThrow(/JWT_KEY/);
expect(() => loadConfig({ ...base, CORS_ORIGIN: "*" })).toThrow(/CORS_ORIGIN/);
expect(() => loadConfig({ ...base, CORS_ORIGIN: "https://app.example.com/path" })).toThrow(/CORS_ORIGIN/);
expect(() => loadConfig({ ...base, FILE_FETCH_ALLOWED_HOSTS: "" })).toThrow(/FILE_FETCH_ALLOWED_HOSTS/);
const config = loadConfig(base);
expect(config.isProduction).toBe(true);
expect(config.fileFetchAllowedHosts).toEqual(["cdn.example.com", "*.oss.example.com"]);
});
it("keeps permissive defaults outside production", () => {
const config = loadConfig({ APP_ROOT: tempRoot() });
expect(config.nodeEnv).toBe("development");
expect(config.isProduction).toBe(false);
expect(config.fileFetchAllowedHosts).toEqual([]);
});
it("resolves relative paths against APP_ROOT", () => {
const root = tempRoot();
const config = loadConfig({ APP_ROOT: root, DATA_DIR: "var/state" });
expect(config.dataDir).toBe(join(root, "var/state"));
expect(config.ledgerFile).toBe(join(root, "var/state/usage-ledger.jsonl"));
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { createConfirmTools } from "../src/agent/confirm_tools.js";
import { ToolConfirmationManager } from "../src/agent/confirmations.js";
import type { AgentStreamEvent } from "../src/agent/service.js";
import { makeCatalog, silentLogger } from "./helpers.js";
interface TestTool {
name: string;
execute: (
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<{
content: Array<{ type: string; text: string }>;
details: Record<string, unknown>;
isError?: boolean;
}>;
}
async function setup() {
const catalog = await makeCatalog([]);
const manager = new ToolConfirmationManager(1_000);
const events: AgentStreamEvent[] = [];
const tools = createConfirmTools({
catalog,
confirmations: manager,
sessionId: "s1",
logger: silentLogger,
emit: (event) => events.push(event),
hasStream: true,
}) as unknown as TestTool[];
return { manager, events, tools };
}
function resultText(result: { content: Array<{ type: string; text: string }> }): unknown {
return JSON.parse(result.content[0]?.text ?? "{}");
}
describe("confirm tools", () => {
it("select mode emits options and returns the selected id", async () => {
const { manager, events, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user");
expect(tool).toBeDefined();
const promise = tool!.execute("call-1", {
message: "请选择风格",
mode: "select",
options: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
],
});
expect(events[0]).toMatchObject({
type: "content_confirmation_required",
confirm_type: "user_confirm",
payload: { message: "请选择风格", mode: "select" },
});
expect(manager.resolve("s1", "call-1", { approved: true, content: "b" })).toBe(true);
expect(resultText(await promise)).toEqual({ approved: true, content: "b" });
expect(events[1]).toMatchObject({
type: "content_confirmation_resolved",
confirm_type: "user_confirm",
approved: true,
content: "b",
});
});
it("treats provided options as select mode", async () => {
const { manager, events, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user")!;
const promise = tool.execute("call-options", {
message: "请选择风格",
mode: "confirm",
options: ["a", "b"],
});
expect(events[0]).toMatchObject({
type: "content_confirmation_required",
confirm_type: "user_confirm",
payload: { mode: "select" },
});
manager.resolve("s1", "call-options", { approved: true, content: "a" });
expect(resultText(await promise)).toEqual({ approved: true, content: "a" });
});
it("edit mode returns edited content", async () => {
const { manager, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user")!;
const promise = tool.execute("call-edit", {
message: "请补充信息",
mode: "edit",
default_value: "默认内容",
});
manager.resolve("s1", "call-edit", { approved: true, content: "用户修改后的内容" });
expect(resultText(await promise)).toEqual({ approved: true, content: "用户修改后的内容" });
});
it("confirm mode clears content", async () => {
const { manager, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user")!;
const promise = tool.execute("call-confirm", { message: "确认继续?", mode: "confirm" });
manager.resolve("s1", "call-confirm", { approved: true, content: "忽略内容" });
expect(resultText(await promise)).toEqual({ approved: true, content: "" });
});
it("rejects an option that was not offered", async () => {
const { manager, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user")!;
const promise = tool.execute("call-invalid", {
message: "请选择",
mode: "select",
options: ["a", "b"],
});
manager.resolve("s1", "call-invalid", { approved: true, content: "c" });
expect(resultText(await promise)).toEqual({ approved: false, content: "" });
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { ToolConfirmationManager, type ToolConfirmationRequest } from "../src/agent/confirmations.js";
function request(manager: ToolConfirmationManager, toolCallId = "call-1", signal?: AbortSignal) {
let emitted: ToolConfirmationRequest | undefined;
const promise = manager.request(
{
sessionId: "s1",
toolCallId,
toolName: "generate_image",
args: { prompt: "poster" },
},
(value) => {
emitted = value;
},
signal,
);
return { promise, get emitted() { return emitted; } };
}
describe("ToolConfirmationManager", () => {
it("waits for approval and resolves the tool call", async () => {
const manager = new ToolConfirmationManager(1_000);
const pending = request(manager);
expect(pending.emitted?.toolCallId).toBe("call-1");
expect(pending.emitted?.expiresAt).toBeGreaterThan(Date.now());
expect(manager.resolve("s1", "call-1", true)).toBe(true);
await expect(pending.promise).resolves.toEqual({ approved: true, reason: undefined });
expect(manager.count()).toBe(0);
});
it("carries confirm type/payload and resolves edited content", async () => {
const manager = new ToolConfirmationManager(1_000);
let emitted: ToolConfirmationRequest | undefined;
const promise = manager.request(
{
sessionId: "s1",
toolCallId: "call-content",
toolName: "confirm_travel_content",
confirmType: "travel_content_confirm",
payload: { summary: "线路:测试" },
args: {},
},
(value) => {
emitted = value;
},
);
expect(emitted?.confirmType).toBe("travel_content_confirm");
expect(emitted?.payload).toEqual({ summary: "线路:测试" });
expect(manager.resolve("s1", "call-content", { approved: true, content: "用户修改后的内容" })).toBe(true);
await expect(promise).resolves.toEqual({ approved: true, content: "用户修改后的内容" });
});
it("returns a rejection reason", async () => {
const manager = new ToolConfirmationManager(1_000);
const pending = request(manager);
manager.resolve("s1", "call-1", false, "不要生成");
await expect(pending.promise).resolves.toEqual({ approved: false, reason: "不要生成" });
});
it("times out and does not leave stale entries", async () => {
const manager = new ToolConfirmationManager(10);
const pending = request(manager);
await expect(pending.promise).resolves.toMatchObject({ approved: false, reason: "等待用户确认超时" });
expect(manager.count()).toBe(0);
});
it("cancels when the run is aborted", async () => {
const manager = new ToolConfirmationManager(1_000);
const controller = new AbortController();
const pending = request(manager, "call-abort", controller.signal);
controller.abort();
await expect(pending.promise).resolves.toEqual({ approved: false, reason: "请求已中止" });
});
it("clears all pending confirmations for a session", async () => {
const manager = new ToolConfirmationManager(1_000);
const first = request(manager, "call-1");
const second = request(manager, "call-2");
manager.clearSession("s1");
await expect(first.promise).resolves.toMatchObject({ approved: false, reason: "会话已结束" });
await expect(second.promise).resolves.toMatchObject({ approved: false, reason: "会话已结束" });
});
it("rejects duplicate pending confirmations for the same tool call", async () => {
const manager = new ToolConfirmationManager(1_000);
const first = request(manager);
const duplicate = request(manager);
await expect(duplicate.promise).resolves.toMatchObject({ approved: false });
manager.resolve("s1", "call-1", true);
await expect(first.promise).resolves.toEqual({ approved: true, reason: undefined });
});
});
+218
View File
@@ -0,0 +1,218 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { GatewayClient, GatewayError, extractImageUrls, normalizeSize } from "../src/gateway/client.js";
import { silentLogger, tempRoot, testConfig } from "./helpers.js";
afterEach(() => {
vi.unstubAllGlobals();
});
function client(overrides: Record<string, string> = {}) {
const config = testConfig(tempRoot(), overrides);
return new GatewayClient(config.gateway, silentLogger);
}
describe("GatewayClient", () => {
it("routes known CN model families to CN and unknown models to global", () => {
const gateway = client({ GATEWAY_MOCK: "false", GATEWAY_CN_URL: "https://cn.test/", GATEWAY_GLOBAL_URL: "https://global.test/" });
expect(gateway.baseUrlFor("seedream@5.0")).toBe("https://cn.test");
expect(gateway.baseUrlFor("kling-v2")).toBe("https://cn.test");
expect(gateway.baseUrlFor("gpt-image-2")).toBe("https://global.test");
expect(gateway.baseUrlFor("unknown-model")).toBe("https://global.test");
});
it("honours GATEWAY_FORCE_URL", () => {
const gateway = client({ GATEWAY_FORCE_URL: "https://forced.test/", GATEWAY_MOCK: "false" });
expect(gateway.baseUrlFor("seedream@5.0")).toBe("https://forced.test");
expect(gateway.baseUrlFor("gpt-image-2")).toBe("https://forced.test");
});
it("normalizes gpt-image sizes to multiples of 16", () => {
expect(normalizeSize("gpt-image-2", "1024x1536")).toBe("1024x1536");
expect(normalizeSize("gpt-image-2", "1000x1501")).toBe("1008x1504");
expect(normalizeSize("seedream@5.0", "1000x1501")).toBe("1000x1501");
});
it("extracts URLs from common gateway response shapes", () => {
expect(
extractImageUrls({
data: [{ url: "https://a/1.png" }, { image_url: "https://a/2.png" }],
output: { images: ["https://a/3.png"] },
drawing: "https://a/4.png",
media_list: [{ oss_url: "https://a/5.png" }],
}),
).toEqual(["https://a/1.png", "https://a/2.png", "https://a/3.png", "https://a/4.png", "https://a/5.png"]);
});
it("generates mock URLs for each requested image", async () => {
const gateway = client();
const result = await gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 2,
});
expect(result.taskId).toMatch(/^mock-/);
expect(result.urls).toHaveLength(2);
expect(result.urls[0]).toContain("picsum.photos");
});
it("submits a task and polls until images are available", async () => {
const gateway = client({
GATEWAY_MOCK: "false",
GATEWAY_CN_URL: "https://cn.test",
GATEWAY_POLL_INTERVAL_MS: "10",
});
let call = 0;
const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
call += 1;
if (call === 1) {
return new Response(JSON.stringify({ ret: 0, task_id: "task-1" }), { status: 200 });
}
return new Response(
JSON.stringify({ status: "running", data: call === 2 ? [] : [{ url: "https://img/1.png" }] }),
{ status: 200 },
);
});
vi.stubGlobal("fetch", fetchMock);
const result = await gateway.generateImage({
uid: "1",
team_id: "2",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1536",
n: 1,
watermark: true,
});
expect(result.taskId).toBe("task-1");
expect(result.urls).toEqual(["https://img/1.png"]);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("https://cn.test/generate/v1/images");
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record<string, unknown>;
expect(body).toMatchObject({ uid: 1, team_id: 2, model: "seedream@5.0", n: 1, watermark: true });
expect(String(fetchMock.mock.calls[1]?.[0])).toBe("https://cn.test/generate/v1/images/task-1");
});
it("retries transient poll network failures", async () => {
const gateway = client({
GATEWAY_MOCK: "false",
GATEWAY_CN_URL: "https://cn.test",
GATEWAY_POLL_INTERVAL_MS: "10",
});
let call = 0;
const fetchMock = vi.fn(async (): Promise<Response> => {
call += 1;
if (call === 1) {
return new Response(JSON.stringify({ ret: 0, task_id: "task-retry" }), { status: 200 });
}
if (call === 2) {
throw new TypeError("fetch failed");
}
return new Response(JSON.stringify({ status: "success", data: [{ url: "https://img/retry.png" }] }), {
status: 200,
});
});
vi.stubGlobal("fetch", fetchMock);
const result = await gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 1,
});
expect(result.urls).toEqual(["https://img/retry.png"]);
expect(call).toBe(3);
});
it("waits briefly when the gateway reports success before urls are attached", async () => {
const gateway = client({
GATEWAY_MOCK: "false",
GATEWAY_CN_URL: "https://cn.test",
GATEWAY_POLL_INTERVAL_MS: "10",
});
let call = 0;
const fetchMock = vi.fn(async (): Promise<Response> => {
call += 1;
if (call === 1) {
return new Response(JSON.stringify({ ret: 0, task_id: "task-finalize" }), { status: 200 });
}
if (call === 2) {
return new Response(JSON.stringify({ status: "success", data: [] }), { status: 200 });
}
return new Response(JSON.stringify({ status: "success", data: [{ url: "https://img/final.png" }] }), {
status: 200,
});
});
vi.stubGlobal("fetch", fetchMock);
const result = await gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 1,
});
expect(result.urls).toEqual(["https://img/final.png"]);
expect(call).toBe(3);
});
it("returns inline urls without polling", async () => {
const gateway = client({ GATEWAY_MOCK: "false" });
const fetchMock = vi.fn(
async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> =>
new Response(JSON.stringify({ ret: 0, task_id: "t", images: ["https://img/1.png"] }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);
const result = await gateway.generateImage({
uid: "1",
team_id: "1",
model: "gpt-image-2",
prompt: "a cat",
size: "1000x1501",
n: 2,
});
expect(result.urls).toEqual(["https://img/1.png"]);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record<string, unknown>;
expect(body.size).toBe("1008x1504");
});
it("fails fast on a non-zero gateway ret", async () => {
const gateway = client({ GATEWAY_MOCK: "false" });
vi.stubGlobal(
"fetch",
vi.fn(async (): Promise<Response> => new Response(JSON.stringify({ ret: 1, msg: "no quota" }), { status: 200 })),
);
await expect(
gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 1,
}),
).rejects.toThrow(/no quota/);
});
it("maps HTTP failures to GatewayError", async () => {
const gateway = client({ GATEWAY_MOCK: "false" });
vi.stubGlobal("fetch", vi.fn(async (): Promise<Response> => new Response("down", { status: 503 })));
await expect(
gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 1,
}),
).rejects.toBeInstanceOf(GatewayError);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadConfig, type AppConfig } from "../src/config.js";
import type { Logger } from "../src/logger.js";
import { SkillCatalog } from "../src/skills/catalog.js";
import type { SkillRepository, StoredSkill, StoredSkillConfig } from "../src/skills/repository.js";
export const silentLogger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
export function tempRoot(prefix = "laimiao-v2-"): string {
return mkdtempSync(join(tmpdir(), prefix));
}
export class FakeSkillRepository implements SkillRepository {
constructor(private skills: StoredSkill[]) {}
async ensureSchema(): Promise<void> {}
async listSkills(): Promise<StoredSkill[]> {
return this.skills;
}
setSkills(skills: StoredSkill[]): void {
this.skills = skills;
}
async close(): Promise<void> {}
}
export interface SkillFixture {
key: string;
name?: string;
description?: string;
content?: string;
triggerKeywords?: string;
libraryEnabled?: boolean;
config?: Partial<StoredSkillConfig> | null;
}
export function skillFixture(fixture: SkillFixture): StoredSkill {
const config: StoredSkillConfig | undefined =
fixture.config === null
? undefined
: {
pricePerImage: 10,
enabled: true,
...fixture.config,
};
return {
key: fixture.key,
name: fixture.name ?? `Test ${fixture.key}`,
industry: "test",
description: fixture.description ?? `Test skill ${fixture.key}`,
triggerKeywords: fixture.triggerKeywords ?? "",
content: fixture.content ?? `# ${fixture.key}\n\nUse this skill for tests.\n`,
libraryEnabled: fixture.libraryEnabled ?? true,
config,
};
}
export async function makeCatalog(
skills: StoredSkill[],
options: { defaultPrice?: number; defaultModel?: string; defaultSize?: string } = {},
): Promise<SkillCatalog> {
return new SkillCatalog({
repository: new FakeSkillRepository(skills),
defaultPrice: options.defaultPrice ?? 10,
defaultModel: options.defaultModel ?? "gpt-image-2",
defaultSize: options.defaultSize ?? "1024x1024",
currency: "credits",
logger: silentLogger,
}).load();
}
export function testConfig(root: string, overrides: Record<string, string> = {}): AppConfig {
return loadConfig({
APP_ROOT: root,
DATA_DIR: "data",
LOG_LEVEL: "error",
GATEWAY_MOCK: "true",
GATEWAY_POLL_INTERVAL_MS: "10",
BILLING_MODE: "dry_run",
...overrides,
});
}
+89
View File
@@ -0,0 +1,89 @@
import { appendFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { UsageLedger, type BeginUsage } from "../src/billing/ledger.js";
import { silentLogger, tempRoot } from "./helpers.js";
function usage(overrides: Partial<BeginUsage> = {}): BeginUsage {
return {
uid: "1",
team_id: "1",
session_id: "s1",
tool_call_id: "call-1",
skill_id: "demo",
skill_name: "Demo",
model: "seedream@5.0",
size: "1024x1536",
quantity: 2,
unit_credits: 30,
size_multiplier: 1,
total_credits: 60,
currency: "credits",
billing_mode: "dry_run",
...overrides,
};
}
describe("UsageLedger", () => {
it("records pending and succeeded usage and finds it by tool call", () => {
const ledger = new UsageLedger(join(tempRoot(), "ledger.jsonl"), silentLogger);
const record = ledger.begin(usage());
expect(record.status).toBe("pending");
ledger.settle(record.id, "succeeded", {
gateway_task_id: "task-1",
image_urls: ["https://img/1.png", "https://img/2.png"],
});
const found = ledger.findSucceededToolCall("1", "s1", "call-1");
expect(found?.id).toBe(record.id);
expect(found?.status).toBe("succeeded");
expect(found?.image_urls).toHaveLength(2);
expect(ledger.findSucceededToolCall("2", "s1", "call-1")).toBeUndefined();
});
it("persists records and rebuilds the idempotency index", () => {
const file = join(tempRoot(), "ledger.jsonl");
const ledger = new UsageLedger(file, silentLogger);
const record = ledger.begin(usage());
ledger.settle(record.id, "succeeded", { image_urls: ["https://img/1.png"] });
const reloaded = new UsageLedger(file, silentLogger);
expect(reloaded.list()).toHaveLength(1);
expect(reloaded.findSucceededToolCall("1", "s1", "call-1")?.id).toBe(record.id);
});
it("does not index failed calls and summarises by status and skill", () => {
const ledger = new UsageLedger(join(tempRoot(), "ledger.jsonl"), silentLogger);
const failed = ledger.begin(usage({ tool_call_id: "call-failed" }));
ledger.settle(failed.id, "failed", { error: "nope" });
const ok = ledger.begin(usage({ tool_call_id: "call-ok", skill_id: "poster", total_credits: 30, quantity: 1 }));
ledger.settle(ok.id, "succeeded", { image_urls: ["https://img/p.png"] });
const pending = ledger.begin(usage({ tool_call_id: "call-pending", skill_id: "poster", total_credits: 30 }));
expect(ledger.findSucceededToolCall("1", "s1", "call-failed")).toBeUndefined();
const summary = ledger.summary({ uid: "1" });
expect(summary.totals).toEqual({
records: 3,
images: 1,
credits_succeeded: 30,
credits_pending: 30,
credits_failed: 60,
});
expect(summary.by_skill).toEqual([
{ skill_id: "poster", skill_name: "Demo", records: 2, images: 1, credits: 30 },
{ skill_id: "demo", skill_name: "Demo", records: 1, images: 0, credits: 0 },
]);
expect(ledger.list({ status: "pending" }).map((item) => item.id)).toEqual([pending.id]);
expect(ledger.list({ limit: 1 })).toHaveLength(1);
});
it("skips malformed lines instead of losing the ledger", () => {
const file = join(tempRoot(), "ledger.jsonl");
const ledger = new UsageLedger(file, silentLogger);
ledger.begin(usage());
appendFileSync(file, "{bad json\n", "utf8");
const reloaded = new UsageLedger(file, silentLogger);
expect(reloaded.list()).toHaveLength(1);
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import {
assertSafeUrl,
hostMatches,
isBlockedAddress,
isBlockedHostname,
parseHostAllowlist,
safeFetch,
} from "../src/utils/net.js";
describe("parseHostAllowlist", () => {
it("normalizes comma separated hosts", () => {
expect(parseHostAllowlist(" CDN.Example.com , *.OSS.example.com.,, ")).toEqual([
"cdn.example.com",
"*.oss.example.com",
]);
});
it("returns an empty list for empty input", () => {
expect(parseHostAllowlist("")).toEqual([]);
expect(parseHostAllowlist(undefined)).toEqual([]);
});
});
describe("hostMatches", () => {
it("allows every host when the allowlist is empty", () => {
expect(hostMatches("anything.example.com", [])).toBe(true);
});
it("matches exact hosts case-insensitively", () => {
expect(hostMatches("CDN.example.com", ["cdn.example.com"])).toBe(true);
expect(hostMatches("other.example.com", ["cdn.example.com"])).toBe(false);
});
it("matches wildcard subdomains and the apex", () => {
expect(hostMatches("a.oss.example.com", ["*.oss.example.com"])).toBe(true);
expect(hostMatches("oss.example.com", ["*.oss.example.com"])).toBe(true);
expect(hostMatches("evil-oss.example.com", ["*.oss.example.com"])).toBe(false);
});
});
describe("isBlockedAddress", () => {
it.each([
"0.0.0.0",
"10.1.2.3",
"100.64.0.1",
"127.0.0.1",
"169.254.169.254",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"198.18.0.1",
"224.0.0.1",
"255.255.255.255",
])("blocks private or reserved IPv4 %s", (address) => {
expect(isBlockedAddress(address)).toBe(true);
});
it.each(["8.8.8.8", "1.1.1.1", "172.32.0.1", "93.184.216.34"])("allows public IPv4 %s", (address) => {
expect(isBlockedAddress(address)).toBe(false);
});
it.each(["::", "::1", "fe80::1", "fd00::1", "fc00::1", "ff02::1", "2001:db8::1"])(
"blocks private or reserved IPv6 %s",
(address) => {
expect(isBlockedAddress(address)).toBe(true);
},
);
it("blocks IPv4 mapped into IPv6", () => {
expect(isBlockedAddress("::ffff:127.0.0.1")).toBe(true);
expect(isBlockedAddress("::ffff:169.254.169.254")).toBe(true);
expect(isBlockedAddress("::ffff:8.8.8.8")).toBe(false);
});
it("blocks 6to4 addresses that embed a private IPv4", () => {
expect(isBlockedAddress("2002:7f00:1::")).toBe(true);
expect(isBlockedAddress("2002:0808:0808::")).toBe(false);
});
});
describe("isBlockedHostname", () => {
it.each([
"localhost",
"api.localhost",
"metadata.google.internal",
"db.internal",
"redis",
"127.0.0.1",
"169.254.169.254",
"[::1]",
])("blocks %s", (host) => {
expect(isBlockedHostname(host)).toBe(true);
});
it.each(["example.com", "cdn.example.com", "8.8.8.8"])("allows %s", (host) => {
expect(isBlockedHostname(host)).toBe(false);
});
});
describe("assertSafeUrl", () => {
it("rejects non-http protocols", async () => {
await expect(assertSafeUrl("ftp://example.com/a")).rejects.toThrow(/http/);
await expect(assertSafeUrl("file:///etc/passwd")).rejects.toThrow(/http/);
});
it("rejects URLs with credentials", async () => {
await expect(assertSafeUrl("https://user:pass@example.com/a")).rejects.toThrow(/账号|密码/);
});
it("rejects loopback and cloud metadata endpoints", async () => {
await expect(assertSafeUrl("http://127.0.0.1:3306")).rejects.toThrow(/禁止访问/);
await expect(assertSafeUrl("http://169.254.169.254/latest/meta-data")).rejects.toThrow(/禁止访问/);
await expect(assertSafeUrl("http://[::1]/")).rejects.toThrow(/禁止访问/);
});
it("rejects hosts outside the allowlist", async () => {
await expect(assertSafeUrl("https://evil.example.com/a", { allowedHosts: ["cdn.example.com"] })).rejects.toThrow(
/白名单/,
);
await expect(
assertSafeUrl("https://93.184.216.34/a", { allowedHosts: ["93.184.216.34"] }),
).resolves.toBeInstanceOf(URL);
});
it("accepts public IP literals", async () => {
await expect(assertSafeUrl("https://93.184.216.34/a")).resolves.toBeInstanceOf(URL);
});
});
describe("safeFetch", () => {
it("refuses to connect to a blocked host before any request is made", async () => {
await expect(safeFetch("http://127.0.0.1:9/")).rejects.toThrow(/禁止访问/);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { PricingError, quoteSkill, repriceForQuantity } from "../src/billing/pricing.js";
import type { SkillDefinition } from "../src/types.js";
const skill: SkillDefinition = {
key: "demo",
name: "demo",
displayName: "Demo Skill",
description: "demo",
content: "# demo",
triggerKeywords: "demo",
usesDefaults: false,
manifest: {
key: "demo",
version: "1.0.0",
industry: "test",
model: "seedream@5.0",
defaults: { size: "1024x1536", n: 1 },
price: { currency: "credits", per_image: 30, size_multipliers: { "2048x3072": 2 } },
capabilities: [],
tags: [],
max_images: 4,
enabled: true,
},
};
describe("pricing", () => {
it("quotes one image using defaults", () => {
expect(quoteSkill(skill, { size: "1024x1536", quantity: 1 }, {}, "dry_run")).toEqual({
skill_id: "demo",
skill_name: "Demo Skill",
model: "seedream@5.0",
size: "1024x1536",
quantity: 1,
unit_credits: 30,
size_multiplier: 1,
total_credits: 30,
currency: "credits",
billing_mode: "dry_run",
});
});
it("applies size multipliers and quantity", () => {
const quote = quoteSkill(
skill,
{ size: "1024x1536", quantity: 1 },
{ size: "2048x3072", quantity: 3 },
"off",
);
expect(quote.size_multiplier).toBe(2);
expect(quote.total_credits).toBe(180);
expect(quote.billing_mode).toBe("off");
});
it("rejects over-limit, non-integer, and malformed sizes", () => {
expect(() => quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { quantity: 5 }, "dry_run")).toThrow(
PricingError,
);
expect(() => quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { quantity: 1.5 }, "dry_run")).toThrow(
/integer/,
);
expect(() => quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { size: "large" }, "dry_run")).toThrow(
/size/,
);
});
it("reprices to the actually returned image count", () => {
const quote = quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { quantity: 3 }, "dry_run");
const repriced = repriceForQuantity(quote, 1);
expect(repriced.quantity).toBe(1);
expect(repriced.total_credits).toBe(30);
expect(() => repriceForQuantity(quote, -1)).toThrow(PricingError);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { buildSystemPrompts } from "../src/agent/prompt.js";
import { makeCatalog, skillFixture, tempRoot, testConfig } from "./helpers.js";
describe("system prompts", () => {
it("uses direct mode when no skill is selected", async () => {
const catalog = await makeCatalog([
skillFixture({ key: "poster", content: "# Poster\n\nUse poster rules." }),
]);
const prompts = buildSystemPrompts(catalog, testConfig(tempRoot()));
const text = [prompts.systemPrompt, ...prompts.appendSystemPrompt].join("\n");
expect(text).toContain("直接生成模式");
expect(text).toContain("普通直接生成不要读取技能规范");
expect(text).toContain("省略 skill_id");
expect(text).toContain("confirm_travel_style");
expect(text).not.toContain("Use poster rules");
expect(text).not.toContain("skill_id: poster");
});
it("injects only the selected skill spec", async () => {
const catalog = await makeCatalog([
skillFixture({ key: "poster", content: "# Poster\n\nUse poster rules." }),
skillFixture({ key: "other", content: "# Other\n\nUse other rules." }),
]);
const skill = catalog.get("poster");
if (!skill) throw new Error("missing fixture");
const prompts = buildSystemPrompts(catalog, testConfig(tempRoot()), skill);
const text = [prompts.systemPrompt, ...prompts.appendSystemPrompt].join("\n");
expect(text).toContain("skill_id: poster");
expect(text).toContain("Test skill poster");
expect(text).toContain("Use poster rules");
expect(text).not.toContain("Use other rules");
expect(text).not.toContain("直接生成模式");
});
});
+216
View File
@@ -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 });
});
});
+61
View File
@@ -0,0 +1,61 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { SessionStore } from "../src/sessions/store.js";
import { silentLogger, tempRoot } from "./helpers.js";
function makeStore(root: string) {
return new SessionStore(join(root, "sessions.json"), join(root, "messages"), silentLogger);
}
describe("SessionStore", () => {
it("creates, lists, and owns sessions", async () => {
const store = makeStore(tempRoot());
const session = await store.create({ uid: "1", team_id: "t1" }, { title: "hello" });
expect((await store.get(session.session_id))?.title).toBe("hello");
expect((await store.getOwned({ uid: "1", team_id: "t1" }, session.session_id))?.session_id).toBe(
session.session_id,
);
expect(await store.getOwned({ uid: "2", team_id: "t1" }, session.session_id)).toBeUndefined();
expect((await store.list({ uid: "1" })).total).toBe(1);
expect((await store.list({ uid: "2" })).total).toBe(0);
});
it("appends messages, updates metadata, and auto-titles from the first user message", async () => {
const store = makeStore(tempRoot());
const session = await store.create({ uid: "1", team_id: "t1" });
await store.append(session.session_id, {
role: "user",
mode: "agent",
content: "帮我做一张云南海报",
media: [],
status: "ok",
});
await store.append(session.session_id, {
role: "assistant",
mode: "image",
content: "已完成",
media: [{ type: "image", url: "https://img/1.png" }],
status: "ok",
});
const meta = await store.get(session.session_id);
expect(meta?.message_count).toBe(2);
expect(meta?.last_mode).toBe("image");
expect(meta?.title).toBe("帮我做一张云南海报");
const messages = await store.messages(session.session_id);
expect(messages).toHaveLength(2);
expect(messages[1]?.media[0]?.url).toBe("https://img/1.png");
});
it("persists the index and paginates", async () => {
const root = tempRoot();
const store = makeStore(root);
for (let index = 0; index < 3; index += 1) {
await store.create({ uid: "1", team_id: "t1" }, { title: `s${index}` });
}
const reloaded = makeStore(root);
expect((await reloaded.list({ uid: "1" })).total).toBe(3);
expect((await reloaded.list({ uid: "1", limit: 2 })).sessions).toHaveLength(2);
expect((await reloaded.list({ uid: "1", limit: 2, offset: 2 })).sessions).toHaveLength(1);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { sanitizeTitle, stripUploadedDocuments } from "../src/utils/text.js";
describe("stripUploadedDocuments", () => {
it("removes the document instruction block appended by the frontend", () => {
const message = "根据上传文档生成旅游详情长图\n\n【用户上传了以下文档(请先用 read_document_text 逐个读取原文;如果是旅游行程文档,可再用 read_document 提取线路摘要):\n行程.pdf:https://oss/x.pdf】";
expect(stripUploadedDocuments(message)).toBe("根据上传文档生成旅游详情长图");
});
it("removes older block variants and the text after them", () => {
const message = "【用户上传了以下行程文档(请先用 read_document 逐个读取,再依据文档内容处理):\nhttps://oss/x.docx】\n请阅读文档并整理线路摘要。";
expect(stripUploadedDocuments(message)).toBe("请阅读文档并整理线路摘要。");
});
it("keeps plain messages untouched", () => {
expect(stripUploadedDocuments("生成三张产品图")).toBe("生成三张产品图");
});
});
describe("sanitizeTitle", () => {
it("cleans and truncates dirty titles", () => {
expect(sanitizeTitle("生成一张旅游海报\n\n【用户上传了以下文档(请先用 read_document_text 逐个读取原文")).toBe("生成一张旅游海报");
expect(sanitizeTitle("【用户上传了以下文档(请先用 read_document_text 逐个")).toBe("新对话");
expect(sanitizeTitle(undefined)).toBe("新对话");
expect(sanitizeTitle("内容", "新对话", 2)).toBe("内容");
});
});
+200
View File
@@ -0,0 +1,200 @@
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createAgentTools } from "../src/agent/tools.js";
import { UsageLedger } from "../src/billing/ledger.js";
import { GatewayClient } from "../src/gateway/client.js";
import { DIRECT_SKILL_KEY, directSkill } from "../src/skills/direct.js";
import type { Quote } from "../src/types.js";
import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js";
interface GenerateDetails {
ok: boolean;
quote?: Quote;
images?: string[];
task_id?: string;
idempotent?: boolean;
error?: string;
}
interface TestTool {
name: string;
execute: (
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<{ content: Array<{ type: string; text: string }>; details: GenerateDetails }>;
}
async function setup(perImage = 20, allowedSkillKeys?: string[], direct = false) {
const root = tempRoot();
const config = testConfig(root);
const catalog = await makeCatalog([
skillFixture({ key: "poster", config: { pricePerImage: perImage } }),
skillFixture({ key: "other", config: { pricePerImage: perImage } }),
]);
const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger);
const gateway = new GatewayClient(config.gateway, silentLogger);
const directModeSkill = direct ? directSkill(config) : undefined;
const tools = createAgentTools({
config,
catalog,
ledger,
gateway,
logger: silentLogger,
identity: { uid: "1", team_id: "1" },
sessionId: "session-1",
allowedSkillKeys: directModeSkill ? [DIRECT_SKILL_KEY] : allowedSkillKeys,
directSkill: directModeSkill,
onImages: () => {},
});
const find = (name: string): TestTool => {
const tool = tools.find((item) => item.name === name);
if (!tool) throw new Error(`missing tool ${name}`);
return tool as unknown as TestTool;
};
return { root, config, catalog, ledger, gateway, tools, find };
}
describe("agent tools", () => {
it("lists and reads skills", async () => {
const { find } = await setup();
const list = await find("list_skills").execute("call-list", {});
expect(list.content[0]?.text).toContain("poster");
const read = await find("read_skill").execute("call-read", { skill_id: "poster" });
expect(read.content[0]?.text).toContain("Use this skill for tests");
expect(read.details.ok).toBe(true);
});
it("no longer exposes the quote_image tool", async () => {
const { tools } = await setup();
expect(tools.some((tool) => tool.name === "quote_image")).toBe(false);
});
it("generates images and records the actual returned count", async () => {
const { find, ledger, gateway } = await setup();
const spy = vi
.spyOn(gateway, "generateImage")
.mockResolvedValueOnce({
taskId: "task-1",
urls: ["https://img/1.png", "https://img/2.png"],
status: "success",
raw: {},
elapsedMs: 12,
})
.mockResolvedValueOnce({
taskId: "task-2",
urls: ["https://img/3.png"],
status: "success",
raw: {},
elapsedMs: 8,
});
const first = await find("generate_image").execute("call-1", {
skill_id: "poster",
prompt: "a travel poster",
n: 2,
});
expect(first.details.ok).toBe(true);
expect(first.details.images).toHaveLength(2);
expect(first.details.quote?.total_credits).toBe(40);
expect(first.details.quote?.billing_mode).toBe("dry_run");
expect(ledger.list()).toHaveLength(1);
expect(ledger.list()[0]?.status).toBe("succeeded");
expect(ledger.list()[0]?.tool_call_id).toBe("call-1");
const replay = await find("generate_image").execute("call-1", {
skill_id: "poster",
prompt: "a travel poster",
n: 2,
});
expect(replay.details.idempotent).toBe(true);
expect(spy).toHaveBeenCalledTimes(1);
expect(ledger.list()).toHaveLength(1);
const fewer = await find("generate_image").execute("call-2", {
skill_id: "poster",
prompt: "a travel poster",
n: 2,
});
expect(fewer.details.quote?.quantity).toBe(1);
expect(fewer.details.quote?.total_credits).toBe(20);
expect(ledger.list()).toHaveLength(2);
});
it("records a failed generation without charging it", async () => {
const { find, ledger, gateway } = await setup();
vi.spyOn(gateway, "generateImage").mockRejectedValue(new Error("gateway exploded"));
const result = await find("generate_image").execute("call-fail", {
skill_id: "poster",
prompt: "a travel poster",
n: 1,
});
expect(result.details.ok).toBe(false);
expect(result.content[0]?.text).toContain("gateway exploded");
expect(ledger.list()).toHaveLength(1);
expect(ledger.list()[0]?.status).toBe("failed");
});
it("limits tools to the routed skill when allowedSkillKeys is set", async () => {
const { find } = await setup(20, ["poster"]);
const list = await find("list_skills").execute("call-list", {});
expect(list.content[0]?.text).toContain("poster");
expect(list.content[0]?.text).not.toContain("other");
const blocked = await find("generate_image").execute("call-blocked", {
skill_id: "other",
prompt: "a travel poster",
});
expect(blocked.details.ok).toBe(false);
expect(blocked.details.error).toBe("unknown_skill");
});
it("generates directly without a skill_id in direct mode", async () => {
const { find, ledger, gateway } = await setup(20, undefined, true);
const spy = vi.spyOn(gateway, "generateImage").mockResolvedValue({
taskId: "task-direct",
urls: ["https://img/direct.png"],
status: "success",
raw: {},
elapsedMs: 3,
});
const result = await find("generate_image").execute("call-direct", { prompt: "a clean product photo" });
expect(result.details.ok).toBe(true);
expect(result.details.images).toEqual(["https://img/direct.png"]);
expect(spy).toHaveBeenCalledOnce();
expect(ledger.list()[0]?.skill_id).toBe(DIRECT_SKILL_KEY);
// 直接生成按默认单价(10)的 1.3 倍计费:ceil(10 * 1.3) = 13
expect(ledger.list()[0]?.total_credits).toBe(13);
const blocked = await find("generate_image").execute("call-direct-blocked", {
skill_id: "poster",
prompt: "a poster",
});
expect(blocked.details.ok).toBe(false);
expect(blocked.details.error).toBe("unknown_skill");
});
it("rejects unknown skills and invalid quantities before calling the gateway", async () => {
const { find, ledger, gateway } = await setup();
const spy = vi.spyOn(gateway, "generateImage");
const unknown = await find("generate_image").execute("call-unknown", {
skill_id: "nope",
prompt: "a travel poster",
});
expect(unknown.details.ok).toBe(false);
expect(unknown.details.error).toBe("unknown_skill");
const invalid = await find("generate_image").execute("call-invalid", {
skill_id: "poster",
prompt: "a travel poster",
n: 99,
});
expect(invalid.details.ok).toBe(false);
expect(spy).not.toHaveBeenCalled();
expect(ledger.list()).toHaveLength(0);
});
});