Initial commit
This commit is contained in:
151
tests/agent-view.test.ts
Normal file
151
tests/agent-view.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
|
||||
classifyAgentMessage,
|
||||
|
||||
buildFocus,
|
||||
|
||||
buildSkillCatalog,
|
||||
|
||||
buildToolTrace,
|
||||
|
||||
inferLifecycleStage,
|
||||
|
||||
canEnterPlay,
|
||||
|
||||
} from "../src/server/agent-view.js";
|
||||
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
|
||||
|
||||
|
||||
describe("classifyAgentMessage", () => {
|
||||
|
||||
it("parses orchestrator decisions", () => {
|
||||
|
||||
const m = classifyAgentMessage("[总管] run_worker: 生成规则");
|
||||
|
||||
expect(m.kind).toBe("orchestrator_decision");
|
||||
|
||||
expect(m.body).toBe("生成规则");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("parses agent tool calls", () => {
|
||||
|
||||
const m = classifyAgentMessage("[总管 tool] invoke_worker: write-rules");
|
||||
|
||||
expect(m.kind).toBe("agent_tool");
|
||||
|
||||
expect(m.title).toContain("invoke_worker");
|
||||
|
||||
expect(m.body).toBe("write-rules");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("parses worker output", () => {
|
||||
|
||||
const m = classifyAgentMessage("[Worker] write-rules 已完成\n\n### rules\n\n1. foo");
|
||||
|
||||
expect(m.kind).toBe("worker_output");
|
||||
|
||||
expect(m.actor).toBe("write-rules");
|
||||
|
||||
expect(m.body).toContain("1. foo");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("parses worker questions", () => {
|
||||
|
||||
const m = classifyAgentMessage(
|
||||
|
||||
"[Worker] setup-scenario 提问:\n- 请提供玩家人数",
|
||||
|
||||
);
|
||||
|
||||
expect(m.kind).toBe("worker_questions");
|
||||
|
||||
expect(m.actor).toBe("setup-scenario");
|
||||
|
||||
expect(m.body).toContain("请提供玩家人数");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("falls back when worker question body is empty", () => {
|
||||
|
||||
const m = classifyAgentMessage("Worker 提问:\n");
|
||||
|
||||
expect(m.kind).toBe("worker_questions");
|
||||
|
||||
expect(m.body).toContain("请补充当前步骤");
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe("lifecycle", () => {
|
||||
|
||||
it("infers design before startup completed", () => {
|
||||
|
||||
const session = createSession();
|
||||
|
||||
expect(inferLifecycleStage(session)).toBe("design");
|
||||
|
||||
expect(canEnterPlay(session)).toBe(false);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("infers play after startup completed", () => {
|
||||
|
||||
const session = {
|
||||
|
||||
...createSession(),
|
||||
|
||||
slots: { ...createSession().slots, startupCompleted: true },
|
||||
|
||||
};
|
||||
|
||||
expect(inferLifecycleStage(session)).toBe("play");
|
||||
|
||||
expect(canEnterPlay(session)).toBe(true);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("respects uiLifecycleStage override", () => {
|
||||
|
||||
const session = {
|
||||
|
||||
...createSession(),
|
||||
|
||||
slots: { ...createSession().slots, uiLifecycleStage: "play" },
|
||||
|
||||
};
|
||||
|
||||
expect(inferLifecycleStage(session)).toBe("play");
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe("buildSkillCatalog", () => {
|
||||
|
||||
it("lists design skills for world-simulator", () => {
|
||||
|
||||
const session = createSession();
|
||||
33
tests/blackboard.test.ts
Normal file
33
tests/blackboard.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import { tagMatchesPattern } from "../src/blackboard/tag-match.js";
|
||||
|
||||
describe("tag matching", () => {
|
||||
it("matches exact and prefix patterns", () => {
|
||||
expect(tagMatchesPattern("角色.A.台词.候选", "角色.A.台词.候选")).toBe(true);
|
||||
expect(tagMatchesPattern("角色.A.台词.候选", "角色.A.*")).toBe(true);
|
||||
expect(tagMatchesPattern("角色.B.台词.候选", "角色.A.*")).toBe(false);
|
||||
expect(tagMatchesPattern("任意.tag", "**")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blackboard", () => {
|
||||
it("writes and queries by tag pattern", () => {
|
||||
const bb = new Blackboard();
|
||||
bb.write({ tag: "book.brief", content: "科幻短篇", source: "user" });
|
||||
bb.write({ tag: "outline.draft", content: "第一章…", source: "outline" });
|
||||
|
||||
expect(bb.getContentByTag("book.brief")).toBe("科幻短篇");
|
||||
expect(bb.listTagIndex()).toHaveLength(2);
|
||||
|
||||
const items = bb.queryByPatterns(["book.brief", "outline.*"]);
|
||||
expect(items.map((i) => i.tag).sort()).toEqual(["book.brief", "outline.draft"]);
|
||||
});
|
||||
|
||||
it("returns latest item for duplicate tags", () => {
|
||||
const bb = new Blackboard();
|
||||
bb.write({ tag: "正文.草稿", content: "v1", source: "w" });
|
||||
bb.write({ tag: "正文.草稿", content: "v2", source: "w" });
|
||||
expect(bb.getContentByTag("正文.草稿")).toBe("v2");
|
||||
});
|
||||
});
|
||||
20
tests/book-store.test.ts
Normal file
20
tests/book-store.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it, beforeAll } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
describe("book store", () => {
|
||||
beforeAll(() => {
|
||||
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||
path.join(tmpdir(), "wa-book-test-"),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates and lists books", async () => {
|
||||
const { createBook, getBook, listBooks } = await import("../src/book/store.js");
|
||||
const book = createBook({ title: "测试之书" });
|
||||
expect(book.id).toBeTruthy();
|
||||
expect(getBook(book.id)?.title).toBe("测试之书");
|
||||
expect(listBooks().some((b) => b.id === book.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
117
tests/intake.test.ts
Normal file
117
tests/intake.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildIntakeFollowUpMessage,
|
||||
buildIntakeProgress,
|
||||
extractIntakeHeuristic,
|
||||
intakeFieldsFromInquiry,
|
||||
synthesizeDemandText,
|
||||
} from "../src/intake/intake.js";
|
||||
import { applyEvent, createSession } from "../src/runtime/phase-machine.js";
|
||||
import { toActiveSkillSnapshot } from "../src/skills/snapshot.js";
|
||||
import { loadSkill } from "../src/skills/loader.js";
|
||||
|
||||
describe("intake fields", () => {
|
||||
it("parses required and optional from roleplay skill", async () => {
|
||||
const skill = await loadSkill("roleplay-game-theory");
|
||||
const fields = intakeFieldsFromInquiry(skill.startupInquiry);
|
||||
expect(fields.filter((f) => f.required).length).toBe(3);
|
||||
expect(fields.filter((f) => !f.required).length).toBe(3);
|
||||
});
|
||||
|
||||
it("marks ready when all required filled", () => {
|
||||
const fields = intakeFieldsFromInquiry({
|
||||
prompt: "p",
|
||||
targetKey: "用户.博弈需求",
|
||||
requiredFields: ["情境", "角色", "轮次"],
|
||||
optionalFields: ["输出偏好"],
|
||||
});
|
||||
const partial = buildIntakeProgress(fields, {
|
||||
[fields[0].id]: "德州扑克",
|
||||
});
|
||||
expect(partial.ready).toBe(false);
|
||||
expect(partial.requiredFilled).toBe(1);
|
||||
|
||||
const full = buildIntakeProgress(fields, {
|
||||
[fields[0].id]: "德州扑克",
|
||||
[fields[1].id]: "玩家A算计,玩家B保守",
|
||||
[fields[2].id]: "单轮",
|
||||
});
|
||||
expect(full.ready).toBe(true);
|
||||
});
|
||||
|
||||
it("heuristic fills scenario and rounds keywords", () => {
|
||||
const fields = intakeFieldsFromInquiry({
|
||||
prompt: "p",
|
||||
targetKey: "t",
|
||||
requiredFields: ["实验情境", "轮次模式"],
|
||||
optionalFields: [],
|
||||
});
|
||||
const values = extractIntakeHeuristic(
|
||||
"德州扑克,单轮定胜负",
|
||||
fields,
|
||||
{},
|
||||
);
|
||||
expect(Object.keys(values).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses roleplay intake fields as 3 required + optional", async () => {
|
||||
const skill = await loadSkill("roleplay-game-theory");
|
||||
const fields = intakeFieldsFromInquiry(skill.startupInquiry);
|
||||
expect(fields.filter((f) => f.required).length).toBe(3);
|
||||
expect(fields.filter((f) => !f.required).length).toBe(3);
|
||||
expect(fields.some((f) => f.label.includes("情境"))).toBe(true);
|
||||
expect(fields.some((f) => f.label.includes("进程"))).toBe(true);
|
||||
});
|
||||
|
||||
it("buildIntakeFollowUpMessage lists missing required fields", () => {
|
||||
const fields = intakeFieldsFromInquiry({
|
||||
prompt: "p",
|
||||
targetKey: "t",
|
||||
requiredFields: ["情境", "角色", "进程"],
|
||||
optionalFields: [],
|
||||
});
|
||||
const progress = buildIntakeProgress(fields, {
|
||||
[fields[0].id]: "德州扑克",
|
||||
});
|
||||
const msg = buildIntakeFollowUpMessage(progress);
|
||||
expect(msg).toContain("还缺以下必要项");
|
||||
expect(msg).toContain("角色");
|
||||
expect(msg).toContain("进程");
|
||||
});
|
||||
|
||||
it("confirm intake writes structured demand tag", async () => {
|
||||
const snap = toActiveSkillSnapshot(await loadSkill("basic"));
|
||||
let session = createSession("default");
|
||||
session = applyEvent(session, {
|
||||
type: "session_started",
|
||||
payload: {
|
||||
presetId: "default",
|
||||
availableSkills: [{ name: "basic", description: "", category: "novel" }],
|
||||
},
|
||||
}).session;
|
||||
session = applyEvent(session, {
|
||||
type: "skill_selected",
|
||||
payload: { skill: snap },
|
||||
}).session;
|
||||
expect(session.waitingReason?.kind).toBe("intake");
|
||||
|
||||
const fields = snap.intakeFields;
|
||||
const values: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
values[f.id] = `值-${f.label}`;
|
||||
}
|
||||
session = applyEvent(session, {
|
||||
type: "user_submitted_input",
|
||||
payload: { text: "一次性提交", intakeValues: values },
|
||||
}).session;
|
||||
expect(session.waitingReason?.kind).toBe("intake");
|
||||
|
||||
session = applyEvent(session, {
|
||||
type: "user_confirmed_intake",
|
||||
payload: {},
|
||||
}).session;
|
||||
expect(session.slots.startupCompleted).toBe(true);
|
||||
expect(String(session.slots["book.brief"])).toContain("题材");
|
||||
expect(synthesizeDemandText(fields, values)).toContain("值-题材");
|
||||
});
|
||||
});
|
||||
26
tests/orchestrator.test.ts
Normal file
26
tests/orchestrator.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMockMainAgentResponse, MockLlmProvider } from "../src/llm/client.js";
|
||||
import { parseMainAgentDecision } from "../src/main-agent/main-agent.js";
|
||||
|
||||
describe("main agent", () => {
|
||||
it("parses valid JSON decision", () => {
|
||||
const raw = createMockMainAgentResponse({
|
||||
action: "run_worker",
|
||||
workerId: "outline-worker",
|
||||
requiresApproval: true,
|
||||
});
|
||||
const decision = parseMainAgentDecision(raw);
|
||||
expect(decision.action).toBe("run_worker");
|
||||
expect(decision.workerId).toBe("outline-worker");
|
||||
expect(decision.statePatchAllowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// 带 skill 的完整闭环见 tests/phase-skill.test.ts(PhaseRuntime)
|
||||
// RuntimeOrchestrator 接 skill 层留待 Phase A1
|
||||
|
||||
describe("runtime orchestrator", () => {
|
||||
it("placeholder until orchestrator uses PhaseRuntime + skill", () => {
|
||||
expect(MockLlmProvider).toBeDefined();
|
||||
});
|
||||
});
|
||||
172
tests/phase-machine.test.ts
Normal file
172
tests/phase-machine.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDecision } from "../src/runtime/orchestrator.js";
|
||||
import {
|
||||
applyEvent,
|
||||
canApplyEvent,
|
||||
createArtifact,
|
||||
createSession,
|
||||
} from "../src/runtime/phase-machine.js";
|
||||
import { loadSkill } from "../src/skills/loader.js";
|
||||
import { toActiveSkillSnapshot } from "../src/skills/snapshot.js";
|
||||
|
||||
const mockSkills = [{ name: "basic", description: "基础", category: "novel" }];
|
||||
|
||||
describe("phase machine", () => {
|
||||
it("runs worker loop after skill and startup input", async () => {
|
||||
const { loadSkill } = await import("../src/skills/loader.js");
|
||||
const { toActiveSkillSnapshot } = await import("../src/skills/snapshot.js");
|
||||
const snap = toActiveSkillSnapshot(await loadSkill("basic"));
|
||||
|
||||
let session = createSession("default");
|
||||
session = applyEvent(session, {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
}).session;
|
||||
session = applyEvent(session, {
|
||||
type: "skill_selected",
|
||||
payload: { skill: snap },
|
||||
}).session;
|
||||
const values: Record<string, string> = {};
|
||||
for (const f of snap.intakeFields) {
|
||||
values[f.id] = "科幻短篇,第一人称";
|
||||
}
|
||||
session = applyEvent(session, {
|
||||
type: "user_submitted_input",
|
||||
payload: { text: "科幻短篇,第一人称", intakeValues: values },
|
||||
}).session;
|
||||
expect(session.waitingReason?.kind).toBe("intake");
|
||||
session = applyEvent(session, {
|
||||
type: "user_confirmed_intake",
|
||||
payload: {},
|
||||
}).session;
|
||||
expect(session.phase).toBe("running");
|
||||
expect(session.slots["book.brief"]).toBeTruthy();
|
||||
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
reason: "生成大纲",
|
||||
workerId: "outline-worker",
|
||||
requiresApproval: true,
|
||||
});
|
||||
session = applyEvent(session, {
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
}).session;
|
||||
expect(session.waitingReason?.kind).toBe("approve_step");
|
||||
|
||||
session = applyEvent(session, {
|
||||
type: "user_approved_next_step",
|
||||
payload: { decisionId: decision.id },
|
||||
}).session;
|
||||
session = applyEvent(session, {
|
||||
type: "worker_started",
|
||||
payload: { workerId: "outline-worker", acceptanceMode: "user_confirmed" },
|
||||
}).session;
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId: "outline-worker",
|
||||
outputTags: ["outline.draft"],
|
||||
});
|
||||
session = { ...session, artifacts: [artifact] };
|
||||
session = applyEvent(session, {
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
}).session;
|
||||
expect(session.waitingReason?.kind).toBe("review_artifact");
|
||||
|
||||
session = applyEvent(session, {
|
||||
type: "user_accepted_artifact",
|
||||
payload: { artifactId: artifact.id },
|
||||
}).session;
|
||||
expect(session.phase).toBe("running");
|
||||
|
||||
session = applyEvent(session, { type: "flow_completed", payload: {} }).session;
|
||||
expect(session.phase).toBe("done");
|
||||
});
|
||||
|
||||
it("rejects illegal events from idle", () => {
|
||||
const session = createSession("default");
|
||||
expect(canApplyEvent(session, { type: "user_submitted_input", payload: { text: "x" } })).toBe(
|
||||
false,
|
||||
);
|
||||
const result = applyEvent(session, {
|
||||
type: "user_submitted_input",
|
||||
payload: { text: "x" },
|
||||
});
|
||||
expect(result.session.phase).toBe("error");
|
||||
});
|
||||
|
||||
it("merges follow-up input after intake confirm via ask_user", async () => {
|
||||
const { createDecision } = await import("../src/runtime/orchestrator.js");
|
||||
const snap = toActiveSkillSnapshot(await loadSkill("roleplay-game-theory"));
|
||||
|
||||
let session = createSession("default");
|
||||
session = applyEvent(session, {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
}).session;
|
||||
session = applyEvent(session, {
|
||||
type: "skill_selected",
|
||||
payload: { skill: snap },
|
||||
}).session;
|
||||
|
||||
const fields = snap.intakeFields;
|
||||
const values: Record<string, string> = {};
|
||||
for (const f of fields.filter((x) => x.required)) {
|
||||
values[f.id] = "已填";
|
||||
}
|
||||
session = applyEvent(session, {
|
||||
type: "user_submitted_input",
|
||||
payload: { text: "首次", intakeValues: values },
|
||||
}).session;
|
||||
session = applyEvent(session, {
|
||||
type: "user_confirmed_intake",
|
||||
payload: {},
|
||||
}).session;
|
||||
expect(session.slots.startupCompleted).toBe(true);
|
||||
|
||||
session = applyEvent(session, {
|
||||
type: "main_agent_decision_created",
|
||||
payload: {
|
||||
decision: createDecision({
|
||||
action: "ask_user",
|
||||
reason: "输出偏好?",
|
||||
}),
|
||||
},
|
||||
}).session;
|
||||
expect(session.waitingReason?.kind).toBe("input");
|
||||
|
||||
session = applyEvent(session, {
|
||||
type: "user_submitted_input",
|
||||
payload: { text: "需要思考标签,带场景描写" },
|
||||
}).session;
|
||||
const demand = String(session.slots["用户.博弈需求"]);
|
||||
expect(demand).toContain("需要思考标签");
|
||||
});
|
||||
|
||||
it("emits one follow-up when roleplay intake required fields incomplete", async () => {
|
||||
const snap = toActiveSkillSnapshot(await loadSkill("roleplay-game-theory"));
|
||||
let session = createSession("default");
|
||||
session = applyEvent(session, {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
}).session;
|
||||
session = applyEvent(session, {
|
||||
type: "skill_selected",
|
||||
payload: { skill: snap },
|
||||
}).session;
|
||||
const scenarioField = snap.intakeFields.find((f) => f.label.includes("情境"));
|
||||
expect(scenarioField).toBeDefined();
|
||||
const result = applyEvent(session, {
|
||||
type: "user_submitted_input",
|
||||
payload: {
|
||||
text: "德州扑克",
|
||||
intakeValues: { [scenarioField!.id]: "德州扑克" },
|
||||
},
|
||||
});
|
||||
expect(result.effects.some((e) => e.type === "emit_message")).toBe(true);
|
||||
const msg = result.effects.find((e) => e.type === "emit_message");
|
||||
expect(msg && "message" in msg && msg.message).toContain("还缺以下必要项");
|
||||
expect(result.session.slots.intakeFollowUpSent).toBe(true);
|
||||
});
|
||||
});
|
||||
77
tests/phase-runtime.test.ts
Normal file
77
tests/phase-runtime.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMockMainAgentResponse,
|
||||
createMockToolCall,
|
||||
MockLlmProvider,
|
||||
} from "../src/llm/client.js";
|
||||
import { buildIntakeProgress, readIntakeValues } from "../src/intake/intake.js";
|
||||
import { PhaseRuntime } from "../src/runtime/phase-runtime.js";
|
||||
|
||||
describe("phase runtime", () => {
|
||||
it("selects basic skill and shows startup inquiry", async () => {
|
||||
const runtime = new PhaseRuntime();
|
||||
await runtime.start();
|
||||
expect(runtime.getSession().waitingReason?.kind).toBe("skill_selection");
|
||||
|
||||
await runtime.selectSkill("basic");
|
||||
expect(runtime.getSession().waitingReason?.kind).toBe("intake");
|
||||
expect(runtime.getActiveSkill()?.name).toBe("basic");
|
||||
});
|
||||
|
||||
it("writes book.brief after intake confirm", async () => {
|
||||
const runtime = new PhaseRuntime();
|
||||
await runtime.start();
|
||||
await runtime.selectSkill("basic");
|
||||
await runtime.submitInput("科幻中篇,第三人称");
|
||||
await runtime.confirmIntake();
|
||||
expect(runtime.getSession().slots["book.brief"]).toContain("科幻");
|
||||
expect(runtime.getSession().slots.startupCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it("syncs demand tag to blackboard after intake confirm", async () => {
|
||||
const runtime = new PhaseRuntime();
|
||||
await runtime.start();
|
||||
await runtime.selectSkill("roleplay-game-theory");
|
||||
const fields = runtime.getActiveSkill()?.intakeFields ?? [];
|
||||
await runtime.submitInput("德州扑克,经典博弈情境");
|
||||
await runtime.submitInput("玩家A算计型,玩家B怕吃亏");
|
||||
await runtime.submitInput("单轮定胜负,需要思考标签与场景描写");
|
||||
const progress = buildIntakeProgress(
|
||||
fields,
|
||||
readIntakeValues(runtime.getSession().slots),
|
||||
);
|
||||
if (!progress.ready) {
|
||||
const values = readIntakeValues(runtime.getSession().slots);
|
||||
for (const f of fields.filter((x) => x.required && !values[x.id])) {
|
||||
values[f.id] = "补充";
|
||||
}
|
||||
await runtime.dispatch({
|
||||
type: "user_submitted_input",
|
||||
payload: { text: "补充", intakeValues: values },
|
||||
});
|
||||
}
|
||||
await runtime.confirmIntake();
|
||||
const demand = runtime.getBlackboard().getContentByTag("用户.博弈需求") ?? "";
|
||||
expect(demand).toContain("德州扑克");
|
||||
expect(runtime.getSession().slots.startupCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it("mock main agent tool loop reads blackboard then proposes worker", async () => {
|
||||
const runtime = new PhaseRuntime({
|
||||
llm: new MockLlmProvider([
|
||||
createMockToolCall("read_blackboard", { tags: ["book.brief"] }),
|
||||
createMockToolCall("run_worker", {
|
||||
workerId: "outline",
|
||||
reason: "已读需求,生成大纲",
|
||||
requiresApproval: true,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
await runtime.start();
|
||||
await runtime.selectSkill("basic");
|
||||
await runtime.submitInput("科幻中篇,第三人称");
|
||||
await runtime.confirmIntake();
|
||||
expect(runtime.getSession().waitingReason?.kind).toBe("approve_step");
|
||||
expect(runtime.getSession().pendingDecision?.workerId).toBe("outline");
|
||||
});
|
||||
});
|
||||
156
tests/phase-skill.test.ts
Normal file
156
tests/phase-skill.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadSkill, listSkills } from "../src/skills/loader.js";
|
||||
import { toActiveSkillSnapshot } from "../src/skills/snapshot.js";
|
||||
import { createDecision } from "../src/runtime/phase-runtime.js";
|
||||
import {
|
||||
applyEvent,
|
||||
canApplyEvent,
|
||||
createSession,
|
||||
} from "../src/runtime/phase-machine.js";
|
||||
|
||||
const mockSkills = [
|
||||
{ name: "basic", description: "基础小说", category: "novel" },
|
||||
];
|
||||
|
||||
describe("skill loader", () => {
|
||||
it("lists basic skill", async () => {
|
||||
const skills = await listSkills();
|
||||
expect(skills.some((s) => s.name === "basic")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads startup inquiry from SKILL.md", async () => {
|
||||
const skill = await loadSkill("basic");
|
||||
expect(skill.startupInquiry.targetKey).toBe("book.brief");
|
||||
expect(skill.startupInquiry.prompt).toContain("基础小说创作");
|
||||
const snap = toActiveSkillSnapshot(skill);
|
||||
expect(snap.startupPrompt).toBe(skill.startupInquiry.prompt);
|
||||
});
|
||||
|
||||
it("loads weird-rules-short skill pack", async () => {
|
||||
const skill = await loadSkill("weird-rules-short");
|
||||
expect(skill.name).toBe("weird-rules-short");
|
||||
expect(skill.path).toBe("novel/weird-rules-short/orchestrator.md");
|
||||
expect(skill.skillPackRoot).toBe("novel/weird-rules-short");
|
||||
expect(skill.bookKind).toBe("novel");
|
||||
expect(skill.tags).toContain("weird_rules");
|
||||
expect(skill.suggestedWorkers).toEqual(["write-rules", "review-infer", "review-author"]);
|
||||
expect(skill.sharedContextPath).toBe("shared-context.md");
|
||||
expect(skill.startupInquiry.prompt).toContain("规则怪谈");
|
||||
expect(skill.startupInquiry.prompt).not.toContain("叙事人称");
|
||||
});
|
||||
|
||||
it("loads roleplay-game-theory skill pack (instantiate)", async () => {
|
||||
const skill = await loadSkill("roleplay-game-theory");
|
||||
expect(skill.name).toBe("roleplay-game-theory");
|
||||
expect(skill.path).toBe("dialogue/roleplay-game-theory/orchestrator.md");
|
||||
expect(skill.bookKind).toBe("dialogue");
|
||||
expect(skill.suggestedWorkers).toEqual(["setup-scenario", "world-engine", "role-decide", "present-round"]);
|
||||
expect(skill.startupInquiry.targetKey).toBe("用户.博弈需求");
|
||||
expect(skill.startupInquiry.prompt).toContain("角色扮演博弈");
|
||||
});
|
||||
|
||||
it("loads worker skills from skill pack", async () => {
|
||||
const {
|
||||
loadWorkerSkill,
|
||||
loadWorkerSkillWithContext,
|
||||
loadSkillSharedContext,
|
||||
listWorkerSkills,
|
||||
} = await import("../src/skills/loader.js");
|
||||
const workers = await listWorkerSkills("weird-rules-short");
|
||||
expect(workers.map((w) => w.id).sort()).toEqual([
|
||||
"review-author",
|
||||
"review-infer",
|
||||
"write-rules",
|
||||
]);
|
||||
|
||||
const writeRules = await loadWorkerSkill("weird-rules-short", "write-rules");
|
||||
expect(writeRules.outputTags).toContain("rules.draft");
|
||||
expect(writeRules.body).toContain("shared-context");
|
||||
|
||||
const reviewInfer = await loadWorkerSkill("weird-rules-short", "review-infer");
|
||||
expect(reviewInfer.outputTags).toContain("review.infer.notes");
|
||||
expect(reviewInfer.body).toContain("verdict:");
|
||||
|
||||
const reviewAuthor = await loadWorkerSkill("weird-rules-short", "review-author");
|
||||
expect(reviewAuthor.outputTags).toContain("review.author.notes");
|
||||
expect(reviewAuthor.body).toContain("表面矛盾");
|
||||
|
||||
const shared = await loadSkillSharedContext("weird-rules-short");
|
||||
expect(shared).toContain("表面矛盾 ≠ 逻辑矛盾");
|
||||
|
||||
const withCtx = await loadWorkerSkillWithContext("weird-rules-short", "write-rules");
|
||||
expect(withCtx.sharedContext).toContain("盲人摸象");
|
||||
expect(withCtx.promptBody).toContain("固定创作上下文");
|
||||
});
|
||||
|
||||
it("loads roleplay role-decide with thinking/action output tags", async () => {
|
||||
const { loadWorkerSkill } = await import("../src/skills/loader.js");
|
||||
const roleDecide = await loadWorkerSkill("roleplay-game-theory", "role-decide");
|
||||
expect(roleDecide.outputTags).toContain("角色.*.思考");
|
||||
expect(roleDecide.outputTags).toContain("角色.*.行动");
|
||||
expect(roleDecide.body).toContain("仅用户可见");
|
||||
});
|
||||
|
||||
it("loads basic skill pack", async () => {
|
||||
const skill = await loadSkill("basic");
|
||||
expect(skill.path).toBe("novel/basic/orchestrator.md");
|
||||
expect(skill.skillPackRoot).toBe("novel/basic");
|
||||
expect(skill.suggestedWorkers).toEqual(["outline"]);
|
||||
});
|
||||
|
||||
it("lists only registered skills", async () => {
|
||||
const skills = await listSkills();
|
||||
const names = skills.map((s) => s.name).sort();
|
||||
expect(names).toEqual(["basic", "roleplay-game-theory", "weird-rules-short"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase machine with skill", () => {
|
||||
it("starts with skill_selection", () => {
|
||||
let session = createSession("default");
|
||||
const result = applyEvent(session, {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
});
|
||||
expect(result.session.phase).toBe("waiting_user");
|
||||
expect(result.session.waitingReason?.kind).toBe("skill_selection");
|
||||
});
|
||||
|
||||
it("skill_selected shows startup prompt from skill", async () => {
|
||||
const skill = await loadSkill("basic");
|
||||
const snap = toActiveSkillSnapshot(skill);
|
||||
|
||||
let session = applyEvent(createSession("default"), {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
}).session;
|
||||
|
||||
const selected = applyEvent(session, {
|
||||
type: "skill_selected",
|
||||
payload: { skill: snap },
|
||||
});
|
||||
expect(selected.session.waitingReason?.kind).toBe("input");
|
||||
expect(selected.session.slots.activeSkill).toBeDefined();
|
||||
expect((selected.session.slots.activeSkill as { name: string }).name).toBe("basic");
|
||||
});
|
||||
|
||||
it("rejects input before skill selected", () => {
|
||||
const session = applyEvent(createSession("default"), {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
}).session;
|
||||
expect(canApplyEvent(session, { type: "user_submitted_input", payload: { text: "x" } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase runtime with skill", () => {
|
||||
it("runs full loop with basic skill", async () => {
|
||||
const { PhaseRuntime, runMinimalClosedLoop } = await import("../src/runtime/phase-runtime.js");
|
||||
const session = await runMinimalClosedLoop(new PhaseRuntime({ autoStubWorker: true }));
|
||||
expect(session.phase).toBe("done");
|
||||
expect(session.slots.startupCompleted).toBe(true);
|
||||
expect(session.slots["book.brief"]).toContain("科幻");
|
||||
});
|
||||
});
|
||||
84
tests/preset.test.ts
Normal file
84
tests/preset.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assemblePresetMessages } from "../src/preset/assembler.js";
|
||||
import { listEnabledPresetEntries, countInjectingEntries } from "../src/preset/entries.js";
|
||||
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||
|
||||
const samplePreset = {
|
||||
temperature: 1,
|
||||
top_p: 0.95,
|
||||
openai_max_tokens: 60000,
|
||||
prompts: [
|
||||
{
|
||||
identifier: "main",
|
||||
name: "主要",
|
||||
role: "system",
|
||||
content: "",
|
||||
marker: true,
|
||||
},
|
||||
{
|
||||
identifier: "custom-a",
|
||||
name: "A",
|
||||
role: "system",
|
||||
content: "instruction A",
|
||||
},
|
||||
{
|
||||
identifier: "custom-b",
|
||||
name: "B",
|
||||
role: "assistant",
|
||||
content: "prefill B",
|
||||
},
|
||||
],
|
||||
prompt_order: [
|
||||
{
|
||||
character_id: 100000,
|
||||
order: [{ identifier: "main", enabled: true }],
|
||||
},
|
||||
{
|
||||
character_id: 100001,
|
||||
order: [
|
||||
{ identifier: "custom-a", enabled: true },
|
||||
{ identifier: "custom-b", enabled: true },
|
||||
{ identifier: "main", enabled: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("importSillyTavernPreset", () => {
|
||||
it("picks prompt_order with most enabled content", () => {
|
||||
const report = importSillyTavernPreset(samplePreset, { name: "test" });
|
||||
expect(report.enabledCount).toBe(3);
|
||||
expect(report.preset.promptOrder.map((o) => o.promptId)).toEqual([
|
||||
"custom-a",
|
||||
"custom-b",
|
||||
"main",
|
||||
]);
|
||||
expect(report.preset.generation.temperature).toBe(1);
|
||||
expect(report.preset.generation.maxOutputTokens).toBe(60000);
|
||||
expect(report.warnings.some((w) => w.includes("prompt_order"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assemblePresetMessages", () => {
|
||||
it("skips empty markers and emits enabled content in order", () => {
|
||||
const report = importSillyTavernPreset(samplePreset);
|
||||
const messages = assemblePresetMessages(report.preset);
|
||||
expect(messages).toEqual([
|
||||
{ role: "system", content: "instruction A" },
|
||||
{ role: "assistant", content: "prefill B" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listEnabledPresetEntries", () => {
|
||||
it("lists enabled order with inject flags", () => {
|
||||
const report = importSillyTavernPreset(samplePreset);
|
||||
const entries = listEnabledPresetEntries(report.preset);
|
||||
expect(entries.length).toBe(3);
|
||||
expect(countInjectingEntries(entries)).toBe(2);
|
||||
expect(entries.filter((e) => e.willInject).map((e) => e.content)).toEqual([
|
||||
"instruction A",
|
||||
"prefill B",
|
||||
]);
|
||||
});
|
||||
});
|
||||
173
tests/run-snapshot.test.ts
Normal file
173
tests/run-snapshot.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it, beforeAll } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
import type { RunSnapshot } from "../src/types/run-snapshot.js";
|
||||
|
||||
describe("run snapshot store", () => {
|
||||
beforeAll(() => {
|
||||
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||
path.join(tmpdir(), "wa-run-snapshot-"),
|
||||
);
|
||||
});
|
||||
|
||||
function makeSnapshot(bookId: string, label: string, kind: "instance" | "run" = "run"): RunSnapshot {
|
||||
const runtimeSession = createSession("default");
|
||||
runtimeSession.phase = "running";
|
||||
runtimeSession.slots.startupCompleted = true;
|
||||
return {
|
||||
version: 1,
|
||||
id: randomUUID(),
|
||||
bookId,
|
||||
label,
|
||||
kind,
|
||||
orchestratorId: "roleplay-game-theory",
|
||||
runtimeSession,
|
||||
blackboardItems: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
tag: "运行.事件流",
|
||||
content: "## 第 1 轮\n- 测试",
|
||||
source: "worker",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
role: "user",
|
||||
text: "存档时的对话",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
note: "测试快照",
|
||||
};
|
||||
}
|
||||
|
||||
it("saveRunSnapshot / loadRunSnapshot roundtrip", async () => {
|
||||
const { saveRunSnapshot, loadRunSnapshot } = await import(
|
||||
"../src/book/run-snapshot-store.js"
|
||||
);
|
||||
const bookId = randomUUID();
|
||||
const snapshot = makeSnapshot(bookId, "第 1 轮后");
|
||||
|
||||
saveRunSnapshot(snapshot);
|
||||
const loaded = loadRunSnapshot(bookId, snapshot.id);
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.label).toBe("第 1 轮后");
|
||||
expect(loaded!.blackboardItems).toHaveLength(1);
|
||||
expect(loaded!.note).toBe("测试快照");
|
||||
});
|
||||
|
||||
it("listRunSnapshots returns saved items newest first", async () => {
|
||||
const { saveRunSnapshot, listRunSnapshots } = await import(
|
||||
"../src/book/run-snapshot-store.js"
|
||||
);
|
||||
const bookId = randomUUID();
|
||||
const older = makeSnapshot(bookId, "较早");
|
||||
older.createdAt = "2020-01-01T00:00:00.000Z";
|
||||
const newer = makeSnapshot(bookId, "较新");
|
||||
newer.createdAt = "2025-01-01T00:00:00.000Z";
|
||||
|
||||
saveRunSnapshot(older);
|
||||
saveRunSnapshot(newer);
|
||||
|
||||
const list = listRunSnapshots(bookId);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].label).toBe("较新");
|
||||
expect(list[1].label).toBe("较早");
|
||||
});
|
||||
|
||||
it("deleteRunSnapshot removes one snapshot", async () => {
|
||||
const { saveRunSnapshot, loadRunSnapshot, deleteRunSnapshot } = await import(
|
||||
"../src/book/run-snapshot-store.js"
|
||||
);
|
||||
const bookId = randomUUID();
|
||||
const snapshot = makeSnapshot(bookId, "待删");
|
||||
|
||||
saveRunSnapshot(snapshot);
|
||||
expect(loadRunSnapshot(bookId, snapshot.id)).not.toBeNull();
|
||||
|
||||
expect(deleteRunSnapshot(bookId, snapshot.id)).toBe(true);
|
||||
expect(loadRunSnapshot(bookId, snapshot.id)).toBeNull();
|
||||
expect(deleteRunSnapshot(bookId, snapshot.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("deleteAllRunSnapshots clears book snapshots", async () => {
|
||||
const { saveRunSnapshot, listRunSnapshots, deleteAllRunSnapshots } =
|
||||
await import("../src/book/run-snapshot-store.js");
|
||||
const bookId = randomUUID();
|
||||
saveRunSnapshot(makeSnapshot(bookId, "a"));
|
||||
saveRunSnapshot(makeSnapshot(bookId, "b"));
|
||||
|
||||
deleteAllRunSnapshots(bookId);
|
||||
expect(listRunSnapshots(bookId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deleteBook removes run snapshots", async () => {
|
||||
const { createBook, deleteBook } = await import("../src/book/store.js");
|
||||
const { saveRunSnapshot, listRunSnapshots } = await import(
|
||||
"../src/book/run-snapshot-store.js"
|
||||
);
|
||||
|
||||
const book = createBook({ title: "快照清理测试" });
|
||||
saveRunSnapshot(makeSnapshot(book.id, "保留前"));
|
||||
|
||||
deleteBook(book.id);
|
||||
expect(listRunSnapshots(book.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("saveGameSnapshot / loadGameSnapshot restores playable state", async () => {
|
||||
const { createBook } = await import("../src/book/store.js");
|
||||
const { SessionManager } = await import("../src/server/session-manager.js");
|
||||
|
||||
const book = createBook({ title: "读档测试" });
|
||||
|
||||
const mgr = new SessionManager();
|
||||
const view1 = await mgr.createForBook(book.id, "basic");
|
||||
await mgr.sendMessage(view1.id, "读档前消息");
|
||||
|
||||
const save = mgr.saveGameSnapshot(view1.id, "第一章末");
|
||||
expect(save.label).toBe("第一章末");
|
||||
|
||||
await mgr.sendMessage(view1.id, "读档后不应保留的消息");
|
||||
|
||||
const view2 = await mgr.loadGameSnapshot(book.id, save.id);
|
||||
expect(view2.messages.some((m) => m.text === "读档前消息")).toBe(true);
|
||||
expect(view2.messages.some((m) => m.text === "读档后不应保留的消息")).toBe(false);
|
||||
expect(view2.hints.some((h) => h.includes("第一章末"))).toBe(true);
|
||||
});
|
||||
|
||||
it("instance snapshot strips run progress on save and load", async () => {
|
||||
const { createBook } = await import("../src/book/store.js");
|
||||
const { SessionManager } = await import("../src/server/session-manager.js");
|
||||
const { loadRunSnapshot } = await import("../src/book/run-snapshot-store.js");
|
||||
|
||||
const book = createBook({ title: "实例快照" });
|
||||
|
||||
const mgr = new SessionManager();
|
||||
const view = await mgr.createForBook(book.id, "basic");
|
||||
const managed = mgr["require"](view.id) as {
|
||||
runtime: { getSession: () => { slots: Record<string, unknown> }; getBlackboard: () => { write: (i: object) => void } };
|
||||
};
|
||||
managed.runtime.getSession().slots.startupCompleted = true;
|
||||
const bb = managed.runtime.getBlackboard();
|
||||
bb.write({ tag: "情境.实验.设定", content: "囚徒困境", source: "worker" });
|
||||
bb.write({ tag: "运行.事件流", content: "第1轮", source: "worker" });
|
||||
|
||||
const save = mgr.saveGameSnapshot(view.id, "标准局", "instance");
|
||||
expect(save.kind).toBe("instance");
|
||||
|
||||
const stored = loadRunSnapshot(book.id, save.id)!;
|
||||
expect(stored.blackboardItems.some((i) => i.tag === "情境.实验.设定")).toBe(true);
|
||||
expect(stored.blackboardItems.some((i) => i.tag === "运行.事件流")).toBe(false);
|
||||
|
||||
const loaded = await mgr.loadGameSnapshot(book.id, save.id);
|
||||
expect(loaded.hints.some((h) => h.includes("实例"))).toBe(true);
|
||||
});
|
||||
});
|
||||
115
tests/session-persist.test.ts
Normal file
115
tests/session-persist.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, beforeAll } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
import type { PersistedBookSession } from "../src/types/book-session.js";
|
||||
|
||||
describe("session persistence", () => {
|
||||
beforeAll(() => {
|
||||
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||
path.join(tmpdir(), "wa-session-persist-"),
|
||||
);
|
||||
});
|
||||
|
||||
it("saveBookSession / loadBookSession roundtrip", async () => {
|
||||
const { createBook } = await import("../src/book/store.js");
|
||||
const { saveBookSession, loadBookSession } = await import(
|
||||
"../src/book/session-store.js"
|
||||
);
|
||||
|
||||
const book = createBook({ title: "持久化测试" });
|
||||
const sessionId = randomUUID();
|
||||
const runtimeSession = createSession("default");
|
||||
runtimeSession.id = sessionId;
|
||||
runtimeSession.phase = "running";
|
||||
runtimeSession.slots = { startupCompleted: true, userGoal: "测试目标" };
|
||||
|
||||
const snapshot: PersistedBookSession = {
|
||||
version: 1,
|
||||
sessionId,
|
||||
bookId: book.id,
|
||||
orchestratorId: "basic",
|
||||
runtimeSession,
|
||||
blackboardItems: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
tag: "测试.条目",
|
||||
content: "hello",
|
||||
source: "worker",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
role: "user",
|
||||
text: "用户消息",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveBookSession(snapshot);
|
||||
const loaded = loadBookSession(book.id);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.sessionId).toBe(sessionId);
|
||||
expect(loaded!.runtimeSession.phase).toBe("running");
|
||||
expect(loaded!.runtimeSession.slots.userGoal).toBe("测试目标");
|
||||
expect(loaded!.blackboardItems).toHaveLength(1);
|
||||
expect(loaded!.messages[0].text).toBe("用户消息");
|
||||
});
|
||||
|
||||
it("openBook restores persisted session after restart", async () => {
|
||||
const { createBook, getBook } = await import("../src/book/store.js");
|
||||
const { SessionManager } = await import("../src/server/session-manager.js");
|
||||
|
||||
const book = createBook({ title: "续作测试" });
|
||||
|
||||
const mgr1 = new SessionManager();
|
||||
const view1 = await mgr1.createForBook(book.id, "basic");
|
||||
await mgr1.sendMessage(view1.id, "测试输入");
|
||||
|
||||
const mgr2 = new SessionManager();
|
||||
const view2 = await mgr2.openBook(book.id);
|
||||
|
||||
expect(view2.id).toBe(view1.id);
|
||||
expect(view2.resumed).toBe(true);
|
||||
expect(view2.hints.some((h) => h.includes("恢复"))).toBe(true);
|
||||
expect(view2.messages.some((m) => m.text === "测试输入")).toBe(true);
|
||||
expect(getBook(book.id)?.activeSessionId).toBe(view1.id);
|
||||
});
|
||||
|
||||
it("deleteBook removes session snapshot", async () => {
|
||||
const { createBook, deleteBook } = await import("../src/book/store.js");
|
||||
const { hasBookSession } = await import("../src/book/session-store.js");
|
||||
const { SessionManager } = await import("../src/server/session-manager.js");
|
||||
|
||||
const book = createBook({ title: "删除测试" });
|
||||
|
||||
const mgr = new SessionManager();
|
||||
await mgr.createForBook(book.id, "basic");
|
||||
expect(hasBookSession(book.id)).toBe(true);
|
||||
|
||||
deleteBook(book.id);
|
||||
expect(hasBookSession(book.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("createForBook captures startup inquiry in messages", async () => {
|
||||
const { createBook, deleteBook } = await import("../src/book/store.js");
|
||||
const { SessionManager } = await import("../src/server/session-manager.js");
|
||||
|
||||
const book = createBook({ title: "启动询问" });
|
||||
|
||||
const mgr = new SessionManager();
|
||||
const view = await mgr.createForBook(book.id, "roleplay-game-theory");
|
||||
expect(view.waitingReason?.kind).toBe("intake");
|
||||
expect(view.messages.some((m) => m.text?.includes("角色扮演博弈"))).toBe(true);
|
||||
expect(view.messages.some((m) => m.kind === "orchestrator_prompt")).toBe(true);
|
||||
|
||||
deleteBook(book.id);
|
||||
});
|
||||
});
|
||||
58
tests/snapshot-filters.test.ts
Normal file
58
tests/snapshot-filters.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterBlackboardForInstance,
|
||||
isRunPhaseBlackboardTag,
|
||||
materializeInstanceSnapshotPayload,
|
||||
} from "../src/book/snapshot-filters.js";
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
|
||||
describe("snapshot-filters", () => {
|
||||
it("keeps instance tags, strips run tags", () => {
|
||||
expect(isRunPhaseBlackboardTag("情境.实验.设定")).toBe(false);
|
||||
expect(isRunPhaseBlackboardTag("角色.A.设定")).toBe(false);
|
||||
expect(isRunPhaseBlackboardTag("博弈.规则.草稿")).toBe(false);
|
||||
expect(isRunPhaseBlackboardTag("运行.事件流")).toBe(true);
|
||||
expect(isRunPhaseBlackboardTag("角色.A.思考")).toBe(true);
|
||||
expect(isRunPhaseBlackboardTag("世界.当前状态")).toBe(true);
|
||||
});
|
||||
|
||||
it("materializeInstanceSnapshotPayload removes run blackboard items", () => {
|
||||
const session = createSession("default");
|
||||
session.slots.startupCompleted = true;
|
||||
session.artifacts = [
|
||||
{
|
||||
id: "a1",
|
||||
workerId: "setup-scenario",
|
||||
outputTags: ["情境.实验.设定"],
|
||||
status: "accepted",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
workerId: "world-engine",
|
||||
outputTags: ["运行.事件流"],
|
||||
status: "accepted",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
const { blackboardItems, runtimeSession } = materializeInstanceSnapshotPayload({
|
||||
runtimeSession: session,
|
||||
blackboardItems: [
|
||||
{ id: "1", tag: "情境.实验.设定", content: "x", source: "worker", createdAt: "", updatedAt: "" },
|
||||
{ id: "2", tag: "运行.事件流", content: "y", source: "worker", createdAt: "", updatedAt: "" },
|
||||
{ id: "3", tag: "角色.A.设定", content: "z", source: "worker", createdAt: "", updatedAt: "" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(blackboardItems.map((i) => i.tag).sort()).toEqual([
|
||||
"情境.实验.设定",
|
||||
"角色.A.设定",
|
||||
]);
|
||||
expect(runtimeSession.artifacts).toHaveLength(1);
|
||||
expect(runtimeSession.artifacts[0].workerId).toBe("setup-scenario");
|
||||
expect(filterBlackboardForInstance).toBeDefined();
|
||||
});
|
||||
});
|
||||
144
tests/token-usage.test.ts
Normal file
144
tests/token-usage.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseUsage } from "../src/llm/client.js";
|
||||
import {
|
||||
recordTokenUsage,
|
||||
summarizeTokenUsage,
|
||||
} from "../src/stats/token-store.js";
|
||||
import { TokenTrackingProvider, type LlmTrackingContext } from "../src/llm/token-tracker.js";
|
||||
import type { CompleteResult, LlmProvider } from "../src/llm/client.js";
|
||||
|
||||
describe("parseUsage", () => {
|
||||
it("parses standard OpenAI usage", () => {
|
||||
const usage = parseUsage({
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 150,
|
||||
});
|
||||
expect(usage).toEqual({
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
totalTokens: 150,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses OpenAI cached_tokens via prompt_tokens_details", () => {
|
||||
const usage = parseUsage({
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 1020,
|
||||
prompt_tokens_details: { cached_tokens: 800 },
|
||||
});
|
||||
expect(usage?.cachedTokens).toBe(800);
|
||||
expect(usage?.cacheMissTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses DeepSeek cache hit and miss fields", () => {
|
||||
const usage = parseUsage({
|
||||
prompt_tokens: 500,
|
||||
completion_tokens: 30,
|
||||
total_tokens: 530,
|
||||
prompt_cache_hit_tokens: 400,
|
||||
prompt_cache_miss_tokens: 100,
|
||||
});
|
||||
expect(usage?.cachedTokens).toBe(400);
|
||||
expect(usage?.cacheMissTokens).toBe(100);
|
||||
});
|
||||
|
||||
it("returns undefined for empty usage", () => {
|
||||
expect(parseUsage(null)).toBeUndefined();
|
||||
expect(parseUsage({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TokenTrackingProvider", () => {
|
||||
it("stores pending usage with cache fields on context", async () => {
|
||||
const inner: LlmProvider = {
|
||||
async complete(): Promise<CompleteResult> {
|
||||
return {
|
||||
content: "ok",
|
||||
model: "test-model",
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
cachedTokens: 8,
|
||||
cacheMissTokens: 2,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const ctx: LlmTrackingContext = { sessionId: "sess-1" };
|
||||
const provider = new TokenTrackingProvider(inner, () => ctx);
|
||||
await provider.complete([], { caller: "worker:write-rules" });
|
||||
|
||||
expect(ctx.pendingUsage).toMatchObject({
|
||||
totalTokens: 15,
|
||||
cachedTokens: 8,
|
||||
cacheMissTokens: 2,
|
||||
caller: "worker:write-rules",
|
||||
model: "test-model",
|
||||
});
|
||||
expect(ctx.pendingUsage?.recordId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stores pending reasoning when usage is absent", async () => {
|
||||
const inner: LlmProvider = {
|
||||
async complete(): Promise<CompleteResult> {
|
||||
return {
|
||||
content: "ok",
|
||||
model: "test-model",
|
||||
reasoning: " chain of thought ",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const ctx: LlmTrackingContext = { sessionId: "sess-2" };
|
||||
const provider = new TokenTrackingProvider(inner, () => ctx);
|
||||
await provider.complete([], { caller: "worker:test" });
|
||||
|
||||
expect(ctx.pendingUsage).toBeUndefined();
|
||||
expect(ctx.pendingReasoning).toBe("chain of thought");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeTokenUsage", () => {
|
||||
it("aggregates cache totals in byCallerDetailed", () => {
|
||||
const sessionId = `test-${Date.now()}`;
|
||||
recordTokenUsage({
|
||||
sessionId,
|
||||
caller: "main_agent",
|
||||
model: "m",
|
||||
promptTokens: 100,
|
||||
completionTokens: 10,
|
||||
totalTokens: 110,
|
||||
cachedTokens: 50,
|
||||
});
|
||||
recordTokenUsage({
|
||||
sessionId,
|
||||
caller: "worker:write-rules",
|
||||
model: "m",
|
||||
promptTokens: 200,
|
||||
completionTokens: 20,
|
||||
totalTokens: 220,
|
||||
cachedTokens: 100,
|
||||
cacheMissTokens: 100,
|
||||
});
|
||||
|
||||
const updated = summarizeTokenUsage({ sessionId, limit: 10 });
|
||||
expect(updated.totalCalls).toBe(2);
|
||||
expect(updated.totalCached).toBe(150);
|
||||
expect(updated.totalCacheMiss).toBe(100);
|
||||
expect(updated.byCallerDetailed["main_agent"]).toMatchObject({
|
||||
totalTokens: 110,
|
||||
cachedTokens: 50,
|
||||
calls: 1,
|
||||
});
|
||||
expect(updated.byCallerDetailed["worker:write-rules"]).toMatchObject({
|
||||
totalTokens: 220,
|
||||
cachedTokens: 100,
|
||||
cacheMissTokens: 100,
|
||||
calls: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
66
tests/tool-loop.test.ts
Normal file
66
tests/tool-loop.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMockMainAgentResponse,
|
||||
createMockToolCall,
|
||||
MockLlmProvider,
|
||||
} from "../src/llm/client.js";
|
||||
import { runMainAgentToolLoop } from "../src/main-agent/tool-loop.js";
|
||||
import { toolCallToDecision } from "../src/runtime/tool-registry.js";
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
|
||||
describe("main agent tool loop", () => {
|
||||
const baseContext = {
|
||||
session: createSession("default"),
|
||||
blackboardIndex: [{ id: "1", tag: "book.brief", source: "user" }],
|
||||
availableWorkers: [{ id: "outline", description: "生成大纲" }],
|
||||
};
|
||||
|
||||
const handlers = {
|
||||
readBlackboard: (tags: string[]) =>
|
||||
Object.fromEntries(tags.map((t) => [t, t === "book.brief" ? "科幻中篇" : ""])),
|
||||
listWorkers: () => [{ id: "outline", description: "生成大纲" }],
|
||||
listArtifacts: () => [],
|
||||
};
|
||||
|
||||
it("runs loop tools then returns terminal decision", async () => {
|
||||
const llm = new MockLlmProvider([
|
||||
createMockToolCall("read_blackboard", { tags: ["book.brief"] }),
|
||||
createMockToolCall("run_worker", {
|
||||
workerId: "outline",
|
||||
reason: "需求已齐,生成大纲",
|
||||
requiresApproval: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await runMainAgentToolLoop(llm, baseContext, handlers);
|
||||
expect(result.iterations).toBe(2);
|
||||
expect(result.toolTrace).toEqual(["read_blackboard", "run_worker (terminal)"]);
|
||||
expect(result.decision.action).toBe("run_worker");
|
||||
expect(result.decision.workerId).toBe("outline");
|
||||
expect(result.decision.requiresApproval).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to JSON content when no tool calls", async () => {
|
||||
const llm = new MockLlmProvider([
|
||||
createMockMainAgentResponse({
|
||||
action: "ask_user",
|
||||
reason: "请补充篇幅",
|
||||
requiresApproval: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await runMainAgentToolLoop(llm, baseContext, handlers);
|
||||
expect(result.decision.action).toBe("ask_user");
|
||||
expect(result.decision.reason).toContain("篇幅");
|
||||
});
|
||||
|
||||
it("maps finish tool to decision", () => {
|
||||
const decision = toolCallToDecision({
|
||||
id: "c1",
|
||||
name: "finish",
|
||||
arguments: JSON.stringify({ reason: "完成" }),
|
||||
});
|
||||
expect(decision.action).toBe("finish");
|
||||
expect(decision.reason).toBe("完成");
|
||||
});
|
||||
});
|
||||
50
tests/worker-inputs.test.ts
Normal file
50
tests/worker-inputs.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import { gatherWorkerInputs } from "../src/worker/executor.js";
|
||||
|
||||
describe("worker input gathering", () => {
|
||||
it("merges userInputs fallback when demand tag missing from blackboard", () => {
|
||||
const blackboard = new Blackboard();
|
||||
const inputs = gatherWorkerInputs(
|
||||
["用户.博弈需求", "用户.worker答复"],
|
||||
"latest",
|
||||
{
|
||||
userInputs: ["德州扑克", "需要思考标签"],
|
||||
},
|
||||
blackboard,
|
||||
);
|
||||
expect(inputs["用户.博弈需求"]).toBe("德州扑克\n\n需要思考标签");
|
||||
});
|
||||
|
||||
it("prefers blackboard and slots over userInputs fallback", () => {
|
||||
const blackboard = new Blackboard();
|
||||
blackboard.write({
|
||||
tag: "用户.博弈需求",
|
||||
content: "来自黑板",
|
||||
source: "user",
|
||||
});
|
||||
const inputs = gatherWorkerInputs(
|
||||
["用户.博弈需求"],
|
||||
"latest",
|
||||
{
|
||||
"用户.博弈需求": "来自 slots",
|
||||
userInputs: ["旧输入"],
|
||||
},
|
||||
blackboard,
|
||||
);
|
||||
expect(inputs["用户.博弈需求"]).toBe("来自 slots");
|
||||
});
|
||||
|
||||
it("falls back to userInputs transcript when demand slot empty", () => {
|
||||
const blackboard = new Blackboard();
|
||||
const longMsg =
|
||||
"1.德州扑克 2.角色:赌徒、职业玩家 3.终止:有人破产 4.规则:标准德州 5.要看思考";
|
||||
const inputs = gatherWorkerInputs(
|
||||
["用户.博弈需求"],
|
||||
"latest",
|
||||
{ userInputs: [longMsg] },
|
||||
blackboard,
|
||||
);
|
||||
expect(inputs["用户.博弈需求"]).toBe(longMsg);
|
||||
});
|
||||
});
|
||||
100
tests/worker-llm.test.ts
Normal file
100
tests/worker-llm.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterInputsForRolePerspective,
|
||||
isRoleUserOnlyTag,
|
||||
} from "../src/skills/worker-llm.js";
|
||||
import type { ParsedWorkerSkill } from "../src/skills/types.js";
|
||||
import { resolveWorkerLlmProvider } from "../src/skills/worker-llm.js";
|
||||
import { MockLlmProvider } from "../src/llm/client.js";
|
||||
|
||||
describe("filterInputsForRolePerspective", () => {
|
||||
it("keeps only current role tags when 世界.当前角色.id is set", () => {
|
||||
const inputs = {
|
||||
"世界.当前角色.id": "A",
|
||||
"角色.A.可见信息": "hand A",
|
||||
"角色.B.可见信息": "hand B",
|
||||
"场景.公开叙述": "public",
|
||||
};
|
||||
const filtered = filterInputsForRolePerspective(inputs, {
|
||||
"世界.当前角色.id": "A",
|
||||
});
|
||||
expect(filtered["角色.A.可见信息"]).toBe("hand A");
|
||||
expect(filtered["角色.B.可见信息"]).toBeUndefined();
|
||||
expect(filtered["场景.公开叙述"]).toBe("public");
|
||||
});
|
||||
|
||||
it("strips thinking tags even for current role", () => {
|
||||
const inputs = {
|
||||
"角色.A.可见信息": "hand A",
|
||||
"角色.A.思考": "inner monologue",
|
||||
"角色.A.推理.候选": "legacy thinking",
|
||||
"角色.B.行动": "should not see",
|
||||
};
|
||||
const filtered = filterInputsForRolePerspective(inputs, {
|
||||
"世界.当前角色.id": "A",
|
||||
});
|
||||
expect(filtered["角色.A.可见信息"]).toBe("hand A");
|
||||
expect(filtered["角色.A.思考"]).toBeUndefined();
|
||||
expect(filtered["角色.A.推理.候选"]).toBeUndefined();
|
||||
expect(filtered["角色.B.行动"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("isRoleUserOnlyTag detects thinking suffixes", () => {
|
||||
expect(isRoleUserOnlyTag("角色.A.思考")).toBe(true);
|
||||
expect(isRoleUserOnlyTag("角色.A.推理.候选")).toBe(true);
|
||||
expect(isRoleUserOnlyTag("角色.A.行动")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkerLlmProvider", () => {
|
||||
const fallback = new MockLlmProvider([]);
|
||||
|
||||
it("returns fallback when no binding", () => {
|
||||
const worker: ParsedWorkerSkill = {
|
||||
id: "role-decide",
|
||||
skill: "roleplay-game-theory",
|
||||
name: "x",
|
||||
description: "",
|
||||
version: 1,
|
||||
inputTags: [],
|
||||
outputTags: [],
|
||||
path: "",
|
||||
body: "",
|
||||
};
|
||||
expect(
|
||||
resolveWorkerLlmProvider({
|
||||
worker,
|
||||
slots: { "世界.当前角色.id": "A" },
|
||||
fallbackLlm: fallback,
|
||||
}),
|
||||
).toBe(fallback);
|
||||
});
|
||||
|
||||
it("uses byRole when bindings match", () => {
|
||||
const worker: ParsedWorkerSkill = {
|
||||
id: "role-decide",
|
||||
skill: "roleplay-game-theory",
|
||||
name: "x",
|
||||
description: "",
|
||||
version: 1,
|
||||
inputTags: [],
|
||||
outputTags: [],
|
||||
path: "",
|
||||
body: "",
|
||||
};
|
||||
const resolved = resolveWorkerLlmProvider({
|
||||
worker,
|
||||
bindings: {
|
||||
workers: {
|
||||
"role-decide": {
|
||||
byRole: { A: "nonexistent-profile-id" },
|
||||
},
|
||||
},
|
||||
},
|
||||
slots: { "世界.当前角色.id": "A" },
|
||||
fallbackLlm: fallback,
|
||||
});
|
||||
// profile 不存在时回退 fallback
|
||||
expect(resolved).toBe(fallback);
|
||||
});
|
||||
});
|
||||
10
tests/worker-resolve.test.ts
Normal file
10
tests/worker-resolve.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWorkerId } from "../src/worker/resolve-id.js";
|
||||
|
||||
describe("resolveWorkerId", () => {
|
||||
it("maps legacy ids to skill worker ids", () => {
|
||||
expect(resolveWorkerId("rules-worker")).toBe("write-rules");
|
||||
expect(resolveWorkerId("outline-worker")).toBe("outline");
|
||||
expect(resolveWorkerId("write-rules")).toBe("write-rules");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user