重构世界模拟器为模块化配方架构,完善创作编排、会话运行时与 Web UI,并清理过时技能。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,151 +1,226 @@
|
||||
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();
|
||||
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("工具 ·");
|
||||
expect(m.title).toContain("invoke_worker");
|
||||
expect(m.body).toBe("write-rules");
|
||||
});
|
||||
|
||||
it("parses worker output", () => {
|
||||
const m = classifyAgentMessage("[Worker] design-core 已完成\n\n### rules\n\n1. foo");
|
||||
expect(m.kind).toBe("worker_output");
|
||||
expect(m.actor).toBe("design-core");
|
||||
expect(m.title).toBe("创作 · 核心 · 产出");
|
||||
expect(m.body).toContain("1. foo");
|
||||
});
|
||||
|
||||
it("parses worker questions", () => {
|
||||
const m = classifyAgentMessage(
|
||||
"[Worker] design-core 提问:\n- 请提供玩家人数",
|
||||
);
|
||||
expect(m.kind).toBe("worker_questions");
|
||||
expect(m.actor).toBe("design-core");
|
||||
expect(m.title).toBe("创作 · 核心 · 提问");
|
||||
expect(m.body).toContain("请提供玩家人数");
|
||||
});
|
||||
|
||||
it("parses agent assessment and ask prompts", () => {
|
||||
const a = classifyAgentMessage(
|
||||
"[Agent] 内容评价:\n核心感觉: 完备度 40%\n 已知: 权力幻想",
|
||||
);
|
||||
expect(a.kind).toBe("orchestrator_assessment");
|
||||
expect(a.title).toContain("内容评价");
|
||||
expect(a.body).toContain("完备度 40%");
|
||||
|
||||
const q = classifyAgentMessage("[Agent] 提问:\n- 你更倾向于哪种享受?");
|
||||
expect(q.kind).toBe("worker_questions");
|
||||
expect(q.actor).toBe("orchestrator");
|
||||
expect(q.body).toContain("哪种享受");
|
||||
|
||||
const opt = classifyAgentMessage(
|
||||
"[Agent] 可选追问(可跳过):\n- 还想补感官吗?",
|
||||
);
|
||||
expect(opt.kind).toBe("worker_questions");
|
||||
expect(opt.title).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("stays in design until worker set accepted", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
slots: { ...createSession().slots, startupCompleted: true },
|
||||
};
|
||||
expect(inferLifecycleStage(session)).toBe("design");
|
||||
expect(canEnterPlay(session)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows play only after accepted worker set", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
slots: { ...createSession().slots, designInstanceReady: true },
|
||||
};
|
||||
expect(canEnterPlay(session)).toBe(true);
|
||||
expect(inferLifecycleStage(session)).toBe("design");
|
||||
});
|
||||
|
||||
it("respects uiLifecycleStage play when instance ready", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
slots: {
|
||||
...createSession().slots,
|
||||
uiLifecycleStage: "play",
|
||||
designInstanceReady: true,
|
||||
},
|
||||
};
|
||||
expect(inferLifecycleStage(session)).toBe("play");
|
||||
});
|
||||
|
||||
it("blocks play override when instance not ready", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
slots: { ...createSession().slots, uiLifecycleStage: "play" },
|
||||
};
|
||||
expect(inferLifecycleStage(session)).toBe("design");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSkillCatalog", () => {
|
||||
it("lists design skills for world-simulator from worker set scope", () => {
|
||||
const session = createSession();
|
||||
const yaml = `
|
||||
form_summary: 测试
|
||||
workers:
|
||||
- ref: narrator
|
||||
duty: 展示
|
||||
instantiate_hints:
|
||||
invoke: [narrative-guide]
|
||||
`;
|
||||
const catalog = buildSkillCatalog(session, "world-simulator", "design", {
|
||||
workerSetYaml: yaml,
|
||||
});
|
||||
expect(catalog.some((s) => s.id === "design-flow")).toBe(true);
|
||||
expect(catalog.some((s) => s.id === "design-refine")).toBe(false);
|
||||
expect(catalog.some((s) => s.id === "narrative-guide")).toBe(true);
|
||||
expect(catalog.some((s) => s.id === "declare-ready")).toBe(true);
|
||||
expect(catalog.every((s) => s.stage === "design")).toBe(true);
|
||||
expect(catalog.find((s) => s.id === "world-blueprint")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lists run workers in play lifecycle from worker set", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
slots: { ...createSession().slots, designInstanceReady: true },
|
||||
};
|
||||
const yaml = `
|
||||
workers:
|
||||
- ref: world-simulator
|
||||
- ref: narrator
|
||||
`;
|
||||
const catalog = buildSkillCatalog(session, "world-simulator", "play", {
|
||||
workerSetYaml: yaml,
|
||||
});
|
||||
expect(catalog.some((s) => s.id === "agent-burst")).toBe(true);
|
||||
expect(catalog.some((s) => s.id === "world-simulator")).toBe(true);
|
||||
expect(catalog.some((s) => s.id === "narrator")).toBe(true);
|
||||
});
|
||||
|
||||
it("lists design step skills when no worker set yet", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
waitingReason: { kind: "input" as const, message: "描述需求" },
|
||||
};
|
||||
const catalog = buildSkillCatalog(session, "world-simulator", "design");
|
||||
expect(catalog.some((s) => s.id === "design-flow")).toBe(true);
|
||||
expect(catalog.some((s) => s.id === "design-step")).toBe(true);
|
||||
expect(catalog.some((s) => s.id === "design-core")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildToolTrace", () => {
|
||||
it("collects agent_tool messages since last user input", () => {
|
||||
const trace = buildToolTrace([
|
||||
{
|
||||
kind: "user_input",
|
||||
text: "hello",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
kind: "agent_tool",
|
||||
text: "[总管 tool] read_blackboard: tags=3",
|
||||
createdAt: "2026-01-01T00:00:01Z",
|
||||
},
|
||||
]);
|
||||
expect(trace).toHaveLength(1);
|
||||
expect(trace[0].name).toBe("read_blackboard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFocus", () => {
|
||||
it("focuses user on input wait", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
phase: "waiting_user" as const,
|
||||
waitingReason: { kind: "input" as const, message: "请描述场景" },
|
||||
};
|
||||
const focus = buildFocus(session, session.waitingReason);
|
||||
expect(focus.actorType).toBe("user");
|
||||
});
|
||||
|
||||
it("focuses user on worker questions", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
phase: "waiting_user" as const,
|
||||
waitingReason: {
|
||||
kind: "worker_questions" as const,
|
||||
workerId: "design-core",
|
||||
questions: [{ id: "q1", prompt: "请提供玩家人数" }],
|
||||
},
|
||||
};
|
||||
const focus = buildFocus(session, session.waitingReason);
|
||||
expect(focus.actorType).toBe("user");
|
||||
expect(focus.action).toContain("创作 · 核心");
|
||||
expect(focus.detail).toContain("请提供玩家人数");
|
||||
});
|
||||
|
||||
it("shows design burst when running in design lifecycle", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
phase: "running" as const,
|
||||
};
|
||||
const focus = buildFocus(session, undefined, undefined, "design");
|
||||
expect(focus.actorLabel).toBe("总管");
|
||||
expect(focus.action).toContain("创作");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,4 +17,31 @@ describe("book store", () => {
|
||||
expect(getBook(book.id)?.title).toBe("测试之书");
|
||||
expect(listBooks().some((b) => b.id === book.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("duplicates book with session data", async () => {
|
||||
const { createBook, duplicateBook, getBook, listBooks } = await import("../src/book/store.js");
|
||||
const { saveBookSession } = await import("../src/book/session-store.js");
|
||||
const source = createBook({ title: "源作品" });
|
||||
saveBookSession({
|
||||
version: 1,
|
||||
sessionId: "sess-1",
|
||||
bookId: source.id,
|
||||
runtimeSession: {
|
||||
id: "sess-1",
|
||||
phase: "idle",
|
||||
presetId: "default",
|
||||
slots: {},
|
||||
artifacts: [],
|
||||
history: [],
|
||||
},
|
||||
blackboardItems: [],
|
||||
messages: [],
|
||||
savedAt: new Date().toISOString(),
|
||||
});
|
||||
const copy = duplicateBook(source.id, "备份作品");
|
||||
expect(copy.id).not.toBe(source.id);
|
||||
expect(copy.title).toBe("备份作品");
|
||||
expect(listBooks().some((b) => b.id === copy.id)).toBe(true);
|
||||
expect(getBook(copy.id)?.title).toBe("备份作品");
|
||||
});
|
||||
});
|
||||
|
||||
54
tests/compress-after-worker.test.ts
Normal file
54
tests/compress-after-worker.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import {
|
||||
CONTEXT_BRIEF_TAG,
|
||||
buildBoardPanel,
|
||||
compressAfterWorkerAccept,
|
||||
} from "../src/runtime/compress-after-worker.js";
|
||||
|
||||
describe("compressAfterWorkerAccept", () => {
|
||||
it("keeps finals, archives process tags, writes brief", () => {
|
||||
const bb = new Blackboard();
|
||||
bb.write({
|
||||
tag: "设计.worker集.草稿",
|
||||
content: '{ "workers": [] }',
|
||||
source: "design-core",
|
||||
});
|
||||
bb.write({
|
||||
tag: "设计.worker集",
|
||||
content: '{ "workers": [{ "ref": "narrator" }] }',
|
||||
source: "design-core",
|
||||
});
|
||||
bb.write({
|
||||
tag: "用户.worker答复",
|
||||
content: "过程问答……",
|
||||
source: "user",
|
||||
});
|
||||
|
||||
const result = compressAfterWorkerAccept({
|
||||
blackboard: bb,
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集", "用户.需求"],
|
||||
summary: "单角代入 + 叙事",
|
||||
});
|
||||
|
||||
expect(result.finals.map((f) => f.tag)).toContain("设计.worker集");
|
||||
expect(result.archivedTags).toEqual(
|
||||
expect.arrayContaining(["用户.worker答复", "设计.worker集.草稿"]),
|
||||
);
|
||||
expect(bb.getContentByTag(CONTEXT_BRIEF_TAG)).toContain("设计.worker集");
|
||||
expect(bb.getContentByTag(CONTEXT_BRIEF_TAG)).toContain("单角代入");
|
||||
|
||||
const draftHits = bb.queryByPatterns(["设计.worker集.草稿"]);
|
||||
expect(draftHits).toHaveLength(0);
|
||||
|
||||
const finalHits = bb.queryByPatterns(["设计.worker集"]);
|
||||
expect(finalHits).toHaveLength(1);
|
||||
expect(finalHits[0].metadata?.role).toBe("final");
|
||||
|
||||
const panel = buildBoardPanel(bb);
|
||||
expect(panel.finals.some((f) => f.tag === "设计.worker集")).toBe(true);
|
||||
expect(panel.archivedCount).toBeGreaterThanOrEqual(1);
|
||||
expect(panel.brief).toContain("定稿");
|
||||
});
|
||||
});
|
||||
136
tests/context-assembly.test.ts
Normal file
136
tests/context-assembly.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import {
|
||||
assembleWorkerContext,
|
||||
parseContextSegments,
|
||||
} from "../src/skills/context-segments.js";
|
||||
import {
|
||||
CREATION_ACCEPTED_CONTENT_TAG,
|
||||
extractUnitContentFromDraft,
|
||||
formatAcceptedContentForPrompt,
|
||||
upsertAcceptedUnitContent,
|
||||
} from "../src/skills/creation-units.js";
|
||||
import type { ParsedWorkerSet } from "../src/skills/worker-set-parse.js";
|
||||
import { loadWorkerSkill } from "../src/skills/loader.js";
|
||||
|
||||
describe("contextSegments assembly", () => {
|
||||
it("parses segments from frontmatter shape", () => {
|
||||
const segs = parseContextSegments([
|
||||
{ id: "a", tier: "static", tags: ["创作.已验收内容"], label: "## 前情" },
|
||||
{ id: "b", tier: "dynamic", tags: ["用户.需求"], policy: "latest" },
|
||||
]);
|
||||
expect(segs).toHaveLength(2);
|
||||
expect(segs[0]?.label).toContain("前情");
|
||||
});
|
||||
|
||||
it("assembles markdown with labels when segments present", () => {
|
||||
const bb = new Blackboard();
|
||||
bb.write({
|
||||
tag: CREATION_ACCEPTED_CONTENT_TAG,
|
||||
content: upsertAcceptedUnitContent(null, {
|
||||
unitId: "phase:core",
|
||||
content: { interaction: { user_stance: "单角" } },
|
||||
summary: "单位 phase:core · 核心",
|
||||
acceptedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
source: "test",
|
||||
});
|
||||
bb.write({ tag: "用户.需求", content: "网恋对话", source: "test" });
|
||||
bb.write({ tag: "创作.当前单位", content: "worker:narrator", source: "test" });
|
||||
|
||||
const text = assembleWorkerContext({
|
||||
inputs: {
|
||||
[CREATION_ACCEPTED_CONTENT_TAG]: bb.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG)!,
|
||||
"用户.需求": "网恋对话",
|
||||
"创作.当前单位": "worker:narrator",
|
||||
},
|
||||
segments: [
|
||||
{
|
||||
id: "prior",
|
||||
tier: "static",
|
||||
tags: [CREATION_ACCEPTED_CONTENT_TAG],
|
||||
label: "## 【前情提要 · 已定稿】",
|
||||
},
|
||||
{
|
||||
id: "unit",
|
||||
tier: "static",
|
||||
tags: ["创作.当前单位"],
|
||||
label: "## 【本单位】",
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
tier: "dynamic",
|
||||
tags: ["用户.需求"],
|
||||
label: "## 用户表述",
|
||||
},
|
||||
],
|
||||
blackboard: bb,
|
||||
workerId: "design-worker",
|
||||
workerName: "B",
|
||||
outputTags: ["设计.worker集.草稿"],
|
||||
});
|
||||
|
||||
expect(text).toContain("【前情提要 · 已定稿】");
|
||||
expect(text).toContain("phase:core");
|
||||
expect(text).toContain("只读");
|
||||
expect(text).toContain("【本单位】");
|
||||
expect(text).toContain("worker:narrator");
|
||||
expect(text).toContain("网恋对话");
|
||||
expect(text).not.toMatch(/^\s*\{/);
|
||||
});
|
||||
|
||||
it("falls back to JSON inputs without segments", () => {
|
||||
const bb = new Blackboard();
|
||||
const text = assembleWorkerContext({
|
||||
inputs: { "用户.需求": "x" },
|
||||
segments: [],
|
||||
blackboard: bb,
|
||||
workerId: "x",
|
||||
workerName: "x",
|
||||
outputTags: [],
|
||||
});
|
||||
expect(JSON.parse(text).inputs["用户.需求"]).toBe("x");
|
||||
});
|
||||
|
||||
it("design-flow skill loads contextSegments", async () => {
|
||||
const w = await loadWorkerSkill("world-simulator", "design-flow");
|
||||
expect(w.contextSegments?.length).toBeGreaterThan(0);
|
||||
expect(w.outputTags).toContain("设计.创作流程");
|
||||
});
|
||||
});
|
||||
|
||||
describe("accepted unit content", () => {
|
||||
it("extracts worker slice and upserts last accept", () => {
|
||||
const parsed: ParsedWorkerSet = {
|
||||
interaction: {
|
||||
user_stance: "单角",
|
||||
system_role: "世界",
|
||||
output: "叙事",
|
||||
},
|
||||
workers: [
|
||||
{ ref: "narrator", duty: "转述", rationale: "可读", acceptance: "review" },
|
||||
],
|
||||
};
|
||||
expect(extractUnitContentFromDraft(parsed, "phase:core")).toMatchObject({
|
||||
interaction: { user_stance: "单角" },
|
||||
});
|
||||
expect(extractUnitContentFromDraft(parsed, "worker:narrator")).toMatchObject({
|
||||
ref: "narrator",
|
||||
duty: "转述",
|
||||
});
|
||||
|
||||
const v1 = upsertAcceptedUnitContent(null, {
|
||||
unitId: "worker:narrator",
|
||||
content: { ref: "narrator", duty: "旧" },
|
||||
acceptedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const v2 = upsertAcceptedUnitContent(v1, {
|
||||
unitId: "worker:narrator",
|
||||
content: { ref: "narrator", duty: "新" },
|
||||
acceptedAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
const prompt = formatAcceptedContentForPrompt(v2);
|
||||
expect(prompt).toContain("新");
|
||||
expect(prompt).not.toContain('"duty": "旧"');
|
||||
});
|
||||
});
|
||||
73
tests/context-trace.test.ts
Normal file
73
tests/context-trace.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildContextTrace,
|
||||
pruneContextTraces,
|
||||
} from "../src/types/context-trace.js";
|
||||
|
||||
function msg(id: string, withTrace = false) {
|
||||
return {
|
||||
id,
|
||||
...(withTrace
|
||||
? {
|
||||
contextTrace: buildContextTrace({
|
||||
caller: `c-${id}`,
|
||||
messages: [{ role: "user", content: `hello-${id}` }],
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("pruneContextTraces", () => {
|
||||
it("keeps only the latest N traces", () => {
|
||||
const messages = [
|
||||
msg("1", true),
|
||||
msg("2"),
|
||||
msg("3", true),
|
||||
msg("4", true),
|
||||
msg("5", true),
|
||||
msg("6", true),
|
||||
];
|
||||
const pruned = pruneContextTraces(messages, 2);
|
||||
expect(pruned.map((m) => Boolean(m.contextTrace))).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(pruned[4].id).toBe("5");
|
||||
expect(pruned[5].contextTrace?.caller).toBe("c-6");
|
||||
});
|
||||
|
||||
it("clears all when keepLatest is 0", () => {
|
||||
const messages = [msg("a", true), msg("b", true)];
|
||||
const pruned = pruneContextTraces(messages, 0);
|
||||
expect(pruned.every((m) => !m.contextTrace)).toBe(true);
|
||||
expect(pruned.map((m) => m.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("is a no-op when under the limit", () => {
|
||||
const messages = [msg("1", true), msg("2", true)];
|
||||
const pruned = pruneContextTraces(messages, 5);
|
||||
expect(pruned).toBe(messages);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildContextTrace", () => {
|
||||
it("counts characters and copies messages", () => {
|
||||
const trace = buildContextTrace({
|
||||
caller: "worker:x",
|
||||
model: "m",
|
||||
messages: [
|
||||
{ role: "system", content: "abc" },
|
||||
{ role: "user", content: "de" },
|
||||
],
|
||||
});
|
||||
expect(trace.charCount).toBe(5);
|
||||
expect(trace.caller).toBe("worker:x");
|
||||
expect(trace.model).toBe("m");
|
||||
expect(trace.messages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
396
tests/creation-flow.test.ts
Normal file
396
tests/creation-flow.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 创作流程 parse / validate / catalog / recipes
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
artifactTagForStep,
|
||||
extractModuleOpening,
|
||||
formatCreationFlowForUser,
|
||||
formatModuleCatalogForAgent,
|
||||
formatRecipeCatalogForAgent,
|
||||
formatSelectedRecipeForAgent,
|
||||
isCreationFlowComplete,
|
||||
loadAllRecipeDetails,
|
||||
loadModuleCatalog,
|
||||
loadModulePrompt,
|
||||
loadRecipeCatalog,
|
||||
needsFlowExpansion,
|
||||
nextPendingStep,
|
||||
parseCreationFlow,
|
||||
parseModuleCatalog,
|
||||
parseModuleOpeningState,
|
||||
parseRecipeCatalog,
|
||||
parseRecipeYaml,
|
||||
parseSelectedRecipeRef,
|
||||
resolveDesignStepBinding,
|
||||
validateCreationFlow,
|
||||
} from "../src/skills/creation-flow.js";
|
||||
import { loadWorkerSkillWithContext } from "../src/skills/loader.js";
|
||||
|
||||
const sampleCatalog = parseModuleCatalog(`
|
||||
modules:
|
||||
- name: 美学纲领与交互范式
|
||||
declaration: 站位与体验契约
|
||||
artifact: 设计.美学纲领与交互范式
|
||||
- name: 实现机制
|
||||
declaration: worker 与表
|
||||
artifact: 设计.实现机制
|
||||
- name: 生成规则
|
||||
declaration: 可执行规则
|
||||
artifact: 设计.生成规则
|
||||
repeatable: true
|
||||
- name: 具体实例
|
||||
declaration: 锚定实例
|
||||
artifact: 设计.具体实例
|
||||
repeatable: true
|
||||
`)!;
|
||||
|
||||
describe("creation-flow", () => {
|
||||
it("parses minimal flow JSON and fills step ids", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"brief": "单角代入",
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] }
|
||||
]
|
||||
}`);
|
||||
expect(flow).toEqual({
|
||||
version: 1,
|
||||
brief: "单角代入",
|
||||
status: undefined,
|
||||
steps: [
|
||||
{
|
||||
id: "美学纲领与交互范式",
|
||||
name: "美学纲领与交互范式",
|
||||
depends_on: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("allows duplicate capability names with distinct ids", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"status": "open",
|
||||
"steps": [
|
||||
{ "id": "生成规则", "name": "生成规则", "depends_on": [] },
|
||||
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] },
|
||||
{ "id": "具体实例", "name": "具体实例", "depends_on": ["生成规则#2"] }
|
||||
]
|
||||
}`)!;
|
||||
expect(flow.status).toBe("open");
|
||||
expect(flow.steps.map((s) => s.id)).toEqual([
|
||||
"生成规则",
|
||||
"生成规则#2",
|
||||
"具体实例",
|
||||
]);
|
||||
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-numbers duplicate names when id omitted", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [
|
||||
{ "name": "生成规则", "depends_on": [] },
|
||||
{ "name": "生成规则", "depends_on": ["生成规则"] }
|
||||
]
|
||||
}`)!;
|
||||
expect(flow.steps[0]?.id).toBe("生成规则");
|
||||
expect(flow.steps[1]?.id).toBe("生成规则#2");
|
||||
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts JSON from surrounding text", () => {
|
||||
const flow = parseCreationFlow(
|
||||
`说明如下\n{"steps":[{"name":"美学纲领与交互范式","depends_on":[]}]}\n完`,
|
||||
);
|
||||
expect(flow?.steps[0]?.name).toBe("美学纲领与交互范式");
|
||||
});
|
||||
|
||||
it("validates deps must appear earlier", () => {
|
||||
const bad = parseCreationFlow(`{
|
||||
"steps": [
|
||||
{ "name": "实现机制", "depends_on": ["美学纲领与交互范式"] },
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] }
|
||||
]
|
||||
}`)!;
|
||||
const result = validateCreationFlow(bad, sampleCatalog);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("未排在其前面"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown module names when catalog given", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [{ "name": "不存在的工序", "depends_on": [] }]
|
||||
}`)!;
|
||||
const result = validateCreationFlow(flow, sampleCatalog);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors[0]).toContain("不在模块目录");
|
||||
});
|
||||
|
||||
it("rejects non-repeatable duplicate names", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [
|
||||
{ "name": "实现机制", "depends_on": [] },
|
||||
{ "id": "实现机制#2", "name": "实现机制", "depends_on": ["实现机制"] }
|
||||
]
|
||||
}`)!;
|
||||
const result = validateCreationFlow(flow, sampleCatalog);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("repeatable"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a valid ordered flow", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{ "name": "实现机制", "depends_on": ["美学纲领与交互范式"] }
|
||||
]
|
||||
}`)!;
|
||||
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
|
||||
expect(artifactTagForStep("美学纲领与交互范式", sampleCatalog)).toBe(
|
||||
"设计.美学纲领与交互范式",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats user view without English tags", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"brief": "网恋回合",
|
||||
"status": "open",
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
|
||||
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }
|
||||
]
|
||||
}`)!;
|
||||
const view = formatCreationFlowForUser(flow, sampleCatalog);
|
||||
expect(view.brief).toBe("网恋回合");
|
||||
expect(view.status).toBe("open");
|
||||
expect(view.steps[0]).toMatchObject({
|
||||
order: 1,
|
||||
name: "美学纲领与交互范式",
|
||||
depends_on: [],
|
||||
declaration: "站位与体验契约",
|
||||
});
|
||||
expect(view.steps[2]?.occurrence).toBe(2);
|
||||
expect(view.steps[2]?.repeatable).toBe(true);
|
||||
});
|
||||
|
||||
it("loads world-simulator catalog and formats for agent", async () => {
|
||||
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||
expect(catalog?.modules.some((m) => m.name === "美学纲领与交互范式")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(catalog?.modules.some((m) => m.name === "交互范式")).toBe(false);
|
||||
expect(catalog?.modules.some((m) => m.name === "美学纲领")).toBe(false);
|
||||
expect(
|
||||
catalog?.modules.find((m) => m.name === "生成规则")?.repeatable,
|
||||
).toBe(true);
|
||||
expect(
|
||||
catalog?.modules.find((m) => m.name === "具体实例")?.repeatable,
|
||||
).toBe(true);
|
||||
const block = formatModuleCatalogForAgent(catalog!);
|
||||
expect(block).toContain("【能力");
|
||||
expect(block).toContain("美学纲领与交互范式:");
|
||||
expect(block).toContain("生成规则〔可反复〕");
|
||||
expect(block).not.toContain("设计.美学纲领与交互范式");
|
||||
});
|
||||
|
||||
it("loads recipe catalog for user director selection UI", async () => {
|
||||
const recipes = await loadRecipeCatalog("dialogue/world-simulator");
|
||||
expect(recipes?.recipes.some((r) => r.name === "世界模拟器")).toBe(true);
|
||||
expect(recipes?.recipes.some((r) => r.name === "扩写助手")).toBe(true);
|
||||
const block = formatRecipeCatalogForAgent(recipes!);
|
||||
expect(block).toContain("须由用户手动选择");
|
||||
expect(block).toContain("世界模拟器");
|
||||
|
||||
const details = await loadAllRecipeDetails("dialogue/world-simulator");
|
||||
expect(details.length).toBeGreaterThanOrEqual(2);
|
||||
expect(details.find((d) => d.id === "world-simulator")?.when).toBeTruthy();
|
||||
expect(
|
||||
details
|
||||
.find((d) => d.id === "world-simulator")
|
||||
?.seed?.steps.some((s) => s.name === "美学纲领与交互范式"),
|
||||
).toBe(true);
|
||||
expect(details.find((d) => d.id === "world-simulator")?.seed?.status).toBe(
|
||||
"open",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses selected recipe ref", () => {
|
||||
expect(parseSelectedRecipeRef("world-simulator")).toBe("world-simulator");
|
||||
expect(parseSelectedRecipeRef('{"id":"expand-assistant","name":"扩写助手"}')).toBe(
|
||||
"expand-assistant",
|
||||
);
|
||||
expect(parseSelectedRecipeRef("")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses recipe yaml with suggested steps", () => {
|
||||
const detail = parseRecipeYaml(
|
||||
`
|
||||
when: 测试适用
|
||||
hint: 可调味
|
||||
brief: 测试 brief
|
||||
steps:
|
||||
- name: 美学纲领与交互范式
|
||||
depends_on: []
|
||||
`,
|
||||
{ id: "t", name: "测试配方", declaration: "测" },
|
||||
);
|
||||
expect(detail.when).toBe("测试适用");
|
||||
expect(detail.seed?.status).toBe("open");
|
||||
expect(detail.seed?.steps).toEqual([
|
||||
{
|
||||
id: "美学纲领与交互范式",
|
||||
name: "美学纲领与交互范式",
|
||||
depends_on: [],
|
||||
},
|
||||
]);
|
||||
expect(formatSelectedRecipeForAgent(detail)).toContain("用户已选导演");
|
||||
expect(formatSelectedRecipeForAgent(detail)).toContain("增量");
|
||||
});
|
||||
|
||||
it("parseRecipeCatalog skips incomplete rows", () => {
|
||||
const cat = parseRecipeCatalog(`
|
||||
recipes:
|
||||
- id: ok
|
||||
name: 好配方
|
||||
declaration: 有声明
|
||||
- id: bad
|
||||
name: 缺声明
|
||||
`);
|
||||
expect(cat?.recipes).toEqual([
|
||||
{ id: "ok", name: "好配方", declaration: "有声明" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("injects selected director into design-flow; missing selection warns", async () => {
|
||||
const missing = await loadWorkerSkillWithContext(
|
||||
"world-simulator",
|
||||
"design-flow",
|
||||
);
|
||||
expect(missing.promptBody).toContain("用户尚未手动选择");
|
||||
expect(missing.promptBody).toContain("【能力");
|
||||
expect(missing.promptBody).toContain("禁止自行猜测");
|
||||
|
||||
const selected = await loadWorkerSkillWithContext(
|
||||
"world-simulator",
|
||||
"design-flow",
|
||||
undefined,
|
||||
{ selectedRecipeRef: "world-simulator" },
|
||||
);
|
||||
expect(selected.worker.outputTags).toContain("设计.创作流程");
|
||||
expect(selected.promptBody).toContain("【用户已选导演 · 世界模拟器】");
|
||||
expect(selected.promptBody).toContain("世界模拟器");
|
||||
expect(selected.promptBody).toContain("【能力");
|
||||
expect(selected.promptBody).toContain("美学纲领与交互范式");
|
||||
expect(selected.promptBody).toContain("增量");
|
||||
expect(selected.promptBody).not.toContain("【导演】用户尚未手动选择");
|
||||
});
|
||||
|
||||
it("injects aesthetics-interaction module prompt into design-step", async () => {
|
||||
const flowRaw = JSON.stringify({
|
||||
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
|
||||
});
|
||||
const loaded = await loadWorkerSkillWithContext(
|
||||
"world-simulator",
|
||||
"design-step",
|
||||
undefined,
|
||||
{
|
||||
flowRaw,
|
||||
currentStepName: "美学纲领与交互范式",
|
||||
acceptedStepNames: [],
|
||||
},
|
||||
);
|
||||
expect(loaded.worker.outputTags).toContain("设计.美学纲领与交互范式");
|
||||
expect(loaded.promptBody).toContain("美学纲领与交互范式");
|
||||
expect(loaded.promptBody).toContain("体验契约");
|
||||
expect(loaded.promptBody).toContain("程序开场");
|
||||
expect(loaded.worker.inputTags).toContain("创作.能力开场白");
|
||||
});
|
||||
|
||||
it("extracts ```opening default question from module prompt", async () => {
|
||||
const prompt = await loadModulePrompt(
|
||||
"dialogue/world-simulator",
|
||||
"aesthetics-interaction",
|
||||
);
|
||||
expect(prompt).toBeTruthy();
|
||||
const opening = extractModuleOpening(prompt!);
|
||||
expect(opening).toContain("原型世界");
|
||||
expect(opening).toContain("变在哪里");
|
||||
expect(opening).toContain("代入");
|
||||
expect(opening).toContain("最想反复感受到");
|
||||
expect(opening).not.toContain("nail清站位");
|
||||
|
||||
const {
|
||||
parseModulePromptSections,
|
||||
formatModulePromptForLlm,
|
||||
MODULE_SECTION_IDS,
|
||||
} = await import("../src/skills/creation-flow.js");
|
||||
const sections = parseModulePromptSections(prompt!);
|
||||
for (const id of ["meta", "opening", "task", "output", "checklist"] as const) {
|
||||
expect(sections.blocks[id]?.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(MODULE_SECTION_IDS).toContain("opening");
|
||||
|
||||
const forLlm = formatModulePromptForLlm(prompt!);
|
||||
expect(forLlm).toContain("```task");
|
||||
expect(forLlm).not.toContain("原型世界是什么");
|
||||
|
||||
expect(
|
||||
extractModuleOpening("## opening\n\n```opening\nhello world\n```\n"),
|
||||
).toBe("hello world");
|
||||
|
||||
expect(parseModuleOpeningState('{"美学纲领与交互范式":"shown"}')).toEqual({
|
||||
美学纲领与交互范式: "shown",
|
||||
});
|
||||
|
||||
const binding = await resolveDesignStepBinding({
|
||||
skillPackRoot: "dialogue/world-simulator",
|
||||
flowRaw: JSON.stringify({
|
||||
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
|
||||
}),
|
||||
currentStepName: "美学纲领与交互范式",
|
||||
});
|
||||
expect(binding?.opening).toContain("原型世界");
|
||||
expect(binding?.modulePrompt).toContain("```task");
|
||||
expect(binding?.modulePrompt).not.toContain("最想反复感受到的是什么");
|
||||
});
|
||||
|
||||
it("nextPendingStep respects deps and accepted by id", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"status": "open",
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
|
||||
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }
|
||||
]
|
||||
}`)!;
|
||||
expect(nextPendingStep(flow, [])?.id).toBe("美学纲领与交互范式");
|
||||
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe("生成规则");
|
||||
expect(nextPendingStep(flow, ["美学纲领与交互范式", "生成规则"])?.id).toBe(
|
||||
"生成规则#2",
|
||||
);
|
||||
expect(
|
||||
nextPendingStep(flow, ["美学纲领与交互范式", "生成规则", "生成规则#2"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("open flow needs expansion after listed steps accepted", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"status": "open",
|
||||
"steps": [{ "name": "美学纲领与交互范式", "depends_on": [] }]
|
||||
}`)!;
|
||||
expect(isCreationFlowComplete(flow, ["美学纲领与交互范式"])).toBe(false);
|
||||
expect(needsFlowExpansion(flow, ["美学纲领与交互范式"])).toBe(true);
|
||||
|
||||
const closed = parseCreationFlow(`{
|
||||
"status": "closed",
|
||||
"steps": [{ "name": "美学纲领与交互范式", "depends_on": [] }]
|
||||
}`)!;
|
||||
expect(isCreationFlowComplete(closed, ["美学纲领与交互范式"])).toBe(true);
|
||||
expect(needsFlowExpansion(closed, ["美学纲领与交互范式"])).toBe(false);
|
||||
|
||||
const legacy = parseCreationFlow(`{
|
||||
"steps": [{ "name": "美学纲领与交互范式", "depends_on": [] }]
|
||||
}`)!;
|
||||
expect(isCreationFlowComplete(legacy, ["美学纲领与交互范式"])).toBe(true);
|
||||
});
|
||||
});
|
||||
96
tests/declared-worker.test.ts
Normal file
96
tests/declared-worker.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
import {
|
||||
buildDeclaredWorkerSkill,
|
||||
resolveAcceptanceModeForWorker,
|
||||
} from "../src/skills/declared-worker.js";
|
||||
import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js";
|
||||
|
||||
describe("resolveAcceptanceModeForWorker", () => {
|
||||
it("always confirms design-flow", () => {
|
||||
const mode = resolveAcceptanceModeForWorker({
|
||||
session: createSession(),
|
||||
blackboard: new Blackboard(),
|
||||
workerId: "design-flow",
|
||||
});
|
||||
expect(mode).toBe("user_confirmed");
|
||||
});
|
||||
|
||||
it("maps review → user_confirmed and continue → no_confirmation", () => {
|
||||
const bb = new Blackboard();
|
||||
const yaml = JSON.stringify({
|
||||
version: 1,
|
||||
workers: [
|
||||
{ ref: "world-simulator", acceptance: "continue" },
|
||||
{ ref: "narrator", acceptance: "review" },
|
||||
],
|
||||
});
|
||||
bb.write({ tag: "设计.worker集", content: yaml, source: "test" });
|
||||
const session = {
|
||||
...createSession(),
|
||||
slots: {
|
||||
...createSession().slots,
|
||||
designInstanceReady: true,
|
||||
uiLifecycleStage: "play",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveAcceptanceModeForWorker({
|
||||
session,
|
||||
blackboard: bb,
|
||||
workerId: "world-simulator",
|
||||
}),
|
||||
).toBe("no_confirmation");
|
||||
expect(
|
||||
resolveAcceptanceModeForWorker({
|
||||
session,
|
||||
blackboard: bb,
|
||||
workerId: "narrator",
|
||||
}),
|
||||
).toBe("user_confirmed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDeclaredWorkerSkill", () => {
|
||||
it("builds runnable contract from entry + template", () => {
|
||||
const parsed = parseWorkerSetYaml(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
narrative_guide: "残酷但不虐主",
|
||||
core_premises: ["主角免疫"],
|
||||
workers: [
|
||||
{
|
||||
ref: "narrator",
|
||||
duty: "组装用户可见回复",
|
||||
rationale: "需要可读终稿",
|
||||
acceptance: "review",
|
||||
outputs: ["输出.用户展示"],
|
||||
context: {
|
||||
static: ["设计.worker集"],
|
||||
dynamic: ["运行.本轮.裁决"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const entry = parsed!.workers[0];
|
||||
const { worker, promptBody } = buildDeclaredWorkerSkill({
|
||||
skillPackName: "world-simulator",
|
||||
entry,
|
||||
template: {
|
||||
label: "转述",
|
||||
prompt_excerpt: "只改表达不改事实",
|
||||
suggested_outputs: ["输出.用户展示"],
|
||||
},
|
||||
workerSet: parsed,
|
||||
});
|
||||
expect(worker.id).toBe("narrator");
|
||||
expect(worker.outputTags).toContain("输出.用户展示");
|
||||
expect(worker.inputTags).toContain("设计.worker集");
|
||||
expect(promptBody).toContain("残酷但不虐主");
|
||||
expect(promptBody).toContain("主角免疫");
|
||||
expect(promptBody).toContain("声明驱动");
|
||||
});
|
||||
});
|
||||
30
tests/display-labels.test.ts
Normal file
30
tests/display-labels.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
displayStageLabel,
|
||||
displayWorkerLabel,
|
||||
formatWorkerDisplayTitle,
|
||||
} from "../src/server/display-labels.js";
|
||||
|
||||
describe("display-labels", () => {
|
||||
it("maps lifecycle stages", () => {
|
||||
expect(displayStageLabel("design")).toBe("创作");
|
||||
expect(displayStageLabel("play")).toBe("游玩");
|
||||
});
|
||||
|
||||
it("maps design workers", () => {
|
||||
expect(displayWorkerLabel("design-flow")).toBe("创作 · 流程编排");
|
||||
expect(displayWorkerLabel("design-step")).toBe("创作 · 执行步骤");
|
||||
expect(formatWorkerDisplayTitle("design-flow", "output")).toBe(
|
||||
"创作 · 流程编排 · 产出",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps creation unit ids", () => {
|
||||
expect(displayWorkerLabel("phase:core")).toBe("单位 · 核心");
|
||||
expect(displayWorkerLabel("fixed:interaction")).toBe("能力 · 交互范式");
|
||||
expect(displayWorkerLabel("fixed:aesthetics-interaction")).toBe(
|
||||
"能力 · 美学纲领与交互范式",
|
||||
);
|
||||
expect(displayWorkerLabel("worker:narrator")).toBe("演员 · 叙事转述");
|
||||
});
|
||||
});
|
||||
@@ -4,24 +4,13 @@ import {
|
||||
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: "用户.博弈需求",
|
||||
targetKey: "用户.需求",
|
||||
requiredFields: ["情境", "角色", "轮次"],
|
||||
optionalFields: ["输出偏好"],
|
||||
});
|
||||
@@ -46,23 +35,10 @@ describe("intake fields", () => {
|
||||
requiredFields: ["实验情境", "轮次模式"],
|
||||
optionalFields: [],
|
||||
});
|
||||
const values = extractIntakeHeuristic(
|
||||
"德州扑克,单轮定胜负",
|
||||
fields,
|
||||
{},
|
||||
);
|
||||
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",
|
||||
@@ -78,40 +54,4 @@ describe("intake fields", () => {
|
||||
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("值-题材");
|
||||
});
|
||||
});
|
||||
|
||||
64
tests/message-branch.test.ts
Normal file
64
tests/message-branch.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMessageBranchState,
|
||||
createUserVariantMessage,
|
||||
ensureBranchForEdit,
|
||||
recordPreMessageCheckpoint,
|
||||
switchBranchVariant,
|
||||
} from "../src/server/message-branch.js";
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
|
||||
describe("message-branch", () => {
|
||||
it("switchBranchVariant restores alternate user text", () => {
|
||||
const state = createMessageBranchState();
|
||||
const checkpoint = {
|
||||
runtimeSession: createSession("default"),
|
||||
blackboardItems: [],
|
||||
};
|
||||
const messages = [
|
||||
createUserVariantMessage("版本 A", "g1", 0),
|
||||
{ id: "w1", role: "system" as const, text: "回复 A", createdAt: "1" },
|
||||
];
|
||||
messages[0].branchGroupId = "g1";
|
||||
messages[0].branchIndex = 0;
|
||||
messages[0].branchTotal = 2;
|
||||
|
||||
state.branches.g1 = {
|
||||
anchorIndex: 0,
|
||||
groupId: "g1",
|
||||
activeIndex: 1,
|
||||
variants: [
|
||||
{
|
||||
messages: [messages[0], messages[1]],
|
||||
checkpoint,
|
||||
},
|
||||
{
|
||||
messages: [
|
||||
createUserVariantMessage("版本 B", "g1", 1),
|
||||
{ id: "w2", role: "system" as const, text: "回复 B", createdAt: "2" },
|
||||
],
|
||||
checkpoint,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const switched = switchBranchVariant(state, messages, "g1", -1);
|
||||
expect(switched?.messages[0].text).toBe("版本 A");
|
||||
expect(switched?.messages[1].text).toBe("回复 A");
|
||||
expect(state.branches.g1.activeIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("ensureBranchForEdit captures first variant", () => {
|
||||
const state = createMessageBranchState();
|
||||
const messages = [
|
||||
createUserVariantMessage("首句", "u1", 0),
|
||||
];
|
||||
recordPreMessageCheckpoint(state, 0, {
|
||||
runtimeSession: createSession("default"),
|
||||
blackboardItems: [],
|
||||
});
|
||||
const branch = ensureBranchForEdit(state, messages, 0, state.preMessageCheckpoints[0]!);
|
||||
expect(branch.variants).toHaveLength(1);
|
||||
expect(branch.variants[0].messages[0].text).toBe("首句");
|
||||
});
|
||||
});
|
||||
@@ -2,50 +2,39 @@ 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" }];
|
||||
const mockSkills = [
|
||||
{ name: "world-simulator", description: "默认", category: "dialogue" },
|
||||
];
|
||||
|
||||
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"));
|
||||
it("runs design-core approve loop on agent-first pack", async () => {
|
||||
const snap = toActiveSkillSnapshot(await loadSkill("world-simulator"));
|
||||
|
||||
let session = createSession("default");
|
||||
session = applyEvent(session, {
|
||||
let session = applyEvent(createSession("default"), {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
payload: {
|
||||
presetId: "default",
|
||||
availableSkills: mockSkills,
|
||||
initialSkill: snap,
|
||||
},
|
||||
}).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: {},
|
||||
payload: { text: "1v1 网恋对话" },
|
||||
}).session;
|
||||
expect(session.phase).toBe("running");
|
||||
expect(session.slots["book.brief"]).toBeTruthy();
|
||||
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
reason: "生成大纲",
|
||||
workerId: "outline-worker",
|
||||
reason: "产出 Worker 集",
|
||||
workerId: "design-core",
|
||||
requiresApproval: true,
|
||||
});
|
||||
session = applyEvent(session, {
|
||||
@@ -60,12 +49,12 @@ describe("phase machine", () => {
|
||||
}).session;
|
||||
session = applyEvent(session, {
|
||||
type: "worker_started",
|
||||
payload: { workerId: "outline-worker", acceptanceMode: "user_confirmed" },
|
||||
payload: { workerId: "design-core", acceptanceMode: "user_confirmed" },
|
||||
}).session;
|
||||
|
||||
const artifact = createArtifact({
|
||||
workerId: "outline-worker",
|
||||
outputTags: ["outline.draft"],
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集"],
|
||||
});
|
||||
session = { ...session, artifacts: [artifact] };
|
||||
session = applyEvent(session, {
|
||||
@@ -78,95 +67,96 @@ describe("phase machine", () => {
|
||||
type: "user_accepted_artifact",
|
||||
payload: { artifactId: artifact.id },
|
||||
}).session;
|
||||
expect(session.slots.designInstanceReady).toBe(true);
|
||||
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"));
|
||||
|
||||
it("hangs optional questions under review_artifact without blocking accept", () => {
|
||||
let session = createSession("default");
|
||||
session = {
|
||||
...session,
|
||||
phase: "running" as const,
|
||||
acceptanceMode: "user_confirmed" as const,
|
||||
};
|
||||
const artifact = createArtifact({
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集.草稿"],
|
||||
});
|
||||
session = { ...session, artifacts: [artifact] };
|
||||
session = applyEvent(session, {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
}).session;
|
||||
session = applyEvent(session, {
|
||||
type: "skill_selected",
|
||||
payload: { skill: snap },
|
||||
type: "worker_completed",
|
||||
payload: {
|
||||
artifactId: artifact.id,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "更偏哪种节奏?",
|
||||
options: [{ id: "A", label: "慢热拉扯" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}).session;
|
||||
|
||||
const fields = snap.intakeFields;
|
||||
const values: Record<string, string> = {};
|
||||
for (const f of fields.filter((x) => x.required)) {
|
||||
values[f.id] = "已填";
|
||||
}
|
||||
expect(session.waitingReason?.kind).toBe("review_artifact");
|
||||
if (session.waitingReason?.kind !== "review_artifact") return;
|
||||
expect(session.waitingReason.questions?.length).toBe(1);
|
||||
expect(session.waitingReason.questions?.[0]?.required).toBe(false);
|
||||
|
||||
// 不答追问,直接接受产物
|
||||
session = applyEvent(session, {
|
||||
type: "user_submitted_input",
|
||||
payload: { text: "首次", intakeValues: values },
|
||||
type: "user_accepted_artifact",
|
||||
payload: { artifactId: artifact.id },
|
||||
}).session;
|
||||
expect(session.phase).toBe("running");
|
||||
});
|
||||
|
||||
it("sidecar skip clears questions but stays in review", () => {
|
||||
let session = createSession("default");
|
||||
session = {
|
||||
...session,
|
||||
phase: "running" as const,
|
||||
acceptanceMode: "user_confirmed" as const,
|
||||
};
|
||||
const artifact = createArtifact({
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集.草稿"],
|
||||
});
|
||||
session = { ...session, artifacts: [artifact] };
|
||||
session = applyEvent(session, {
|
||||
type: "user_confirmed_intake",
|
||||
type: "worker_completed",
|
||||
payload: {
|
||||
artifactId: artifact.id,
|
||||
questions: ["还想补一点感官细节吗?"],
|
||||
},
|
||||
}).session;
|
||||
|
||||
session = applyEvent(session, {
|
||||
type: "user_resolved_sidecar_questions",
|
||||
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("需要思考标签");
|
||||
expect(session.waitingReason?.kind).toBe("review_artifact");
|
||||
if (session.waitingReason?.kind !== "review_artifact") return;
|
||||
expect(session.waitingReason.questions).toBeUndefined();
|
||||
expect(session.pendingArtifactId).toBe(artifact.id);
|
||||
});
|
||||
|
||||
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]: "德州扑克" },
|
||||
},
|
||||
it("accepting design-core with only draft does not set designInstanceReady", () => {
|
||||
let session = createSession();
|
||||
const artifact = createArtifact({
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集.草稿", "创作.当前单位"],
|
||||
});
|
||||
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);
|
||||
session = {
|
||||
...session,
|
||||
artifacts: [artifact],
|
||||
pendingArtifactId: artifact.id,
|
||||
phase: "waiting_user",
|
||||
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
|
||||
};
|
||||
session = applyEvent(session, {
|
||||
type: "user_accepted_artifact",
|
||||
payload: { artifactId: artifact.id },
|
||||
}).session;
|
||||
expect(session.slots.designInstanceReady).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,74 +4,47 @@ import {
|
||||
createMockToolCall,
|
||||
MockLlmProvider,
|
||||
} from "../src/llm/client.js";
|
||||
import { buildIntakeProgress, readIntakeValues } from "../src/intake/intake.js";
|
||||
import { PhaseRuntime } from "../src/runtime/phase-runtime.js";
|
||||
import { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js";
|
||||
|
||||
describe("phase runtime", () => {
|
||||
it("selects basic skill and shows startup inquiry", async () => {
|
||||
it("auto-loads default orchestrator and awaits first user input", 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");
|
||||
expect(runtime.getActiveSkill()?.name).toBe("world-simulator");
|
||||
expect(runtime.getActiveSkill()?.startupMode).toBe("agent-first");
|
||||
expect(runtime.getSession().waitingReason?.kind).toBe("input");
|
||||
});
|
||||
|
||||
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,
|
||||
it("writes 用户.需求 and invokes main agent after first input", async () => {
|
||||
const llm = new MockLlmProvider([
|
||||
createMockMainAgentResponse([
|
||||
createMockToolCall("ask_user", {
|
||||
question: "还需要补充吗?",
|
||||
reason: "确认交互细节",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
]);
|
||||
const runtime = new PhaseRuntime({ llm });
|
||||
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");
|
||||
await runtime.submitInput("西幻升级交互,世界推着走");
|
||||
expect(runtime.getSession().slots["用户.需求"]).toContain("西幻");
|
||||
expect(runtime.getSession().slots.startupCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it("stub run_worker design-flow is allowed before worker set accept", async () => {
|
||||
const runtime = new PhaseRuntime({ autoStubWorker: true });
|
||||
await runtime.start();
|
||||
await runtime.submitInput("网恋对象对话");
|
||||
await runtime.submitDecision(
|
||||
createDecision({
|
||||
action: "run_worker",
|
||||
reason: "编排创作流程",
|
||||
workerId: "design-flow",
|
||||
requiresApproval: false,
|
||||
}),
|
||||
);
|
||||
expect(runtime.getSession().artifacts.some((a) => a.workerId === "design-flow")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,137 +1,91 @@
|
||||
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";
|
||||
import { toActiveSkillSnapshot } from "../src/skills/snapshot.js";
|
||||
import { loadSkill, listSkills, listWorkerSkills, loadWorkerSkill } from "../src/skills/loader.js";
|
||||
|
||||
const mockSkills = [
|
||||
{ name: "basic", description: "基础小说", category: "novel" },
|
||||
{ name: "world-simulator", description: "默认能力库", category: "dialogue" },
|
||||
];
|
||||
|
||||
describe("skill loader", () => {
|
||||
it("lists basic skill", async () => {
|
||||
it("lists only world-simulator", async () => {
|
||||
const skills = await listSkills();
|
||||
expect(skills.some((s) => s.name === "basic")).toBe(true);
|
||||
expect(skills.map((s) => s.name)).toEqual(["world-simulator"]);
|
||||
});
|
||||
|
||||
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 world-simulator pack and design step skills", async () => {
|
||||
const skill = await loadSkill("world-simulator");
|
||||
expect(skill.name).toBe("world-simulator");
|
||||
expect(skill.path).toBe("dialogue/world-simulator/orchestrator.md");
|
||||
expect(skill.skillPackRoot).toBe("dialogue/world-simulator");
|
||||
|
||||
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");
|
||||
const workers = await listWorkerSkills("world-simulator");
|
||||
expect(workers.map((w) => w.id).sort()).toEqual([
|
||||
"review-author",
|
||||
"review-infer",
|
||||
"write-rules",
|
||||
"design-flow",
|
||||
"design-step",
|
||||
"opening-generator",
|
||||
]);
|
||||
const flow = await loadWorkerSkill("world-simulator", "design-flow");
|
||||
expect(flow.outputTags).toContain("设计.创作流程");
|
||||
expect(flow.name).toContain("流程");
|
||||
|
||||
const writeRules = await loadWorkerSkill("weird-rules-short", "write-rules");
|
||||
expect(writeRules.outputTags).toContain("rules.draft");
|
||||
expect(writeRules.body).toContain("shared-context");
|
||||
const step = await loadWorkerSkill("world-simulator", "design-step");
|
||||
expect(step.id).toBe("design-step");
|
||||
|
||||
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"]);
|
||||
const opening = await loadWorkerSkill("world-simulator", "opening-generator");
|
||||
expect(opening.outputTags[0]).toBe("输出.开场白");
|
||||
expect(opening.body).toContain("开场白");
|
||||
expect(opening.body).toContain("填表工具");
|
||||
expect(opening.body).toContain("普通大学生");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase machine with skill", () => {
|
||||
it("starts with skill_selection", () => {
|
||||
let session = createSession("default");
|
||||
const result = applyEvent(session, {
|
||||
describe("phase machine with world-simulator", () => {
|
||||
it("session_started agent-first awaits user input", async () => {
|
||||
const skill = await loadSkill("world-simulator");
|
||||
const snap = toActiveSkillSnapshot(skill);
|
||||
expect(snap.startupMode).toBe("agent-first");
|
||||
|
||||
const result = applyEvent(createSession("default"), {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
payload: {
|
||||
presetId: "default",
|
||||
availableSkills: mockSkills,
|
||||
initialSkill: snap,
|
||||
},
|
||||
});
|
||||
expect(result.session.phase).toBe("waiting_user");
|
||||
expect(result.session.waitingReason?.kind).toBe("skill_selection");
|
||||
expect(result.session.waitingReason?.kind).toBe("input");
|
||||
});
|
||||
|
||||
it("skill_selected shows startup prompt from skill", async () => {
|
||||
const skill = await loadSkill("basic");
|
||||
it("first user input invokes main agent", async () => {
|
||||
const skill = await loadSkill("world-simulator");
|
||||
const snap = toActiveSkillSnapshot(skill);
|
||||
|
||||
let session = applyEvent(createSession("default"), {
|
||||
const session = applyEvent(createSession("default"), {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
payload: { presetId: "default", availableSkills: mockSkills, initialSkill: snap },
|
||||
}).session;
|
||||
|
||||
const selected = applyEvent(session, {
|
||||
type: "skill_selected",
|
||||
payload: { skill: snap },
|
||||
const result = applyEvent(session, {
|
||||
type: "user_submitted_input",
|
||||
payload: { text: "我要你塑造一个网恋对象和我对话" },
|
||||
});
|
||||
expect(selected.session.waitingReason?.kind).toBe("input");
|
||||
expect(selected.session.slots.activeSkill).toBeDefined();
|
||||
expect((selected.session.slots.activeSkill as { name: string }).name).toBe("basic");
|
||||
expect(result.session.phase).toBe("running");
|
||||
expect(result.session.slots["用户.需求"]).toContain("网恋");
|
||||
expect(result.effects.some((e) => e.type === "invoke_main_agent")).toBe(true);
|
||||
});
|
||||
|
||||
it("legacy session_started without initialSkill enters skill_selection", () => {
|
||||
const result = applyEvent(createSession("default"), {
|
||||
type: "session_started",
|
||||
payload: { presetId: "default", availableSkills: mockSkills },
|
||||
});
|
||||
expect(result.session.waitingReason?.kind).toBe("skill_selection");
|
||||
});
|
||||
|
||||
it("rejects input before skill selected", () => {
|
||||
@@ -145,12 +99,21 @@ describe("phase machine with skill", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("科幻");
|
||||
describe("phase runtime with world-simulator", () => {
|
||||
it("starts default orchestrator and accepts stub design-flow", async () => {
|
||||
const { PhaseRuntime, createDecision } = await import("../src/runtime/phase-runtime.js");
|
||||
const runtime = new PhaseRuntime({ autoStubWorker: true });
|
||||
await runtime.start();
|
||||
expect(runtime.getActiveSkill()?.name).toBe("world-simulator");
|
||||
|
||||
await runtime.submitInput("西幻升级交互,世界推着走");
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
reason: "编排创作流程",
|
||||
workerId: "design-flow",
|
||||
requiresApproval: false,
|
||||
});
|
||||
await runtime.submitDecision(decision);
|
||||
expect(runtime.getSession().artifacts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assemblePresetMessages } from "../src/preset/assembler.js";
|
||||
import { listEnabledPresetEntries, countInjectingEntries } from "../src/preset/entries.js";
|
||||
import {
|
||||
applyPresetEntryPatches,
|
||||
countInjectingEntries,
|
||||
listAllPresetEntries,
|
||||
listEnabledPresetEntries,
|
||||
} from "../src/preset/entries.js";
|
||||
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||
|
||||
const samplePreset = {
|
||||
@@ -82,3 +87,29 @@ describe("listEnabledPresetEntries", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPresetEntryPatches", () => {
|
||||
it("toggles enable and edits content", () => {
|
||||
const report = importSillyTavernPreset(samplePreset);
|
||||
const disabled = applyPresetEntryPatches(report.preset, [
|
||||
{ id: "custom-a", enabled: false },
|
||||
]);
|
||||
expect(listAllPresetEntries(disabled).find((e) => e.id === "custom-a")?.enabled).toBe(
|
||||
false,
|
||||
);
|
||||
expect(assemblePresetMessages(disabled).map((m) => m.content)).toEqual([
|
||||
"prefill B",
|
||||
]);
|
||||
|
||||
const edited = applyPresetEntryPatches(disabled, [
|
||||
{ id: "main", enabled: true, content: "hello main", name: "主提示" },
|
||||
]);
|
||||
const main = listAllPresetEntries(edited).find((e) => e.id === "main");
|
||||
expect(main?.enabled).toBe(true);
|
||||
expect(main?.willInject).toBe(true);
|
||||
expect(main?.name).toBe("主提示");
|
||||
expect(
|
||||
assemblePresetMessages(edited).some((m) => m.content === "hello main"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
310
tests/prune-creation-messages.test.ts
Normal file
310
tests/prune-creation-messages.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
foldRunProcessMessages,
|
||||
pruneCreationUnitMessages,
|
||||
trimCreationDialogueMessages,
|
||||
selectCreationDialogueForAi,
|
||||
buildCreationDialogueTranscript,
|
||||
type PrunableChatMessage,
|
||||
} from "../src/server/prune-creation-messages.js";
|
||||
import {
|
||||
isDesignUnitArtifact,
|
||||
isFinalWorkerSetArtifact,
|
||||
isWorkerSetReadyForFinal,
|
||||
listCreationUnits,
|
||||
nextCreationUnitId,
|
||||
} from "../src/skills/creation-units.js";
|
||||
import type { ParsedWorkerSet } from "../src/skills/worker-set-parse.js";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import { compressAfterWorkerAccept } from "../src/runtime/compress-after-worker.js";
|
||||
import { hasAcceptedWorkerSet } from "../src/skills/worker-declaration.js";
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
|
||||
function msg(
|
||||
partial: Partial<PrunableChatMessage> & Pick<PrunableChatMessage, "id" | "role" | "text">,
|
||||
): PrunableChatMessage {
|
||||
return { createdAt: "2026-01-01T00:00:00.000Z", ...partial };
|
||||
}
|
||||
|
||||
describe("pruneCreationUnitMessages", () => {
|
||||
it("is a no-op: does not delete user-visible messages", () => {
|
||||
const messages: PrunableChatMessage[] = [
|
||||
msg({ id: "1", role: "user", text: "我想玩校园末日", kind: "user_input" }),
|
||||
msg({
|
||||
id: "2",
|
||||
role: "system",
|
||||
text: "[Worker] design-core 运行中",
|
||||
kind: "worker_running",
|
||||
actor: "design-core",
|
||||
}),
|
||||
msg({
|
||||
id: "3",
|
||||
role: "system",
|
||||
text: "[Worker] design-core 提问",
|
||||
kind: "worker_questions",
|
||||
actor: "design-core",
|
||||
}),
|
||||
msg({ id: "4", role: "user", text: "单角代入", kind: "user_input" }),
|
||||
msg({
|
||||
id: "5",
|
||||
role: "system",
|
||||
text: "[Worker] design-core 草稿",
|
||||
kind: "worker_output",
|
||||
actor: "design-core",
|
||||
body: "{ draft }",
|
||||
}),
|
||||
msg({
|
||||
id: "6",
|
||||
role: "system",
|
||||
text: "[Worker] design-core 定稿",
|
||||
kind: "worker_output",
|
||||
actor: "design-core",
|
||||
body: "{ final }",
|
||||
}),
|
||||
];
|
||||
const idsBefore = messages.map((m) => m.id);
|
||||
const result = pruneCreationUnitMessages(messages, "design-core");
|
||||
expect(result).toEqual({ removedCount: 0, productKept: false });
|
||||
expect(messages.map((m) => m.id)).toEqual(idsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectCreationDialogueForAi / buildCreationDialogueTranscript", () => {
|
||||
it("keeps full messages for browsing; AI transcript only last AI + all users", () => {
|
||||
const messages: PrunableChatMessage[] = [
|
||||
msg({ id: "u1", role: "user", text: "荒野求生", kind: "user_input" }),
|
||||
msg({
|
||||
id: "a1",
|
||||
role: "system",
|
||||
text: "draft1",
|
||||
kind: "worker_output",
|
||||
actor: "design-core",
|
||||
}),
|
||||
msg({ id: "u2", role: "user", text: "无其它幸存者", kind: "user_input" }),
|
||||
msg({
|
||||
id: "run",
|
||||
role: "system",
|
||||
text: "running",
|
||||
kind: "worker_running",
|
||||
actor: "design-core",
|
||||
}),
|
||||
msg({
|
||||
id: "a2",
|
||||
role: "system",
|
||||
text: "draft2",
|
||||
kind: "worker_output",
|
||||
actor: "design-core",
|
||||
body: "{ v2 }",
|
||||
}),
|
||||
];
|
||||
|
||||
// 用户浏览:原数组不动
|
||||
const { removedCount } = trimCreationDialogueMessages(messages);
|
||||
expect(removedCount).toBe(0);
|
||||
expect(messages.map((m) => m.id)).toEqual(["u1", "a1", "u2", "run", "a2"]);
|
||||
|
||||
// 拼给 AI:用户全量 + 最后一次 AI
|
||||
expect(selectCreationDialogueForAi(messages).map((m) => m.id)).toEqual([
|
||||
"u1",
|
||||
"u2",
|
||||
"a2",
|
||||
]);
|
||||
const transcript = buildCreationDialogueTranscript(messages);
|
||||
expect(transcript).toContain("荒野求生");
|
||||
expect(transcript).toContain("无其它幸存者");
|
||||
expect(transcript).toContain("{ v2 }");
|
||||
expect(transcript).not.toContain("draft1");
|
||||
expect(transcript).not.toContain("running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("foldRunProcessMessages", () => {
|
||||
it("marks process messages compressed without deleting", () => {
|
||||
const messages: PrunableChatMessage[] = [
|
||||
msg({
|
||||
id: "1",
|
||||
role: "system",
|
||||
text: "q",
|
||||
kind: "worker_questions",
|
||||
actor: "narrator",
|
||||
}),
|
||||
msg({
|
||||
id: "2",
|
||||
role: "system",
|
||||
text: "out",
|
||||
kind: "worker_output",
|
||||
actor: "narrator",
|
||||
}),
|
||||
];
|
||||
foldRunProcessMessages(messages, "narrator");
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages.every((m) => m.compressed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listCreationUnits", () => {
|
||||
it("only lists filled fixed topics by default (not empty catalog blanks)", () => {
|
||||
const parsed: ParsedWorkerSet = {
|
||||
interaction: {
|
||||
user_stance: "单角",
|
||||
system_role: "世界",
|
||||
output: "叙事",
|
||||
},
|
||||
narrative_guide: "世界不有求必应",
|
||||
workers: [
|
||||
{
|
||||
ref: "narrator",
|
||||
duty: "转述",
|
||||
rationale: "可读",
|
||||
acceptance: "review",
|
||||
},
|
||||
{ ref: null, duty: "缺口", gap: "待定" },
|
||||
],
|
||||
resident_context: [
|
||||
{ id: "tone", content: "文风克制", position: "static" },
|
||||
],
|
||||
};
|
||||
const units = listCreationUnits(parsed, {
|
||||
acceptedUnitIds: ["fixed:interaction", "skeleton:interaction"],
|
||||
currentUnitId: "worker:narrator",
|
||||
});
|
||||
expect(units.map((u) => u.id)).toEqual([
|
||||
"phase:core",
|
||||
"fixed:narrative_guide",
|
||||
"worker:narrator",
|
||||
"worker:#2",
|
||||
"resident:tone",
|
||||
]);
|
||||
expect(units.find((u) => u.id === "phase:core")?.accepted).toBe(true);
|
||||
expect(units.every((u) => u.id !== "fixed:input_protocol" || u.filled)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(units.find((u) => u.id === "fixed:input_protocol")).toBeUndefined();
|
||||
expect(units.find((u) => u.id === "worker:narrator")?.current).toBe(true);
|
||||
});
|
||||
|
||||
it("nextCreationUnitId prefers foundation fixed tags before workers", () => {
|
||||
const parsed: ParsedWorkerSet = {
|
||||
interaction: {
|
||||
user_stance: "单角",
|
||||
system_role: "世界",
|
||||
output: "叙事",
|
||||
},
|
||||
workers: [{ ref: "narrator", duty: "转述", rationale: "r" }],
|
||||
};
|
||||
expect(nextCreationUnitId(parsed, ["fixed:interaction"])).toBe(
|
||||
"fixed:narrative_guide",
|
||||
);
|
||||
});
|
||||
|
||||
it("nextCreationUnitId reaches workers after weighty fixed accepted", () => {
|
||||
const parsed: ParsedWorkerSet = {
|
||||
interaction: {
|
||||
user_stance: "单角",
|
||||
system_role: "世界",
|
||||
output: "叙事",
|
||||
},
|
||||
narrative_guide: "边界清晰",
|
||||
workers: [{ ref: "narrator", duty: "转述", rationale: "r" }],
|
||||
};
|
||||
expect(
|
||||
nextCreationUnitId(parsed, [
|
||||
"fixed:interaction",
|
||||
"fixed:narrative_guide",
|
||||
"fixed:aesthetics",
|
||||
"fixed:input_protocol",
|
||||
"fixed:core_premises",
|
||||
]),
|
||||
).toBe("worker:narrator");
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifact classification", () => {
|
||||
it("distinguishes unit vs final", () => {
|
||||
expect(
|
||||
isDesignUnitArtifact({
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集.草稿"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFinalWorkerSetArtifact({
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDesignUnitArtifact({
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集", "设计.worker集.草稿"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAcceptedWorkerSet", () => {
|
||||
it("does not treat draft-only accepted artifact as ready", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
artifacts: [
|
||||
{
|
||||
id: "a1",
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集.草稿"],
|
||||
status: "accepted" as const,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(hasAcceptedWorkerSet(session)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compressAfterWorkerAccept unit mode", () => {
|
||||
it("keeps draft active and does not write brief as final", () => {
|
||||
const bb = new Blackboard();
|
||||
bb.write({
|
||||
tag: "设计.worker集.草稿",
|
||||
content: '{ "workers": [{ "ref": "narrator" }] }',
|
||||
source: "design-core",
|
||||
});
|
||||
bb.write({
|
||||
tag: "用户.worker答复",
|
||||
content: "过程问答",
|
||||
source: "user",
|
||||
});
|
||||
const result = compressAfterWorkerAccept({
|
||||
blackboard: bb,
|
||||
workerId: "design-core",
|
||||
outputTags: ["设计.worker集.草稿"],
|
||||
summary: "单位 worker:narrator",
|
||||
mode: "unit",
|
||||
});
|
||||
expect(result.archivedTags).toContain("用户.worker答复");
|
||||
expect(bb.getContentByTag("设计.worker集.草稿")).toContain("narrator");
|
||||
expect(bb.getLatestByTag("设计.worker集.草稿")?.metadata?.role).not.toBe(
|
||||
"archived",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkerSetReadyForFinal", () => {
|
||||
it("requires accepted filled units", () => {
|
||||
const parsed: ParsedWorkerSet = {
|
||||
interaction: {
|
||||
user_stance: "单角",
|
||||
system_role: "世界",
|
||||
output: "叙事",
|
||||
},
|
||||
workers: [{ ref: "narrator", duty: "转述", rationale: "r" }],
|
||||
};
|
||||
expect(isWorkerSetReadyForFinal(parsed, []).ready).toBe(false);
|
||||
expect(
|
||||
isWorkerSetReadyForFinal(parsed, [
|
||||
"fixed:interaction",
|
||||
"worker:narrator",
|
||||
]).ready,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
65
tests/question-protocol.test.ts
Normal file
65
tests/question-protocol.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatQuestionAnswersForAi,
|
||||
formatQuestionAnswersForDisplay,
|
||||
normalizeQuestions,
|
||||
} from "../src/skills/question-protocol.js";
|
||||
|
||||
describe("normalizeQuestions", () => {
|
||||
it("accepts string[]", () => {
|
||||
const qs = normalizeQuestions(["站位倾向?", " "]);
|
||||
expect(qs).toHaveLength(1);
|
||||
expect(qs[0]?.prompt).toBe("站位倾向?");
|
||||
expect(qs[0]?.allowOther).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts structured items with options", () => {
|
||||
const qs = normalizeQuestions([
|
||||
{
|
||||
id: "stance",
|
||||
prompt: "你更想以什么身份玩?",
|
||||
options: ["单角幸存者代入", { id: "B", label: "旁观调度" }],
|
||||
},
|
||||
]);
|
||||
expect(qs[0]?.id).toBe("stance");
|
||||
expect(qs[0]?.options).toHaveLength(2);
|
||||
expect(qs[0]?.options?.[0]?.id).toBe("A");
|
||||
expect(qs[0]?.options?.[0]?.label).toContain("幸存者");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatQuestionAnswers", () => {
|
||||
it("AI payload keeps Q+A; display omits prompts", () => {
|
||||
const questions = normalizeQuestions([
|
||||
{ id: "q1", prompt: "有无其它幸存者?", options: ["没有,仅自然生物"] },
|
||||
]);
|
||||
const answers = [
|
||||
{ questionId: "q1", optionId: "A", text: "没有,仅自然生物" },
|
||||
];
|
||||
const ai = formatQuestionAnswersForAi(questions, answers);
|
||||
expect(ai).toContain("问:有无其它幸存者?");
|
||||
expect(ai).toContain("答:没有,仅自然生物");
|
||||
const display = formatQuestionAnswersForDisplay(questions, answers);
|
||||
expect(display).toBe("没有,仅自然生物");
|
||||
expect(display).not.toContain("问:");
|
||||
});
|
||||
|
||||
it("appends free-text note to AI and display payloads", () => {
|
||||
const questions = normalizeQuestions([
|
||||
{ id: "q1", prompt: "节奏?", options: ["严格回合制"] },
|
||||
]);
|
||||
const answers = [
|
||||
{ questionId: "q1", optionId: "A", text: "严格回合制" },
|
||||
];
|
||||
const ai = formatQuestionAnswersForAi(questions, answers, "希望偏慢热");
|
||||
expect(ai).toContain("【补充】");
|
||||
expect(ai).toContain("希望偏慢热");
|
||||
const display = formatQuestionAnswersForDisplay(
|
||||
questions,
|
||||
answers,
|
||||
"希望偏慢热",
|
||||
);
|
||||
expect(display).toContain("严格回合制");
|
||||
expect(display).toContain("希望偏慢热");
|
||||
});
|
||||
});
|
||||
98
tests/resident-context.test.ts
Normal file
98
tests/resident-context.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import {
|
||||
entriesForWorker,
|
||||
formatResidentPromptSection,
|
||||
mountResidentContextForWorker,
|
||||
parseResidentContext,
|
||||
residentTagFor,
|
||||
} from "../src/skills/resident-context.js";
|
||||
import { buildDeclaredWorkerSkill } from "../src/skills/declared-worker.js";
|
||||
import type { ParsedWorkerSet } from "../src/skills/worker-set-parse.js";
|
||||
|
||||
describe("resident-context", () => {
|
||||
const entries = parseResidentContext([
|
||||
{
|
||||
id: "tone",
|
||||
position: "static",
|
||||
importance: 2,
|
||||
content: "文风克制、偏冷",
|
||||
mount: ["narrator"],
|
||||
},
|
||||
{
|
||||
id: "rules",
|
||||
content: "硬规则:不死复活",
|
||||
mount: ["world-simulator", "narrator"],
|
||||
},
|
||||
{
|
||||
id: "scratch",
|
||||
position: "dynamic",
|
||||
content: "本轮提示",
|
||||
mount: ["world-simulator"],
|
||||
},
|
||||
]);
|
||||
|
||||
it("parses and sorts by importance", () => {
|
||||
expect(entries[0]?.id).toBe("tone");
|
||||
expect(residentTagFor(entries[0]!)).toBe("上下文.常驻.tone");
|
||||
});
|
||||
|
||||
it("filters mount per worker", () => {
|
||||
expect(entriesForWorker(entries, "narrator").map((e) => e.id)).toEqual([
|
||||
"tone",
|
||||
"rules",
|
||||
]);
|
||||
expect(entriesForWorker(entries, "world-simulator").map((e) => e.id)).toEqual([
|
||||
"rules",
|
||||
"scratch",
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes tags to blackboard", () => {
|
||||
const bb = new Blackboard();
|
||||
const { staticTags, dynamicTags, written } = mountResidentContextForWorker({
|
||||
blackboard: bb,
|
||||
entries,
|
||||
workerId: "world-simulator",
|
||||
});
|
||||
expect(written).toContain("上下文.常驻.rules");
|
||||
expect(staticTags).toContain("上下文.常驻.rules");
|
||||
expect(dynamicTags).toContain("上下文.常驻.scratch");
|
||||
expect(bb.getContentByTag("上下文.常驻.rules")).toContain("不死复活");
|
||||
});
|
||||
|
||||
it("injects into declared worker prompt and inputTags", () => {
|
||||
const workerSet: ParsedWorkerSet = {
|
||||
workers: [
|
||||
{
|
||||
ref: "narrator",
|
||||
duty: "转述",
|
||||
acceptance: "review",
|
||||
},
|
||||
],
|
||||
resident_context: entries,
|
||||
narrative_guide: "残酷求生",
|
||||
};
|
||||
const built = buildDeclaredWorkerSkill({
|
||||
skillPackName: "world-simulator",
|
||||
entry: workerSet.workers[0]!,
|
||||
template: {
|
||||
id: "narrator",
|
||||
label: "转述",
|
||||
suggested_outputs: ["输出.用户展示"],
|
||||
},
|
||||
workerSet,
|
||||
});
|
||||
expect(built.promptBody).toContain("常驻上下文");
|
||||
expect(built.promptBody).toContain("文风克制");
|
||||
expect(built.worker.inputTags).toContain("上下文.常驻.tone");
|
||||
expect(built.worker.inputTags).toContain("上下文.常驻.rules");
|
||||
expect(built.worker.inputTags).not.toContain("上下文.常驻.scratch");
|
||||
});
|
||||
|
||||
it("formats prompt section", () => {
|
||||
const text = formatResidentPromptSection(entries, "narrator");
|
||||
expect(text).toContain("### tone");
|
||||
expect(text).not.toContain("scratch");
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ describe("run snapshot store", () => {
|
||||
bookId,
|
||||
label,
|
||||
kind,
|
||||
orchestratorId: "roleplay-game-theory",
|
||||
orchestratorId: "world-simulator",
|
||||
runtimeSession,
|
||||
blackboardItems: [
|
||||
{
|
||||
@@ -129,7 +129,7 @@ describe("run snapshot store", () => {
|
||||
const book = createBook({ title: "读档测试" });
|
||||
|
||||
const mgr = new SessionManager();
|
||||
const view1 = await mgr.createForBook(book.id, "basic");
|
||||
const view1 = await mgr.createForBook(book.id, "world-simulator");
|
||||
await mgr.sendMessage(view1.id, "读档前消息");
|
||||
|
||||
const save = mgr.saveGameSnapshot(view1.id, "第一章末");
|
||||
@@ -141,7 +141,7 @@ describe("run snapshot store", () => {
|
||||
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);
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
it("instance snapshot strips run progress on save and load", async () => {
|
||||
const { createBook } = await import("../src/book/store.js");
|
||||
@@ -151,7 +151,7 @@ describe("run snapshot store", () => {
|
||||
const book = createBook({ title: "实例快照" });
|
||||
|
||||
const mgr = new SessionManager();
|
||||
const view = await mgr.createForBook(book.id, "basic");
|
||||
const view = await mgr.createForBook(book.id, "world-simulator");
|
||||
const managed = mgr["require"](view.id) as {
|
||||
runtime: { getSession: () => { slots: Record<string, unknown> }; getBlackboard: () => { write: (i: object) => void } };
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("session persistence", () => {
|
||||
version: 1,
|
||||
sessionId,
|
||||
bookId: book.id,
|
||||
orchestratorId: "basic",
|
||||
orchestratorId: "world-simulator",
|
||||
runtimeSession,
|
||||
blackboardItems: [
|
||||
{
|
||||
@@ -70,7 +70,7 @@ describe("session persistence", () => {
|
||||
const book = createBook({ title: "续作测试" });
|
||||
|
||||
const mgr1 = new SessionManager();
|
||||
const view1 = await mgr1.createForBook(book.id, "basic");
|
||||
const view1 = await mgr1.createForBook(book.id, "world-simulator");
|
||||
await mgr1.sendMessage(view1.id, "测试输入");
|
||||
|
||||
const mgr2 = new SessionManager();
|
||||
@@ -81,7 +81,7 @@ describe("session persistence", () => {
|
||||
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);
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
it("deleteBook removes session snapshot", async () => {
|
||||
const { createBook, deleteBook } = await import("../src/book/store.js");
|
||||
@@ -91,24 +91,39 @@ describe("session persistence", () => {
|
||||
const book = createBook({ title: "删除测试" });
|
||||
|
||||
const mgr = new SessionManager();
|
||||
await mgr.createForBook(book.id, "basic");
|
||||
await mgr.createForBook(book.id, "world-simulator");
|
||||
expect(hasBookSession(book.id)).toBe(true);
|
||||
|
||||
deleteBook(book.id);
|
||||
expect(hasBookSession(book.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("createForBook captures startup inquiry in messages", async () => {
|
||||
it("createForBook auto-starts world-simulator agent-first", 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);
|
||||
const view = await mgr.createForBook(book.id);
|
||||
expect(view.waitingReason?.kind).toBe("input");
|
||||
expect(view.activeSkill).toBe("world-simulator");
|
||||
expect(view.uiPrompt).toContain("描述想做什么");
|
||||
expect(view.messages.some((m) => m.text?.includes("描述想做什么"))).toBe(false);
|
||||
|
||||
deleteBook(book.id);
|
||||
});
|
||||
|
||||
it("createForBook with explicit world-simulator override", 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, "world-simulator");
|
||||
expect(view.waitingReason?.kind).toBe("input");
|
||||
expect(view.activeSkill).toBe("world-simulator");
|
||||
|
||||
deleteBook(book.id);
|
||||
});
|
||||
|
||||
51
tests/table-cells.test.ts
Normal file
51
tests/table-cells.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createTableFromValues,
|
||||
mergeTableCells,
|
||||
parseTableDoc,
|
||||
stringifyTableDoc,
|
||||
} from "../src/blackboard/table-cells.js";
|
||||
|
||||
describe("table-cells", () => {
|
||||
it("creates and parses rows with rev", () => {
|
||||
const doc = createTableFromValues(
|
||||
{ 年龄: 20, 资产: "少" },
|
||||
"worker:opening-generator",
|
||||
{ 年龄: { note: "由普通大学生推断" } },
|
||||
);
|
||||
expect(doc.rows).toHaveLength(2);
|
||||
const roundtrip = parseTableDoc(stringifyTableDoc(doc));
|
||||
expect(roundtrip?.rows.find((r) => r.key === "年龄")?.value).toBe(20);
|
||||
expect(roundtrip?.rows.find((r) => r.key === "年龄")?.note).toContain("大学生");
|
||||
});
|
||||
|
||||
it("does not overwrite user-owned cells", () => {
|
||||
const current = createTableFromValues({ 年龄: 22 }, "user");
|
||||
const patch = createTableFromValues({ 年龄: 18, 资产: 100 }, "worker:opening-generator");
|
||||
const { doc, applied, skipped } = mergeTableCells({
|
||||
current,
|
||||
patch,
|
||||
actor: "worker:variable-update",
|
||||
});
|
||||
expect(skipped.some((s) => s.key === "年龄" && s.reason === "user-owned")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(applied).toContain("资产");
|
||||
expect(doc.rows.find((r) => r.key === "年龄")?.value).toBe(22);
|
||||
expect(doc.rows.find((r) => r.key === "资产")?.value).toBe(100);
|
||||
});
|
||||
|
||||
it("respects expectedRev conflicts", () => {
|
||||
const current = createTableFromValues({ 好感: 10 }, "worker:a");
|
||||
current.rows[0].rev = 3;
|
||||
const patch = createTableFromValues({ 好感: 60 }, "worker:b");
|
||||
const { skipped, doc } = mergeTableCells({
|
||||
current,
|
||||
patch,
|
||||
actor: "worker:b",
|
||||
expectedRev: { 好感: 2 },
|
||||
});
|
||||
expect(skipped[0]?.reason).toContain("rev-conflict");
|
||||
expect(doc.rows.find((r) => r.key === "好感")?.value).toBe(10);
|
||||
});
|
||||
});
|
||||
165
tests/table-side-effects.test.ts
Normal file
165
tests/table-side-effects.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import {
|
||||
createTableFromValues,
|
||||
mergeTableCells,
|
||||
} from "../src/blackboard/table-cells.js";
|
||||
import {
|
||||
SIDE_EFFECT_FIRED_TAG,
|
||||
applySideEffectTagActions,
|
||||
evaluateSideEffects,
|
||||
parseFiredRegistry,
|
||||
parseSideEffectRules,
|
||||
stringifyFiredRegistry,
|
||||
} from "../src/blackboard/table-side-effects.js";
|
||||
|
||||
describe("table-side-effects", () => {
|
||||
const rules = parseSideEffectRules({
|
||||
side_effects: [
|
||||
{
|
||||
id: "affinity-romance",
|
||||
field: "好感",
|
||||
op: "gte",
|
||||
value: 60,
|
||||
mode: "once",
|
||||
action: {
|
||||
type: "write_tag",
|
||||
tag: "上下文.角色态度",
|
||||
content: "恋爱模式",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chapter-1",
|
||||
field: "当前章",
|
||||
op: "eq",
|
||||
value: "第一章",
|
||||
mode: "once",
|
||||
action: {
|
||||
type: "write_tag",
|
||||
tag: "上下文.细纲",
|
||||
content: "第一章细纲",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hp-warn",
|
||||
field: "HP",
|
||||
op: "lte",
|
||||
value: 20,
|
||||
mode: "every_edge",
|
||||
action: {
|
||||
type: "queue_worker",
|
||||
workerId: "narrator",
|
||||
note: "低血警告",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("parses side_effects from tables", () => {
|
||||
expect(rules).toHaveLength(3);
|
||||
expect(rules[0]?.id).toBe("affinity-romance");
|
||||
expect(rules[0]?.mode).toBe("once");
|
||||
});
|
||||
|
||||
it("fires on rising edge only, not while condition stays true", () => {
|
||||
const prev = createTableFromValues({ 好感: 50 }, "worker:a");
|
||||
const mid = createTableFromValues({ 好感: 65 }, "worker:a");
|
||||
const first = evaluateSideEffects({
|
||||
prev,
|
||||
next: mid,
|
||||
rules,
|
||||
fired: {},
|
||||
});
|
||||
expect(first.triggers.map((t) => t.rule.id)).toEqual(["affinity-romance"]);
|
||||
|
||||
const stillHigh = createTableFromValues({ 好感: 80 }, "worker:a");
|
||||
const second = evaluateSideEffects({
|
||||
prev: mid,
|
||||
next: stillHigh,
|
||||
rules,
|
||||
fired: first.nextFired,
|
||||
});
|
||||
expect(second.triggers).toHaveLength(0);
|
||||
expect(second.nextFired["affinity-romance"]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not re-fire once rule after drop-and-rise when already fired", () => {
|
||||
const fired = { "affinity-romance": { at: "2020-01-01T00:00:00.000Z" } };
|
||||
const prev = createTableFromValues({ 好感: 40 }, "worker:a");
|
||||
const next = createTableFromValues({ 好感: 70 }, "worker:a");
|
||||
const result = evaluateSideEffects({ prev, next, rules, fired });
|
||||
expect(result.triggers.find((t) => t.rule.id === "affinity-romance")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("every_edge can fire again after leaving and re-entering", () => {
|
||||
const low = createTableFromValues({ HP: 10 }, "worker:a");
|
||||
const ok = createTableFromValues({ HP: 50 }, "worker:a");
|
||||
const lowAgain = createTableFromValues({ HP: 15 }, "worker:a");
|
||||
|
||||
const enter = evaluateSideEffects({
|
||||
prev: createTableFromValues({ HP: 40 }, "worker:a"),
|
||||
next: low,
|
||||
rules,
|
||||
fired: {},
|
||||
});
|
||||
expect(enter.queuedWorkers.map((q) => q.workerId)).toEqual(["narrator"]);
|
||||
|
||||
const leave = evaluateSideEffects({
|
||||
prev: low,
|
||||
next: ok,
|
||||
rules,
|
||||
fired: enter.nextFired,
|
||||
});
|
||||
expect(leave.triggers).toHaveLength(0);
|
||||
|
||||
const reenter = evaluateSideEffects({
|
||||
prev: ok,
|
||||
next: lowAgain,
|
||||
rules,
|
||||
fired: leave.nextFired,
|
||||
});
|
||||
expect(reenter.queuedWorkers.map((q) => q.workerId)).toEqual(["narrator"]);
|
||||
});
|
||||
|
||||
it("applies write_tag actions and persists fired registry", () => {
|
||||
const bb = new Blackboard();
|
||||
const prev = createTableFromValues({ 当前章: "序章" }, "user");
|
||||
const next = createTableFromValues({ 当前章: "第一章" }, "worker:var");
|
||||
const { triggers, nextFired } = evaluateSideEffects({
|
||||
prev,
|
||||
next,
|
||||
rules,
|
||||
fired: {},
|
||||
});
|
||||
const { writtenTags } = applySideEffectTagActions({
|
||||
blackboard: bb,
|
||||
triggers,
|
||||
source: "side-effect:test",
|
||||
});
|
||||
expect(writtenTags).toContain("上下文.细纲");
|
||||
expect(bb.getContentByTag("上下文.细纲")).toBe("第一章细纲");
|
||||
bb.write({
|
||||
tag: SIDE_EFFECT_FIRED_TAG,
|
||||
content: stringifyFiredRegistry(nextFired),
|
||||
source: "system",
|
||||
});
|
||||
expect(parseFiredRegistry(bb.getContentByTag(SIDE_EFFECT_FIRED_TAG))["chapter-1"]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("works after mergeTableCells patch", () => {
|
||||
const current = createTableFromValues({ 好感: 55 }, "worker:a");
|
||||
const patch = createTableFromValues({ 好感: 60 }, "worker:b");
|
||||
const { doc } = mergeTableCells({
|
||||
current,
|
||||
patch,
|
||||
actor: "worker:b",
|
||||
});
|
||||
const { triggers } = evaluateSideEffects({
|
||||
prev: current,
|
||||
next: doc,
|
||||
rules,
|
||||
fired: {},
|
||||
});
|
||||
expect(triggers[0]?.rule.id).toBe("affinity-romance");
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ describe("TokenTrackingProvider", () => {
|
||||
caller: "worker:write-rules",
|
||||
model: "test-model",
|
||||
});
|
||||
expect(ctx.pendingUsage?.at).toBeTruthy();
|
||||
expect(ctx.pendingUsage?.recordId).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -63,4 +63,36 @@ describe("main agent tool loop", () => {
|
||||
expect(decision.action).toBe("finish");
|
||||
expect(decision.reason).toBe("完成");
|
||||
});
|
||||
|
||||
it("maps ask_user assessment + questions", () => {
|
||||
const decision = toolCallToDecision({
|
||||
id: "c2",
|
||||
name: "ask_user",
|
||||
arguments: JSON.stringify({
|
||||
reason: "核心体验分叉需用户拍板",
|
||||
assessment:
|
||||
"核心感觉: 完备度 40%\n 已知: 皇帝权力幻想\n 待探: 【冷峻威严】还是【感官沉沦】?",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "你更倾向于哪种皇帝的享受?",
|
||||
options: [
|
||||
{
|
||||
label: "冰冷的、主宰一切的权力感——九重宫阙一言定生死",
|
||||
},
|
||||
{
|
||||
label: "私密的、极致的感官享受——温柔乡中的沉沦",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(decision.action).toBe("ask_user");
|
||||
expect(decision.reason).toBe("核心体验分叉需用户拍板");
|
||||
expect(decision.assessment).toContain("完备度 40%");
|
||||
expect(decision.questions).toHaveLength(1);
|
||||
expect(decision.questions?.[0]?.prompt).toContain("皇帝的享受");
|
||||
expect(decision.questions?.[0]?.options?.[0]?.label).toContain("权力感");
|
||||
});
|
||||
});
|
||||
|
||||
102
tests/worker-declaration.test.ts
Normal file
102
tests/worker-declaration.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
import {
|
||||
buildInstanceWorkerDeclaration,
|
||||
inferLifecycleStage,
|
||||
isWorkerDeclared,
|
||||
readWorkerSetYamlForDeclaration,
|
||||
} from "../src/skills/worker-declaration.js";
|
||||
|
||||
const WORKER_SET = `
|
||||
version: 1
|
||||
workers:
|
||||
- ref: world-simulator
|
||||
duty: 世界推进
|
||||
- ref: narrator
|
||||
duty: 转述
|
||||
- ref: opening-generator
|
||||
duty: 开局
|
||||
instantiate_hints:
|
||||
invoke: [opening-generator]
|
||||
`;
|
||||
|
||||
function sessionWithAcceptedWorkerSet() {
|
||||
return {
|
||||
...createSession(),
|
||||
slots: {
|
||||
...createSession().slots,
|
||||
designInstanceReady: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("worker-declaration", () => {
|
||||
it("design before accept: design step skills", () => {
|
||||
const bb = new Blackboard();
|
||||
const decl = buildInstanceWorkerDeclaration(createSession(), bb, "design");
|
||||
expect(decl.activeWorkerIds).toEqual(["design-flow", "design-step"]);
|
||||
expect(decl.accepted).toBe(false);
|
||||
});
|
||||
|
||||
it("play after accept: only run workers from worker set", () => {
|
||||
const bb = new Blackboard();
|
||||
bb.write({ tag: "设计.worker集", content: WORKER_SET, source: "test" });
|
||||
const session = {
|
||||
...sessionWithAcceptedWorkerSet(),
|
||||
slots: {
|
||||
...sessionWithAcceptedWorkerSet().slots,
|
||||
uiLifecycleStage: "play",
|
||||
},
|
||||
};
|
||||
const decl = buildInstanceWorkerDeclaration(session, bb, "play");
|
||||
expect(decl.playWorkerIds).toEqual(["world-simulator", "narrator"]);
|
||||
expect(decl.activeWorkerIds).toEqual(["world-simulator", "narrator"]);
|
||||
expect(decl.designEndWorkerIds).toEqual(["opening-generator"]);
|
||||
expect(isWorkerDeclared(decl, "narrator")).toBe(true);
|
||||
expect(isWorkerDeclared(decl, "design-flow")).toBe(false);
|
||||
expect(isWorkerDeclared(decl, "opening-generator")).toBe(false);
|
||||
});
|
||||
|
||||
it("design after accept: design steps + design-end workers", () => {
|
||||
const bb = new Blackboard();
|
||||
bb.write({ tag: "设计.worker集", content: WORKER_SET, source: "test" });
|
||||
const decl = buildInstanceWorkerDeclaration(
|
||||
sessionWithAcceptedWorkerSet(),
|
||||
bb,
|
||||
"design",
|
||||
);
|
||||
expect(decl.activeWorkerIds).toContain("design-flow");
|
||||
expect(decl.activeWorkerIds).toContain("design-step");
|
||||
expect(decl.activeWorkerIds).toContain("opening-generator");
|
||||
expect(decl.activeWorkerIds).not.toContain("world-simulator");
|
||||
});
|
||||
|
||||
it("prefers accepted tag over draft when worker set accepted", () => {
|
||||
const bb = new Blackboard();
|
||||
bb.write({ tag: "设计.worker集", content: WORKER_SET, source: "test" });
|
||||
bb.write({ tag: "设计.worker集.草稿", content: "workers: []", source: "test" });
|
||||
const raw = readWorkerSetYamlForDeclaration(bb, sessionWithAcceptedWorkerSet());
|
||||
expect(raw?.sourceTag).toBe("设计.worker集");
|
||||
expect(raw?.yaml).toContain("world-simulator");
|
||||
});
|
||||
|
||||
it("inferLifecycleStage respects play tab and acceptance", () => {
|
||||
const bb = new Blackboard();
|
||||
const s1 = {
|
||||
...createSession(),
|
||||
slots: { ...createSession().slots, uiLifecycleStage: "play" },
|
||||
};
|
||||
expect(inferLifecycleStage(s1)).toBe("design");
|
||||
|
||||
bb.write({ tag: "设计.worker集", content: WORKER_SET, source: "test" });
|
||||
const s2 = {
|
||||
...sessionWithAcceptedWorkerSet(),
|
||||
slots: {
|
||||
...sessionWithAcceptedWorkerSet().slots,
|
||||
uiLifecycleStage: "play",
|
||||
},
|
||||
};
|
||||
expect(inferLifecycleStage(s2)).toBe("play");
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe("resolveWorkerLlmProvider", () => {
|
||||
it("returns fallback when no binding", () => {
|
||||
const worker: ParsedWorkerSkill = {
|
||||
id: "role-decide",
|
||||
skill: "roleplay-game-theory",
|
||||
skill: "world-simulator",
|
||||
name: "x",
|
||||
description: "",
|
||||
version: 1,
|
||||
@@ -73,7 +73,7 @@ describe("resolveWorkerLlmProvider", () => {
|
||||
it("uses byRole when bindings match", () => {
|
||||
const worker: ParsedWorkerSkill = {
|
||||
id: "role-decide",
|
||||
skill: "roleplay-game-theory",
|
||||
skill: "world-simulator",
|
||||
name: "x",
|
||||
description: "",
|
||||
version: 1,
|
||||
|
||||
124
tests/worker-set-parse.test.ts
Normal file
124
tests/worker-set-parse.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveDesignStageScope,
|
||||
deriveInstantiateScope,
|
||||
deriveReviewWorkerScope,
|
||||
deriveRunWorkerScope,
|
||||
parseWorkerSetYaml,
|
||||
} from "../src/skills/worker-set-parse.js";
|
||||
|
||||
const SAMPLE = `
|
||||
version: 1
|
||||
form_summary: 西幻升级交互,带状态跟踪
|
||||
play_morphology: action_reaction_loop
|
||||
workers:
|
||||
- ref: world-simulator
|
||||
duty: 世界推进与裁决
|
||||
when: 每轮用户输入后
|
||||
presentation: null
|
||||
- ref: narrator
|
||||
duty: 组装用户可见回复
|
||||
when: 中间产物齐后
|
||||
presentation:
|
||||
tone: 冷感
|
||||
pacing: 中等
|
||||
avoid: [冗长环境描写]
|
||||
- ref: variable-update
|
||||
duty: 跟踪等级与资源
|
||||
when: 每轮结束后
|
||||
- ref: opening-generator
|
||||
duty: 填初始等级职业与开场白
|
||||
when: Worker 集 accept 后、进 play 前
|
||||
instantiate_hints:
|
||||
invoke:
|
||||
- opening-generator
|
||||
skip: []
|
||||
skip_reason: ""
|
||||
notes: 需要 swipe 选定开局
|
||||
tag_flow:
|
||||
- "用户.最新输入 → 运行.本轮.拓写"
|
||||
open_questions: []
|
||||
notes: ""
|
||||
`;
|
||||
|
||||
describe("parseWorkerSetYaml", () => {
|
||||
it("parses worker set structure", () => {
|
||||
const parsed = parseWorkerSetYaml(SAMPLE);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.form_summary).toContain("西幻");
|
||||
expect(parsed?.workers).toHaveLength(4);
|
||||
expect(parsed?.workers[1].ref).toBe("narrator");
|
||||
expect(parsed?.workers[1].presentation?.tone).toBe("冷感");
|
||||
expect(parsed?.instantiate_hints?.invoke).toContain("opening-generator");
|
||||
expect(parsed?.instantiate_hints?.notes).toContain("swipe");
|
||||
});
|
||||
|
||||
it("parses JSON worker set (preferred format)", () => {
|
||||
const json = JSON.stringify({
|
||||
version: 1,
|
||||
interaction: {
|
||||
user_stance: "单角代入",
|
||||
system_role: "世界执行+叙事",
|
||||
turn_shape: "对话回合",
|
||||
},
|
||||
experience_check: {
|
||||
satisfaction_source: "关系推进",
|
||||
},
|
||||
workers: [
|
||||
{
|
||||
ref: "world-simulator",
|
||||
duty: "推进",
|
||||
rationale: "需要世界结果",
|
||||
acceptance: "continue",
|
||||
},
|
||||
{
|
||||
ref: "narrator",
|
||||
duty: "展示",
|
||||
rationale: "需要可读终稿",
|
||||
acceptance: "review",
|
||||
},
|
||||
],
|
||||
design_end: { opening: "optional" },
|
||||
narrative_guide: "不有求必应",
|
||||
core_premises: ["主角免疫"],
|
||||
});
|
||||
const parsed = parseWorkerSetYaml(json);
|
||||
expect(parsed?.parseError).toBeUndefined();
|
||||
expect(parsed?.interaction?.user_stance).toBe("单角代入");
|
||||
expect(parsed?.workers).toHaveLength(2);
|
||||
expect(parsed?.workers[0].acceptance).toBe("continue");
|
||||
expect(parsed?.workers[1].acceptance).toBe("review");
|
||||
expect(parsed?.narrative_guide).toBe("不有求必应");
|
||||
expect(parsed?.core_premises).toEqual(["主角免疫"]);
|
||||
expect(deriveDesignStageScope(parsed)).toEqual(["opening-generator"]);
|
||||
expect(deriveReviewWorkerScope(parsed)).toEqual(["narrator"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveDesignStageScope", () => {
|
||||
it("uses instantiate_hints.invoke and design-end worker refs", () => {
|
||||
const workerSet = parseWorkerSetYaml(SAMPLE)!;
|
||||
const scope = deriveDesignStageScope(workerSet);
|
||||
expect(scope).toEqual(["opening-generator"]);
|
||||
expect(scope).not.toContain("world-blueprint");
|
||||
expect(scope).not.toContain("variable-catalog");
|
||||
});
|
||||
|
||||
it("deriveInstantiateScope aliases deriveDesignStageScope", () => {
|
||||
const workerSet = parseWorkerSetYaml(SAMPLE)!;
|
||||
expect(deriveInstantiateScope(workerSet)).toEqual(
|
||||
deriveDesignStageScope(workerSet),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRunWorkerScope", () => {
|
||||
it("lists play run worker refs excluding opening-generator", () => {
|
||||
const workerSet = parseWorkerSetYaml(SAMPLE)!;
|
||||
expect(deriveRunWorkerScope(workerSet)).toEqual([
|
||||
"world-simulator",
|
||||
"narrator",
|
||||
"variable-update",
|
||||
]);
|
||||
});
|
||||
});
|
||||
88
tests/worker-set-sanitize.test.ts
Normal file
88
tests/worker-set-sanitize.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractJsonObjectText,
|
||||
isUsableWorkerSet,
|
||||
looksLikeProseNotSpec,
|
||||
parseWorkerSetYaml,
|
||||
} from "../src/skills/worker-set-parse.js";
|
||||
import {
|
||||
parseWorkerResponseForTest,
|
||||
sanitizeWorkerSetOutputs,
|
||||
} from "../src/worker/executor.js";
|
||||
|
||||
describe("worker-set prose rejection", () => {
|
||||
it("does not YAML-crash on Chinese prose; returns clear parseError", () => {
|
||||
const prose =
|
||||
"我们被要求输出 JSON。首先,根据用户输入和 SKILL,进行实例设计。用户需求是:扮演一个坠机的幸存者…";
|
||||
expect(looksLikeProseNotSpec(prose)).toBe(true);
|
||||
const parsed = parseWorkerSetYaml(prose);
|
||||
expect(parsed?.parseError).toMatch(/不是 JSON 规格|askUser/);
|
||||
expect(parsed?.parseError).not.toMatch(/Implicit keys/);
|
||||
});
|
||||
|
||||
it("extracts JSON object from surrounding prose", () => {
|
||||
const raw = `说明如下:\n\`\`\`json\n{"version":1,"workers":[{"ref":"narrator","duty":"转述","acceptance":"review"}]}\n\`\`\``;
|
||||
const extracted = extractJsonObjectText(raw);
|
||||
expect(extracted?.startsWith("{")).toBe(true);
|
||||
expect(isUsableWorkerSet(parseWorkerSetYaml(extracted!))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeWorkerSetOutputs", () => {
|
||||
it("converts prose-in-draft to askUser and clears bad tag", () => {
|
||||
const result = sanitizeWorkerSetOutputs({
|
||||
outputs: {
|
||||
"设计.worker集.草稿":
|
||||
"坠机后伤势如何?\n请描述你的初始状态:轻伤还是重伤?\n也可以让我随机生成。",
|
||||
},
|
||||
summary: "提问",
|
||||
preview: "…",
|
||||
});
|
||||
expect(result.outputs["设计.worker集.草稿"]).toBeUndefined();
|
||||
expect(result.askUser?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps valid JSON draft", () => {
|
||||
const json = JSON.stringify({
|
||||
version: 1,
|
||||
workers: [{ ref: "world-simulator", duty: "世界", acceptance: "continue" }],
|
||||
});
|
||||
const result = sanitizeWorkerSetOutputs({
|
||||
outputs: { "设计.worker集.草稿": json },
|
||||
summary: "草案",
|
||||
preview: json,
|
||||
});
|
||||
expect(result.outputs["设计.worker集.草稿"]).toContain("world-simulator");
|
||||
expect(result.askUser).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseWorkerResponse does not dump raw prose into worker-set tags", () => {
|
||||
const raw =
|
||||
"我们被要求输出 JSON。请描述你的初始状态?伤势如何?";
|
||||
const result = parseWorkerResponseForTest(raw, [
|
||||
"设计.worker集.草稿",
|
||||
"设计.worker集",
|
||||
"用户.需求",
|
||||
]);
|
||||
expect(result.outputs["设计.worker集.草稿"]).toBeUndefined();
|
||||
expect(result.askUser?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("accepts nested object outputs for worker set", () => {
|
||||
const result = parseWorkerResponseForTest(
|
||||
JSON.stringify({
|
||||
outputs: {
|
||||
"设计.worker集.草稿": {
|
||||
version: 1,
|
||||
workers: [{ ref: "narrator", duty: "转述", acceptance: "review" }],
|
||||
},
|
||||
},
|
||||
summary: "ok",
|
||||
askUser: null,
|
||||
}),
|
||||
["设计.worker集.草稿"],
|
||||
);
|
||||
expect(result.outputs["设计.worker集.草稿"]).toContain("narrator");
|
||||
expect(result.askUser).toBeUndefined();
|
||||
});
|
||||
});
|
||||
162
tests/worker-set-view.test.ts
Normal file
162
tests/worker-set-view.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js";
|
||||
import { formatWorkerSetForUser } from "../src/skills/worker-set-view.js";
|
||||
|
||||
const FULL_SAMPLE = `
|
||||
version: 1
|
||||
form_summary: 西幻升级交互,带状态跟踪
|
||||
play_morphology: action_reaction_loop
|
||||
workers:
|
||||
- ref: world-simulator
|
||||
duty: 世界推进与裁决
|
||||
when: 每轮用户输入后
|
||||
context:
|
||||
static:
|
||||
- 设计.worker集
|
||||
- 世界.蓝图.确认稿
|
||||
dynamic:
|
||||
- 用户.最新输入
|
||||
- 运行.事件流
|
||||
outputs:
|
||||
- 运行.本轮.裁决
|
||||
- 运行.事件流
|
||||
- ref: narrator
|
||||
duty: 组装用户可见回复
|
||||
when: 中间产物齐后
|
||||
presentation:
|
||||
tone: 西幻
|
||||
avoid: [空泛公告]
|
||||
context:
|
||||
static:
|
||||
- 叙事.指南.确认稿
|
||||
dynamic:
|
||||
- 运行.事件流
|
||||
outputs:
|
||||
- 输出.用户展示
|
||||
instantiate_hints:
|
||||
invoke: [opening-generator]
|
||||
tag_flow:
|
||||
- "用户.最新输入 → 运行.本轮.裁决"
|
||||
- "运行.本轮.裁决 → 输出.用户展示"
|
||||
`;
|
||||
|
||||
describe("formatWorkerSetForUser", () => {
|
||||
it("builds Chinese worker cards with context order", () => {
|
||||
const parsed = parseWorkerSetYaml(FULL_SAMPLE)!;
|
||||
const view = formatWorkerSetForUser(parsed)!;
|
||||
expect(view.headline).toContain("西幻");
|
||||
expect(view.playModeLabel).toBe("行动–反应循环");
|
||||
expect(view.workers).toHaveLength(2);
|
||||
expect(view.workers[0].displayName).toBe("世界模拟");
|
||||
expect(view.creationUnits?.map((u) => u.id)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"fixed:aesthetics", // sample 含 narrator.presentation
|
||||
"worker:world-simulator",
|
||||
"worker:narrator",
|
||||
]),
|
||||
);
|
||||
// 未写入的示例话题不出现(不是填空表)
|
||||
expect(view.creationUnits?.some((u) => u.id === "fixed:input_protocol")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(view.workers[0].context.staticTags[0].tag).toBe("设计.worker集");
|
||||
expect(view.workers[0].writes).toEqual(["运行.本轮.裁决", "运行.事件流"]);
|
||||
expect(view.workers[0].context.explicit).toBe(true);
|
||||
expect(view.workers[1].presentation?.[0].label).toBe("语气");
|
||||
expect(view.tagFlow).toHaveLength(2);
|
||||
expect(view.designTasks).toHaveLength(1);
|
||||
expect(view.designTasks[0].id).toBe("opening-generator");
|
||||
});
|
||||
|
||||
it("exposes contextTags as standalone cards with mount targets", () => {
|
||||
const parsed = parseWorkerSetYaml(`
|
||||
narrative_guide: 残酷、不有求必应
|
||||
resident_context:
|
||||
- id: tone
|
||||
content: 文风克制
|
||||
mount: [narrator]
|
||||
workers:
|
||||
- ref: world-simulator
|
||||
duty: 裁决
|
||||
- ref: narrator
|
||||
duty: 转述
|
||||
presentation:
|
||||
tone: 冷峻
|
||||
`)!;
|
||||
const view = formatWorkerSetForUser(parsed)!;
|
||||
expect(view.contextTags?.length).toBeGreaterThanOrEqual(2);
|
||||
const guide = view.contextTags?.find((c) => c.id === "fixed:narrative_guide");
|
||||
expect(guide?.mountSummary).toBe("全部 Worker");
|
||||
expect(guide?.mounts.map((m) => m.workerId).sort()).toEqual([
|
||||
"narrator",
|
||||
"world-simulator",
|
||||
]);
|
||||
const tone = view.contextTags?.find((c) => c.id === "resident:tone");
|
||||
expect(tone?.mounts).toHaveLength(1);
|
||||
expect(tone?.mounts[0].workerId).toBe("narrator");
|
||||
expect(tone?.mountSummary).toBe(tone!.mounts[0].workerName);
|
||||
const aesthetics = view.contextTags?.find((c) =>
|
||||
c.id.startsWith("fixed:aesthetics"),
|
||||
);
|
||||
expect(aesthetics?.mounts[0].workerId).toBe("narrator");
|
||||
expect(view.workers[1].readsSummary).toMatch(/美学|叙事|常驻/);
|
||||
});
|
||||
|
||||
it("surfaces interaction paradigm, core worker, and rationale", () => {
|
||||
const parsed = parseWorkerSetYaml(`
|
||||
form_summary: 囚徒困境旁观
|
||||
interaction_paradigm: 旁观多角(推断)
|
||||
core_worker: world-simulator
|
||||
reasoning: 三个角色需信息隔绝,各用 role-decide;陈述用 Markdown,不必文学转述。
|
||||
workers:
|
||||
- ref: world-simulator
|
||||
role: core
|
||||
rationale: 裁决组合结果,兼任结构化陈述。
|
||||
- ref: role-decide
|
||||
role: auxiliary
|
||||
rationale: 仅 3 个参与者,隔绝决策上下文。
|
||||
merge_considered: 不为每个 NPC 各开一个 worker。
|
||||
`)!;
|
||||
const view = formatWorkerSetForUser(parsed)!;
|
||||
expect(view.interactionParadigm).toContain("旁观");
|
||||
expect(view.coreWorker).toBe("world-simulator");
|
||||
expect(view.reasoning).toContain("信息隔绝");
|
||||
expect(view.workers[0].roleLabel).toBe("核心");
|
||||
expect(view.workers[0].rationale).toContain("裁决");
|
||||
expect(view.workers[1].mergeConsidered).toContain("NPC");
|
||||
});
|
||||
|
||||
it("merges default context when explicit context omitted", () => {
|
||||
const parsed = parseWorkerSetYaml(`
|
||||
workers:
|
||||
- ref: variable-update
|
||||
duty: 跟踪变量
|
||||
when: 世界机之后
|
||||
`)!;
|
||||
const view = formatWorkerSetForUser(parsed)!;
|
||||
expect(view.workers[0].context.staticTags.some((t) => t.tag.includes("变量.目录"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(view.workers[0].context.explicit).toBe(false);
|
||||
expect(view.workers[0].writes).toContain("变量.当前");
|
||||
});
|
||||
|
||||
it("marks design tasks filled when blackboard has tags", () => {
|
||||
const parsed = parseWorkerSetYaml(`
|
||||
workers:
|
||||
- ref: narrator
|
||||
instantiate_hints:
|
||||
invoke: [opening-generator]
|
||||
`)!;
|
||||
const view = formatWorkerSetForUser(parsed, {
|
||||
filledTags: ["运行.初始变量", "输出.开场白"],
|
||||
})!;
|
||||
expect(view.designTasks[0].status).toBe("filled");
|
||||
});
|
||||
|
||||
it("parses context and outputs fields", () => {
|
||||
const parsed = parseWorkerSetYaml(FULL_SAMPLE)!;
|
||||
expect(parsed.workers[0].context?.static).toContain("世界.蓝图.确认稿");
|
||||
expect(parsed.workers[0].outputs).toContain("运行.事件流");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user