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,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" });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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: "" });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(/禁止访问/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("直接生成模式");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("内容");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user