Enhance Beryl Agent detail and report views
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { it, expect } from "vitest";
|
||||
import { parseReviewResult } from "../src/content-review.js";
|
||||
it("fails closed for malformed or contradictory review output", () => {
|
||||
for (const output of ["完成", "{}", '{"passed":"true","reasons":[]}', '{"passed":true,"reasons":["第2页仍是糖果"]}', '{"passed":false,"reasons":[]}'])
|
||||
expect(parseReviewResult(output).passed).toBe(false);
|
||||
});
|
||||
it("accepts only an explicit positive review without failures", () => {
|
||||
expect(parseReviewResult('{"passed":true,"reasons":[]}')).toEqual({ passed: true, reasons: [] });
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { enforceDelivery, reportContract, validateReportDelivery } from "../src/delivery.js";
|
||||
import { SafeWorkspace } from "../src/workspace.js";
|
||||
import { MemoryEventStore } from "../src/store.js";
|
||||
import { SessionRegistry } from "../src/session.js";
|
||||
|
||||
it("detects report requests but leaves normal chat alone", () => {
|
||||
expect(reportContract("dapure上周周报")).toBe("weekly");
|
||||
expect(reportContract("生成日报")).toBe("daily");
|
||||
expect(reportContract("你好")).toBeUndefined();
|
||||
expect(reportContract("生成周报PPT")).toBe("pptx");
|
||||
expect(reportContract("生成俄罗斯ozon彩妆竞品分析报告")).toBe("markdown");
|
||||
});
|
||||
it("inherits delivery on follow-ups without forcing files for status questions", () => {
|
||||
expect(reportContract("改用 SW Ads 模板", "pptx")).toBe("pptx");
|
||||
expect(reportContract("改用 SW Ads 模板")).toBe("pptx");
|
||||
expect(reportContract("重试", "pptx")).toBe("pptx");
|
||||
expect(reportContract("继续", "markdown")).toBe("markdown");
|
||||
expect(reportContract("改为 Markdown", "pptx")).toBe("markdown");
|
||||
expect(reportContract("卡在哪了", "pptx")).toBeUndefined();
|
||||
expect(reportContract("为什么 PPT 没生成", "pptx")).toBeUndefined();
|
||||
expect(reportContract("不要生成 PPT,只解释", "pptx")).toBeUndefined();
|
||||
expect(reportContract("你好", "pptx")).toBeUndefined();
|
||||
});
|
||||
it("keeps PPT delivery across a status turn and rejects template inspection alone", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "delivery-followup-test-"));
|
||||
try {
|
||||
const registry = new SessionRegistry(root);
|
||||
const session = registry.require((await registry.create()).sessionId);
|
||||
vi.spyOn(registry.models, "get").mockReturnValue({ providerId: "test", api: "anthropic-messages", modelId: "test", baseUrl: "https://example.com", supportsImages: false, supportsVideo: false });
|
||||
const prompt = vi.fn(async () => {
|
||||
session.assistantTextEmitted = true;
|
||||
registry.events.append(session.sessionId, { type: "tool.completed", runId: session.runId, payload: { toolName: "inspect_ppt_template" } });
|
||||
});
|
||||
vi.spyOn(registry, "ensureAgent").mockResolvedValue({ prompt, abort: async () => {}, dispose: () => {}, subscribe: () => () => {} });
|
||||
for (const request of ["生成竞品分析PPT", "卡在哪了", "改用 SW Ads 模板", "重试"]) {
|
||||
prompt.mockClear();
|
||||
const runId = await registry.run(session, request);
|
||||
await vi.waitFor(() => expect(session.busy).toBe(false));
|
||||
const events = registry.events.listAfter(session.sessionId).filter(e => e.runId === runId);
|
||||
const status = request === "卡在哪了";
|
||||
expect(prompt).toHaveBeenCalledTimes(status ? 1 : 3);
|
||||
expect(events.some(e => e.type === "run.completed")).toBe(status);
|
||||
expect(events.some(e => e.type === "run.failed")).toBe(!status);
|
||||
}
|
||||
} finally { await rm(root, { recursive: true, force: true }); vi.restoreAllMocks(); }
|
||||
});
|
||||
it("fails after exactly two recovery attempts", async () => {
|
||||
const prompt = vi.fn(async () => {});
|
||||
await expect(enforceDelivery({ prompt, validate: async () => false, stopped: () => false, retryPrompt: "retry", onRetry: () => {} })).rejects.toThrow("DELIVERY_INCOMPLETE");
|
||||
expect(prompt).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("stops retrying after successful recovery", async () => {
|
||||
const prompt = vi.fn(async () => {});
|
||||
await enforceDelivery({ prompt, validate: async () => prompt.mock.calls.length === 1, stopped: () => false, retryPrompt: "retry", onRetry: () => {} });
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("does not retry cancellation or denied approval", async () => {
|
||||
const prompt = vi.fn(async () => {});
|
||||
await enforceDelivery({ prompt, validate: async () => false, stopped: () => true, retryPrompt: "retry", onRetry: () => {} });
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["生成周报", "生成俄罗斯ozon彩妆竞品分析报告"])("fails without delivery: %s", async (request) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "delivery-run-test-"));
|
||||
try {
|
||||
const registry = new SessionRegistry(root);
|
||||
const session = await registry.create();
|
||||
vi.spyOn(registry.models, "get").mockReturnValue({ providerId: "test", api: "anthropic-messages", modelId: "test", baseUrl: "https://example.com", supportsImages: false, supportsVideo: false });
|
||||
const prompt = vi.fn(async () => {});
|
||||
vi.spyOn(registry, "ensureAgent").mockResolvedValue({ prompt, abort: async () => {}, dispose: () => {}, subscribe: () => () => {} });
|
||||
await registry.run(session, request);
|
||||
await vi.waitFor(() => expect(session.busy).toBe(false));
|
||||
const events = registry.events.listAfter(session.sessionId);
|
||||
expect(prompt).toHaveBeenCalledTimes(3);
|
||||
expect(events.some(e => e.type === "run.completed")).toBe(false);
|
||||
expect(events.find(e => e.type === "run.failed")?.payload.message).toContain("DELIVERY_INCOMPLETE");
|
||||
} finally { await rm(root, { recursive: true, force: true }); vi.restoreAllMocks(); }
|
||||
});
|
||||
it("requires a current-run structured Markdown report", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "markdown-delivery-test-"));
|
||||
try {
|
||||
const workspace = await SafeWorkspace.create(root, "test");
|
||||
const events = new MemoryEventStore();
|
||||
const body = "# 竞品报告\n## 分析结论\n" + "这是测试分析内容。".repeat(30) + "\n## 来源\n用户提供的测试数据\n## 数据限制\n仅用于验收测试。";
|
||||
await workspace.writeReport(body);
|
||||
expect(await validateReportDelivery("markdown", workspace, [], "new")).toBe(false);
|
||||
events.append("test", { type: "tool.completed", runId: "new", payload: { toolName: "save_report_draft" } });
|
||||
expect(await validateReportDelivery("markdown", workspace, events.listAfter("test"), "new")).toBe(true);
|
||||
expect(await validateReportDelivery("markdown", workspace, events.listAfter("test"), "other")).toBe(false);
|
||||
await workspace.writeReport("正在研究,稍后生成报告。");
|
||||
expect(await validateReportDelivery("markdown", workspace, events.listAfter("test"), "new")).toBe(false);
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it("requires a current-run save and a decodable PNG, not an old file", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "delivery-test-"));
|
||||
try {
|
||||
const workspace = await SafeWorkspace.create(root, "test");
|
||||
const events = new MemoryEventStore();
|
||||
await workspace.writeReportImage("weekly", await sharp({ create: { width: 10, height: 10, channels: 3, background: "white" } }).png().toBuffer());
|
||||
expect(await validateReportDelivery("weekly", workspace, [], "new")).toBe(false);
|
||||
events.append("test", { type: "tool.completed", runId: "new", payload: { toolName: "save_report_image", result: { details: { reportType: "weekly" } } } });
|
||||
expect(await validateReportDelivery("weekly", workspace, events.listAfter("test"), "new")).toBe(true);
|
||||
expect(await validateReportDelivery("weekly", workspace, events.listAfter("test"), "other")).toBe(false);
|
||||
await workspace.writeReportImage("weekly", Buffer.from("corrupt"));
|
||||
expect(await validateReportDelivery("weekly", workspace, events.listAfter("test"), "new")).toBe(false);
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import {expect,it} from "vitest";
|
||||
import {monitorView} from "../src/monitor-view.js";
|
||||
it("bounds model context and exposes counts and pagination",()=>{
|
||||
const state={fatigueRows:Array.from({length:60},(_,i)=>({id:i,state:"observe",account:{id:"a"}})),rows:[{id:"campaign"}],error:"stale"};
|
||||
const first=monitorView(state,{kind:"fatigue"});expect(first.rows).toHaveLength(25);expect(first.total).toBe(60);expect(first.nextOffset).toBe(25);expect(first.error).toBe("stale");
|
||||
expect(monitorView(state,{kind:"fatigue",offset:50}).rows).toHaveLength(10);
|
||||
expect(monitorView(state,{kind:"campaign"}).rows).toHaveLength(1);
|
||||
expect(monitorView(state,{accountId:"b"}).total).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { SafeWorkspace } from "../src/workspace.js";
|
||||
import { validatedPpt } from "../src/ppt-tools.js";
|
||||
import { validateReportDelivery } from "../src/delivery.js";
|
||||
import { MemoryEventStore } from "../src/store.js";
|
||||
import { saveTask, updateTask } from "../src/task.js";
|
||||
it("rejects traversal, stale PPTs and files changed after validation", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ppt-gate-test-"));
|
||||
try {
|
||||
const ws = await SafeWorkspace.create(root, "test");
|
||||
await expect(validatedPpt(ws, "../../secret")).rejects.toThrow("PPT_NOT_FOUND");
|
||||
const id = randomUUID();
|
||||
const dir = ws.resolve(`presentations/${id}`);
|
||||
await mkdir(path.join(dir, "output"), { recursive: true });
|
||||
const bytes = Buffer.from("mock validated artifact");
|
||||
await writeFile(path.join(dir, "output/deck.pptx"), bytes);
|
||||
const task = updateTask(undefined, "生成PPT", "new")!;
|
||||
await saveTask(ws, task);
|
||||
const receipt = { validated: true, purpose: "final", deliveryAccepted: true, taskRevision: task.revision, runId: "new", sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
await writeFile(path.join(dir, "result.json"), JSON.stringify({ ...receipt, purpose: "debug", deliveryAccepted: false }));
|
||||
await expect(validatedPpt(ws, id)).rejects.toThrow("PPT_NOT_ACCEPTED");
|
||||
await writeFile(path.join(dir, "result.json"), JSON.stringify(receipt));
|
||||
expect(await validateReportDelivery("pptx", ws, [], "new")).toBe(false);
|
||||
const events = new MemoryEventStore();
|
||||
events.append("s", { type: "tool.completed", runId: "new", payload: { toolName: "generate_pptx", result: { details: { artifactId: id } } } });
|
||||
expect(await validateReportDelivery("pptx", ws, events.listAfter("s"), "new")).toBe(true);
|
||||
await writeFile(path.join(dir, "output/deck.pptx"), "changed");
|
||||
expect(await validateReportDelivery("pptx", ws, events.listAfter("s"), "new")).toBe(false);
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createSkillLoader, SkillCatalog } from "../src/skills.js";
|
||||
let root: string;
|
||||
const previous = process.env.AGENT_SKILLS_DIRS;
|
||||
afterEach(async () => {
|
||||
if (previous === undefined) delete process.env.AGENT_SKILLS_DIRS;
|
||||
else process.env.AGENT_SKILLS_DIRS = previous;
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
it("catalog and execution load the same extra skills and refresh additions", async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), "skill-loader-test-"));
|
||||
const extra = path.join(root, "extra");
|
||||
await mkdir(extra);
|
||||
process.env.AGENT_SKILLS_DIRS = "extra";
|
||||
const catalog = new SkillCatalog(root);
|
||||
const loader = createSkillLoader(root);
|
||||
for (const name of ["first", "second"]) {
|
||||
const dir = path.join(extra, name);
|
||||
await mkdir(dir);
|
||||
await writeFile(path.join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: Test skill\n---\nRead data.`);
|
||||
const items = await catalog.reload();
|
||||
await loader.reload();
|
||||
expect(items.some(s => s.name === name)).toBe(true);
|
||||
expect(loader.getSkills().skills.some(s => s.name === name)).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { it, expect } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { SessionRegistry } from "../src/session.js";
|
||||
import { updateTask, saveTask, loadTask } from "../src/task.js";
|
||||
it("restores delivery requirements across process recreation, not completion", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "task-restore-"));
|
||||
try {
|
||||
const registry = new SessionRegistry(root);
|
||||
const s = await registry.create();
|
||||
const first = updateTask(undefined, "俄罗斯 Ozon 100元彩妆 PPT", "r1")!;
|
||||
const task = updateTask(first, "改用 SW Ads 模板", "r2")!;
|
||||
await saveTask(s.workspace, task);
|
||||
const restored = new SessionRegistry(root);
|
||||
await restored.restore();
|
||||
expect(restored.require(s.sessionId).task?.instructions).toEqual(first.instructions.concat("改用 SW Ads 模板"));
|
||||
expect((await loadTask(s.workspace))?.status).toBe("failed");
|
||||
expect(updateTask(restored.require(s.sessionId).task, "继续", "r3")?.template).toBe("swads");
|
||||
expect(updateTask(task, "卡在哪了", "r4")).toBeUndefined();
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in New Issue
Block a user