Initial commit

This commit is contained in:
2026-07-10 08:31:27 +08:00
commit 2b74c30d36
134 changed files with 21801 additions and 0 deletions

151
tests/agent-view.test.ts Normal file
View 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
View 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
View 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
View 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("值-题材");
});
});

View 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.tsPhaseRuntime
// 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
View 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);
});
});

View 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
View 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
View 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
View 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);
});
});

View 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);
});
});

View 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
View 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
View 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("完成");
});
});

View 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
View 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);
});
});

View 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");
});
});