Add Beryl Agent and harness source
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, readFile, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ApprovalBroker,
|
||||
MemoryEventStore,
|
||||
RunIdleWatchdog,
|
||||
SafeWorkspace,
|
||||
adsMetrics,
|
||||
boundSwadsArguments,
|
||||
compactSwadsText,
|
||||
createTools,
|
||||
validateBaseUrl,
|
||||
} from "../src/index.js";
|
||||
let roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots)
|
||||
await import("node:fs/promises").then((f) =>
|
||||
f.rm(root, { recursive: true, force: true }),
|
||||
);
|
||||
roots = [];
|
||||
});
|
||||
async function ws() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "asm-"));
|
||||
roots.push(root);
|
||||
const sessions = path.join(root, "sessions");
|
||||
await mkdir(sessions);
|
||||
return SafeWorkspace.create(sessions, "s1");
|
||||
}
|
||||
describe("harness core", () => {
|
||||
it("assigns monotonic event sequence", () => {
|
||||
const s = new MemoryEventStore();
|
||||
expect(s.append("x", { type: "run.started", payload: {} }).sequence).toBe(
|
||||
1,
|
||||
);
|
||||
expect(s.append("x", { type: "run.completed", payload: {} }).sequence).toBe(
|
||||
2,
|
||||
);
|
||||
});
|
||||
it("approves and is idempotent", async () => {
|
||||
const b = new ApprovalBroker(1000);
|
||||
const p = b.request(
|
||||
{
|
||||
sessionId: "s",
|
||||
runId: "r",
|
||||
toolCallId: "t",
|
||||
toolName: "save_report_draft",
|
||||
arguments: {},
|
||||
riskLevel: "medium",
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
const id = b.get(
|
||||
[...(b as unknown as { all: Map<string, unknown> }).all.keys()][0]!,
|
||||
)!.approvalId;
|
||||
b.resolve(id, "approved");
|
||||
expect((await p).decision).toBe("approved");
|
||||
expect(b.resolve(id, "approved").status).toBe("approved");
|
||||
});
|
||||
it("times out approval", async () => {
|
||||
vi.useFakeTimers();
|
||||
const b = new ApprovalBroker(10);
|
||||
const p = b.request(
|
||||
{
|
||||
sessionId: "s",
|
||||
runId: "r",
|
||||
toolCallId: "t",
|
||||
toolName: "save_report_draft",
|
||||
arguments: {},
|
||||
riskLevel: "medium",
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(11);
|
||||
expect((await p).decision).toBe("expired");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it("uses an activity-based watchdog and pauses while waiting", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onTimeout = vi.fn();
|
||||
const watchdog = new RunIdleWatchdog(100, onTimeout);
|
||||
watchdog.touch();
|
||||
await vi.advanceTimersByTimeAsync(75);
|
||||
watchdog.touch();
|
||||
await vi.advanceTimersByTimeAsync(75);
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
watchdog.pause();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
watchdog.touch();
|
||||
await vi.advanceTimersByTimeAsync(101);
|
||||
expect(onTimeout).toHaveBeenCalledTimes(1);
|
||||
watchdog.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it("writes only report.md", async () => {
|
||||
const w = await ws();
|
||||
await w.writeReport("hello", "Demo");
|
||||
expect(await readFile(path.join(w.root, "report.md"), "utf8")).toContain(
|
||||
"hello",
|
||||
);
|
||||
expect(() => w.resolve("../escape")).toThrow("PATH_ESCAPE");
|
||||
});
|
||||
it("renders daily and weekly reports as PNG with a three-stage funnel", async () => {
|
||||
const w = await ws();
|
||||
const fixture = path.resolve("../../fixtures/ads-account.json");
|
||||
const imageTool = createTools(fixture, w).find(
|
||||
(tool) => tool.name === "save_report_image",
|
||||
)!;
|
||||
const result = await imageTool.execute(
|
||||
"tool-1",
|
||||
{
|
||||
reportType: "weekly",
|
||||
accountName: "Lemonz Home",
|
||||
period: "2026-08-31 – 2026-09-06",
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
currency: "MYR",
|
||||
freshness: "2026-09-07 10:39",
|
||||
outcome: "成交增长,素材效率出现分化。",
|
||||
kpis: [
|
||||
{ label: "花费", value: "RM 100" },
|
||||
{ label: "成交", value: "RM 700" },
|
||||
{ label: "订单", value: "40" },
|
||||
{ label: "ROI", value: "7.00" },
|
||||
],
|
||||
funnel: [
|
||||
{ label: "商品曝光", value: "10,000" },
|
||||
{ label: "商品点击", value: "500", rate: "曝光→点击 5.00%" },
|
||||
{ label: "订单", value: "40", rate: "点击→购买 8.00%" },
|
||||
],
|
||||
funnelCaption: "同一商品指标族与归因窗口。",
|
||||
highlights: ["高效商品贡献主要成交。"],
|
||||
recommendations: ["复核低效素材。", "验证高效方向。", "继续观察低量样本。"],
|
||||
limitations: ["平台归因不是因果增量。"],
|
||||
},
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
{} as never,
|
||||
);
|
||||
const png = await readFile(path.join(w.root, "swads-weekly-report.png"));
|
||||
expect(png.subarray(1, 4).toString()).toBe("PNG");
|
||||
expect((result.details as { funnelIncluded: boolean }).funnelIncluded).toBe(true);
|
||||
});
|
||||
it("rejects symlink report", async () => {
|
||||
const w = await ws();
|
||||
await symlink("/tmp/outside", path.join(w.root, "report.md"));
|
||||
await expect(w.writeReport("x")).rejects.toThrow("SYMLINK_DENIED");
|
||||
});
|
||||
it("computes safe metrics", async () => {
|
||||
const fixture = path.resolve("../../fixtures/ads-account.json");
|
||||
const x = await adsMetrics(fixture, "demo-account", 7);
|
||||
expect(x.roas).toBe(3);
|
||||
expect(x.days).toBe(7);
|
||||
});
|
||||
it("rejects private model URLs", async () =>
|
||||
await expect(validateBaseUrl("https://127.0.0.1/v1")).rejects.toThrow(
|
||||
"BASE_URL_PRIVATE_DENIED",
|
||||
));
|
||||
it("bounds SW Ads semantic queries", () => {
|
||||
const result = boundSwadsArguments("metrics_semantic_query", {
|
||||
query: { limit: 1000, metrics: ["spend"] },
|
||||
});
|
||||
expect((result.query as { limit: number }).limit).toBe(50);
|
||||
});
|
||||
it("compacts large SW Ads row results", () => {
|
||||
const result = JSON.parse(
|
||||
compactSwadsText(
|
||||
JSON.stringify({ rows: Array.from({ length: 80 }, (_, i) => [i]) }),
|
||||
),
|
||||
) as { rows: unknown[]; response_compaction: { original_rows: number } };
|
||||
expect(result.rows).toHaveLength(50);
|
||||
expect(result.response_compaction.original_rows).toBe(80);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user