接通主角绑定、开场封印与游玩辅助,让世界模拟器从创作连续落到本局。
直聘插入按模块回馈,并记录请求骨架便于排查。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
formatFlowProgressForAgent,
|
||||
listCallableCatalogModules,
|
||||
formatModuleCatalogForAgent,
|
||||
formatModuleCatalogForDictate,
|
||||
catalogModulesForIntake,
|
||||
formatRecipeCatalogForAgent,
|
||||
formatDictateRecipeBrief,
|
||||
formatSelectedRecipeForAgent,
|
||||
@@ -442,6 +444,7 @@ describe("creation-flow", () => {
|
||||
expect(catalog?.modules.some((m) => m.name === "美学纲领与交互范式")).toBe(
|
||||
true,
|
||||
);
|
||||
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(
|
||||
@@ -480,16 +483,52 @@ describe("creation-flow", () => {
|
||||
expect(block).toContain("role=prototype");
|
||||
expect(block).toContain("编排参数");
|
||||
expect(block).not.toContain("设计.美学纲领与交互范式");
|
||||
expect(block).toContain("- 叙事指南与故事推进:");
|
||||
expect(block).not.toMatch(/^- 叙事指南:/m);
|
||||
expect(block).not.toMatch(/^- 故事推进:/m);
|
||||
const dictateBlock = formatModuleCatalogForDictate(catalog!);
|
||||
expect(dictateBlock).toMatch(/^- 叙事指南:/m);
|
||||
expect(dictateBlock).toMatch(/^- 故事推进:/m);
|
||||
expect(dictateBlock).not.toMatch(/^- 叙事指南与故事推进/m);
|
||||
expect(
|
||||
catalog?.modules.find((m) => m.name === "叙事指南与故事推进")?.intake,
|
||||
).toBe("recipe");
|
||||
expect(catalog?.modules.find((m) => m.name === "叙事指南")?.intake).toBe(
|
||||
"dictate",
|
||||
);
|
||||
expect(catalog?.modules.find((m) => m.name === "故事推进")?.intake).toBe(
|
||||
"dictate",
|
||||
);
|
||||
expect(
|
||||
catalogModulesForIntake(catalog!, "recipe").some(
|
||||
(m) => m.name === "叙事指南",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
catalogModulesForIntake(catalog!, "dictate").some(
|
||||
(m) => m.name === "叙事指南与故事推进",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const leftover = listCallableCatalogModules({ catalog: catalog! });
|
||||
expect(leftover.some((m) => m.name === "叙事指南与故事推进")).toBe(true);
|
||||
expect(leftover.some((m) => m.name === "叙事指南")).toBe(false);
|
||||
expect(leftover.some((m) => m.name === "故事推进")).toBe(false);
|
||||
|
||||
const splitFlow = parseCreationFlow(`{
|
||||
"steps": [{ "name": "叙事指南", "depends_on": [] }]
|
||||
}`)!;
|
||||
expect(validateCreationFlow(splitFlow, catalog).ok).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(recipes?.recipes.some((r) => r.name === "扩写助手")).toBe(true);
|
||||
expect(recipes?.recipes.some((r) => r.name === "文本生成器")).toBe(false);
|
||||
const block = formatRecipeCatalogForAgent(recipes!);
|
||||
expect(block).toContain("须由用户手动选择");
|
||||
expect(block).toContain("世界模拟器");
|
||||
expect(block).toContain("回合推演");
|
||||
|
||||
const details = await loadAllRecipeDetails("dialogue/world-simulator");
|
||||
expect(details.length).toBeGreaterThanOrEqual(2);
|
||||
@@ -519,7 +558,9 @@ describe("creation-flow", () => {
|
||||
expect(dictate?.recipes.some((r) => r.name === "文本生成器")).toBe(true);
|
||||
expect(dictate?.recipes.some((r) => r.name === "交互式长文生成器")).toBe(true);
|
||||
expect(dictate?.recipes.some((r) => r.name === "数据化跑团体验")).toBe(true);
|
||||
expect(dictate?.recipes[0]?.id).toBe("数据化跑团体验");
|
||||
expect(dictate?.recipes.some((r) => r.id === "快穿短局")).toBe(true);
|
||||
expect(dictate?.recipes.some((r) => r.name === "快穿短局")).toBe(true);
|
||||
expect(dictate?.recipes[0]?.id).toBe("快穿短局");
|
||||
const entry = dictate!.recipes.find((r) => r.id === "文本生成器")!;
|
||||
expect(entry.family).toBe("dictate");
|
||||
const detail = await loadRecipeDetail("dialogue/world-simulator", entry);
|
||||
@@ -540,6 +581,15 @@ describe("creation-flow", () => {
|
||||
expect(formatDictateRecipeBrief(rpgDetail)).toContain("declare_variable");
|
||||
expect(formatDictateRecipeBrief(rpgDetail)).toContain("何时落盘");
|
||||
expect(formatDictateRecipeBrief(rpgDetail)).toMatch(/美学纲领|体验核心/);
|
||||
|
||||
const skip = dictate!.recipes.find((r) => r.id === "快穿短局")!;
|
||||
const skipDetail = await loadRecipeDetail("dialogue/world-simulator", skip);
|
||||
expect(skipDetail.seed).toBeNull();
|
||||
expect(skipDetail.brief).toMatch(/一核|短局|主角/);
|
||||
expect(skipDetail.core).toMatch(/一核|开场白|黑板/);
|
||||
expect(skipDetail.when).toMatch(/快穿|一次性|短任务/);
|
||||
expect(formatDictateRecipeBrief(skipDetail)).toMatch(/舞台骨架|生成规则|拓扑/);
|
||||
expect(formatDictateRecipeBrief(skipDetail)).toMatch(/回合推演|数据化跑团体验/);
|
||||
});
|
||||
|
||||
it("parses selected recipe ref", () => {
|
||||
@@ -649,8 +699,8 @@ recipes:
|
||||
{ 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).toContain("美学纲领与交互范式");
|
||||
expect(selected.promptBody).toContain("核心思路");
|
||||
|
||||
@@ -16,6 +16,8 @@ describe("dictate play-bind", () => {
|
||||
expect(isDictateStyleBindTag("设计.正文组成")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.模仿范例")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.模仿要点")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.叙事指南")).toBe(true);
|
||||
expect(isDictateStyleBindTag("设计.故事推进")).toBe(true);
|
||||
expect(isDictateStyleBindTag("用户.需求")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -107,7 +109,13 @@ describe("dictate play-bind", () => {
|
||||
metadata: { [DICTATE_ORDER_META_KEY]: -28 },
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.叙事指南与故事推进",
|
||||
tag: "设计.叙事指南",
|
||||
content: "冷硬短句,笔墨落在权力交换。",
|
||||
source: "dictate",
|
||||
metadata: { [DICTATE_ORDER_META_KEY]: -20 },
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.故事推进",
|
||||
content: "用户输入用法:大纲扩写。禁止扮演停笔。",
|
||||
source: "dictate",
|
||||
metadata: { [DICTATE_ORDER_META_KEY]: -22 },
|
||||
@@ -132,11 +140,11 @@ describe("dictate play-bind", () => {
|
||||
const narrRefs = narr!.inserts.map((i) => i.ref);
|
||||
expect(narrRefs).toContain("设计.模仿范例");
|
||||
expect(narrRefs).toContain("设计.模仿要点");
|
||||
expect(narrRefs).toContain("设计.叙事指南与故事推进");
|
||||
expect(narrRefs).toContain("设计.叙事指南");
|
||||
expect(narrRefs).toContain("设计.故事推进");
|
||||
expect(narrRefs).not.toContain("用户.需求");
|
||||
expect(JSON.parse(result.workerSetJson).play_slots.narrator).toBe(true);
|
||||
// 落档不改写创作期叙事指南
|
||||
expect(board.getContentByTag("设计.叙事指南与故事推进")).toBe(
|
||||
expect(board.getContentByTag("设计.故事推进")).toBe(
|
||||
"用户输入用法:大纲扩写。禁止扮演停笔。",
|
||||
);
|
||||
});
|
||||
|
||||
+476
-4
@@ -2,16 +2,23 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDictateMessages,
|
||||
buildDictateSystemPrompt,
|
||||
buildDictateInsertFeedbackIndex,
|
||||
defaultDictateOrder,
|
||||
extractDictateDialogue,
|
||||
formatDictateUserFacingBrief,
|
||||
isAllowedProductTag,
|
||||
isDictateModeValue,
|
||||
resolveModuleForInsertTag,
|
||||
runDictateTurn,
|
||||
sortDictateProducts,
|
||||
resolveRepeatableInsertTag,
|
||||
lookupDictateInsertFeedback,
|
||||
} from "../src/dictate/index.js";
|
||||
import {
|
||||
formatModuleCatalogForDictate,
|
||||
loadModuleCatalog,
|
||||
} from "../src/skills/creation-flow.js";
|
||||
import type { AgentDriver } from "../src/runtime/driver.js";
|
||||
|
||||
describe("dictate context", () => {
|
||||
it("assembles full dialogue + products with almost no filtering", () => {
|
||||
@@ -78,21 +85,37 @@ describe("dictate context", () => {
|
||||
expect(prompt).toContain("设计.开场白");
|
||||
expect(prompt).toContain("输出.开场白");
|
||||
expect(prompt).toContain("insert");
|
||||
expect(prompt).toContain("delete");
|
||||
expect(prompt).toContain("declare_variable");
|
||||
expect(prompt).toContain("undeclare_variable");
|
||||
expect(prompt).toContain("declare_map");
|
||||
expect(prompt).toContain("remove_map");
|
||||
expect(prompt).toContain("同 position 再 insert");
|
||||
expect(prompt).toContain("数据化跑团体验");
|
||||
expect(prompt).toMatch(/order/);
|
||||
expect(prompt).toContain("仅允许 toolcall");
|
||||
expect(prompt).toContain("设计.模仿范例");
|
||||
expect(prompt).toContain("设计.模仿要点");
|
||||
expect(prompt).toContain("设计.叙事指南与故事推进");
|
||||
expect(prompt).toContain("设计.故事推进");
|
||||
expect(prompt).toContain("设计.叙事指南");
|
||||
expect(prompt).toContain("大纲扩写");
|
||||
expect(prompt).toContain("禁止扮演停笔");
|
||||
expect(prompt).toContain("扮演停笔关掉");
|
||||
expect(prompt).toContain("只挂载这些产物");
|
||||
expect(prompt).toContain("【能力 · 何时落盘】");
|
||||
expect(prompt).toContain("设计.美学纲领与交互范式");
|
||||
expect(prompt).toContain("每轮通常只推进一刀");
|
||||
expect(prompt).toContain("勿因配方名叫数据化就抢跑 D");
|
||||
expect(prompt).toContain("同轮可多次 insert");
|
||||
expect(prompt).toContain("不抢跑未齐条件的下游");
|
||||
expect(prompt).toContain("也不自动开跑 D");
|
||||
expect(prompt).toContain("快穿短局");
|
||||
expect(prompt).toContain("一核短局");
|
||||
expect(prompt).toMatch(/舞台骨架/);
|
||||
expect(prompt).toMatch(/覆盖 C/);
|
||||
expect(prompt).toContain("设计.主角设定");
|
||||
expect(prompt).toContain("reply_module");
|
||||
expect(prompt).toContain("对用户可见回复");
|
||||
expect(prompt).toContain("有 N 个 reply_module");
|
||||
expect(prompt).toContain("〔可反复〕须拆分");
|
||||
expect(prompt).toContain("设计.具体实例#");
|
||||
});
|
||||
|
||||
it("formats module catalog for dictate with land tags and when", async () => {
|
||||
@@ -107,7 +130,13 @@ describe("dictate context", () => {
|
||||
expect(block).toContain("declare_variable");
|
||||
expect(block).toContain("设计.开场白");
|
||||
expect(block).toContain("〔落档程序〕");
|
||||
expect(block).toContain("设计.生成规则#");
|
||||
expect(block).toContain("设计.具体实例#");
|
||||
expect(block).toContain("可增殖");
|
||||
expect(block).not.toContain("role=prototype");
|
||||
expect(block).toContain("落盘:insert 「设计.叙事指南」");
|
||||
expect(block).toContain("落盘:insert 「设计.故事推进」");
|
||||
expect(block).not.toMatch(/^- 叙事指南与故事推进/m);
|
||||
});
|
||||
|
||||
it("sorts products by relative order: neg then 0 then pos", () => {
|
||||
@@ -146,4 +175,447 @@ describe("dictate context", () => {
|
||||
expect(body).toContain("order=-10");
|
||||
expect(body).toContain("order=10");
|
||||
});
|
||||
|
||||
it("resolves insert tag to module and builds user-facing brief", async () => {
|
||||
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||
expect(catalog).toBeTruthy();
|
||||
const mod = resolveModuleForInsertTag(
|
||||
"设计.美学纲领与交互范式",
|
||||
catalog!,
|
||||
);
|
||||
expect(mod?.id).toBe("aesthetics-interaction");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.开场白", catalog!)?.id,
|
||||
).toBe("opening-setup");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id,
|
||||
).toBe("concrete-instances");
|
||||
expect(
|
||||
resolveModuleForInsertTag("设计.生成规则#ex-girlfriend-card", catalog!)?.id,
|
||||
).toBe("generation-rules");
|
||||
expect(resolveModuleForInsertTag("设计.叙事指南", catalog!)?.id).toBe(
|
||||
"narrative-guide",
|
||||
);
|
||||
expect(resolveModuleForInsertTag("设计.故事推进", catalog!)?.id).toBe(
|
||||
"story-progression",
|
||||
);
|
||||
expect(resolveModuleForInsertTag("设计.文风", catalog!)?.id).toBe(
|
||||
"narrative-guide",
|
||||
);
|
||||
|
||||
const index = await buildDictateInsertFeedbackIndex({
|
||||
catalog: catalog!,
|
||||
skillPackRoot: "dialogue/world-simulator",
|
||||
});
|
||||
const brief = index.get("设计.美学纲领与交互范式");
|
||||
expect(brief).toBeTruthy();
|
||||
expect(brief!).toContain("# 模块 · 美学纲领与交互范式");
|
||||
expect(brief!).toContain("回答时请附带本模块");
|
||||
expect(brief!).toContain("本模块内容包括");
|
||||
expect(brief!).toContain("## 写入用户回复时做什么");
|
||||
expect(brief!).toContain("## 探测与追问(本模块口径)");
|
||||
expect(brief!).toContain("展示优于提问");
|
||||
expect(brief!).toMatch(/自评关注|交互范式|美学纲领/);
|
||||
|
||||
const alias = index.get("设计.开场白");
|
||||
expect(alias).toContain("开场白与开场变量");
|
||||
});
|
||||
|
||||
it("splits repeatable insert tags and rejects bare overwrite", async () => {
|
||||
const families = new Set(["设计.生成规则", "设计.具体实例"]);
|
||||
const instanceBody = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "具体实例",
|
||||
正文: {
|
||||
rule_id: "ex-girlfriend-card",
|
||||
batch_id: "lei-ying",
|
||||
records: [{ 姓名: "雷樱" }],
|
||||
},
|
||||
});
|
||||
const first = resolveRepeatableInsertTag({
|
||||
tag: "设计.具体实例",
|
||||
content: instanceBody,
|
||||
existingTags: [],
|
||||
repeatableFamilies: families,
|
||||
});
|
||||
expect("tag" in first && first.tag).toBe("设计.具体实例#lei-ying");
|
||||
|
||||
const second = resolveRepeatableInsertTag({
|
||||
tag: "设计.具体实例",
|
||||
content: JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: { records: [{ 姓名: "谢明澜" }] },
|
||||
}),
|
||||
existingTags: ["设计.具体实例#lei-ying"],
|
||||
repeatableFamilies: families,
|
||||
});
|
||||
expect("tag" in second && second.tag).toBe("设计.具体实例#谢明澜");
|
||||
|
||||
const overwriteBare = resolveRepeatableInsertTag({
|
||||
tag: "设计.具体实例",
|
||||
content: "不是JSON就抽不出槽",
|
||||
existingTags: ["设计.具体实例#lei-ying"],
|
||||
repeatableFamilies: families,
|
||||
});
|
||||
expect("error" in overwriteBare).toBe(true);
|
||||
|
||||
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||
const index = await buildDictateInsertFeedbackIndex({
|
||||
catalog: catalog!,
|
||||
skillPackRoot: "dialogue/world-simulator",
|
||||
});
|
||||
expect(
|
||||
lookupDictateInsertFeedback(index, "设计.具体实例#lei-ying"),
|
||||
).toContain("具体实例");
|
||||
});
|
||||
|
||||
it("runDictateTurn remaps bare repeatable insert to #slot", async () => {
|
||||
const products: { tag: string; content: string }[] = [];
|
||||
const body = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: {
|
||||
batch_id: "lei-ying",
|
||||
records: [{ 姓名: "雷樱" }],
|
||||
},
|
||||
});
|
||||
const driver: AgentDriver = {
|
||||
async run(input) {
|
||||
const outcome = await input.handleStep([
|
||||
{
|
||||
id: "c1",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.具体实例",
|
||||
content: body,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(outcome.kind).toBe("continue");
|
||||
if (outcome.kind === "continue") {
|
||||
const payload = JSON.parse(outcome.results[0]!.content) as {
|
||||
ok?: boolean;
|
||||
position?: string;
|
||||
error?: string;
|
||||
};
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.position).toBe("设计.具体实例#lei-ying");
|
||||
}
|
||||
return {
|
||||
iterations: 1,
|
||||
stop: { kind: "text", content: "已落实例" },
|
||||
};
|
||||
},
|
||||
};
|
||||
const result = await runDictateTurn({
|
||||
llm: {} as never,
|
||||
dialogue: [{ role: "user", text: "再生成一张雷樱" }],
|
||||
driver,
|
||||
repeatableFamilies: ["设计.具体实例", "设计.生成规则"],
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(result.wroteTags).toEqual(["设计.具体实例#lei-ying"]);
|
||||
expect(products.map((p) => p.tag)).toEqual(["设计.具体实例#lei-ying"]);
|
||||
});
|
||||
|
||||
it("insert tool result includes reply_module from lookup", async () => {
|
||||
let capturedSpec: string | undefined;
|
||||
const driver: AgentDriver = {
|
||||
async run(input) {
|
||||
const outcome = await input.handleStep([
|
||||
{
|
||||
id: "c1",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.美学纲领与交互范式",
|
||||
content: "傀儡帝体验核",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(outcome.kind).toBe("continue");
|
||||
if (outcome.kind === "continue") {
|
||||
const payload = JSON.parse(outcome.results[0]!.content) as {
|
||||
ok?: boolean;
|
||||
reply_module?: string;
|
||||
};
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.reply_module).toContain("回答时请附带本模块");
|
||||
capturedSpec = payload.reply_module;
|
||||
}
|
||||
return {
|
||||
iterations: 1,
|
||||
stop: { kind: "text", content: "已落美学;下一刀建议主角名片。" },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const products: { tag: string; content: string }[] = [];
|
||||
const result = await runDictateTurn({
|
||||
llm: {} as never,
|
||||
dialogue: [{ role: "user", text: "傀儡帝恋爱" }],
|
||||
driver,
|
||||
handlers: {
|
||||
listProducts: () => products,
|
||||
writeProduct: (tag, content) => {
|
||||
products.push({ tag, content });
|
||||
},
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
lookupInsertFeedback: () =>
|
||||
formatDictateUserFacingBrief({
|
||||
tag: "设计.美学纲领与交互范式",
|
||||
module: {
|
||||
id: "aesthetics-interaction",
|
||||
name: "美学纲领与交互范式",
|
||||
declaration: "x",
|
||||
artifact: "设计.美学纲领与交互范式",
|
||||
},
|
||||
promptMd: [
|
||||
"```probe",
|
||||
"缺其中一件:追问 1~2 点",
|
||||
"```",
|
||||
"```principles",
|
||||
"展示优于提问、大胆优于保守",
|
||||
"```",
|
||||
"```output",
|
||||
'{"自评":{"维度":[{"名":"交互范式"},{"名":"美学纲领"}]}}',
|
||||
"```",
|
||||
].join("\n"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedSpec).toContain("探测与追问");
|
||||
expect(result.wroteTags).toContain("设计.美学纲领与交互范式");
|
||||
expect(result.reply).toContain("已落美学");
|
||||
});
|
||||
|
||||
it("delete removes a product tag", async () => {
|
||||
const products = new Map<string, string>([
|
||||
["设计.开场白", "旧开场"],
|
||||
["用户.需求", "雨夜"],
|
||||
]);
|
||||
const driver: AgentDriver = {
|
||||
async run(input) {
|
||||
const outcome = await input.handleStep([
|
||||
{
|
||||
id: "d1",
|
||||
name: "delete",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.开场白",
|
||||
reason: "重做开场",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(outcome.kind).toBe("continue");
|
||||
if (outcome.kind === "continue") {
|
||||
const payload = JSON.parse(outcome.results[0]!.content) as {
|
||||
ok?: boolean;
|
||||
deleted?: string;
|
||||
};
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.deleted).toBe("设计.开场白");
|
||||
}
|
||||
return {
|
||||
iterations: 1,
|
||||
stop: { kind: "text", content: "已删开场白。" },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runDictateTurn({
|
||||
llm: {} as never,
|
||||
dialogue: [{ role: "user", text: "删掉开场白" }],
|
||||
driver,
|
||||
handlers: {
|
||||
listProducts: () =>
|
||||
[...products.entries()].map(([tag, content]) => ({ tag, content })),
|
||||
writeProduct: (tag, content) => {
|
||||
products.set(tag, content);
|
||||
},
|
||||
deleteProduct: (tag) => products.delete(tag),
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(products.has("设计.开场白")).toBe(false);
|
||||
expect(products.has("用户.需求")).toBe(true);
|
||||
expect(result.deletedTags).toContain("设计.开场白");
|
||||
});
|
||||
|
||||
it("undeclare_variable and remove_map prune catalog/map docs", async () => {
|
||||
let catalogRaw =
|
||||
'{"schema":"variable-catalog.v1","fields":[{"key":"好感","type":"number","initial":10,"user_visible":true},{"key":"章节","type":"number","initial":1,"user_visible":true}]}';
|
||||
let mapRaw =
|
||||
'{"schema":"value-map.v1","maps":[{"id":"aff","field":"好感","target_tag":"上下文.角色态度","bands":[{"min":0,"content":"冷"}]}]}';
|
||||
let currentRaw =
|
||||
'{"rows":[{"key":"好感","value":10,"rev":1},{"key":"章节","value":1,"rev":1}]}';
|
||||
const projection = new Map<string, string>([
|
||||
["上下文.角色态度", "冷"],
|
||||
]);
|
||||
const tags = new Map<string, string>([
|
||||
["设计.变量目录", catalogRaw],
|
||||
["设计.变量映射", mapRaw],
|
||||
["变量.当前", currentRaw],
|
||||
]);
|
||||
|
||||
const driver: AgentDriver = {
|
||||
async run(input) {
|
||||
const r1 = await input.handleStep([
|
||||
{
|
||||
id: "u1",
|
||||
name: "undeclare_variable",
|
||||
arguments: JSON.stringify({ key: "好感" }),
|
||||
},
|
||||
]);
|
||||
expect(r1.kind).toBe("continue");
|
||||
const r2 = await input.handleStep([
|
||||
{
|
||||
id: "r1",
|
||||
name: "remove_map",
|
||||
arguments: JSON.stringify({ id: "aff" }),
|
||||
},
|
||||
]);
|
||||
expect(r2.kind).toBe("continue");
|
||||
if (r2.kind === "continue") {
|
||||
const payload = JSON.parse(r2.results[0]!.content) as {
|
||||
cleared_target?: string | null;
|
||||
};
|
||||
expect(payload.cleared_target).toBe("上下文.角色态度");
|
||||
}
|
||||
return {
|
||||
iterations: 2,
|
||||
stop: { kind: "text", content: "已移除好感与映射。" },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runDictateTurn({
|
||||
llm: {} as never,
|
||||
dialogue: [{ role: "user", text: "去掉好感" }],
|
||||
driver,
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: () => undefined,
|
||||
deleteProduct: () => false,
|
||||
readTag: (tag) => tags.get(tag) ?? projection.get(tag),
|
||||
writeTag: (tag, content) => {
|
||||
tags.set(tag, content);
|
||||
if (tag === "设计.变量目录") catalogRaw = content;
|
||||
if (tag === "设计.变量映射") mapRaw = content;
|
||||
if (tag === "变量.当前") currentRaw = content;
|
||||
},
|
||||
deleteTag: (tag) => {
|
||||
const a = tags.delete(tag);
|
||||
const b = projection.delete(tag);
|
||||
return a || b;
|
||||
},
|
||||
clearDialogue: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(catalogRaw).toContain("章节");
|
||||
expect(catalogRaw).not.toContain('"好感"');
|
||||
expect(currentRaw).not.toContain('"好感"');
|
||||
expect(tags.has("设计.变量映射")).toBe(false);
|
||||
expect(projection.has("上下文.角色态度")).toBe(false);
|
||||
expect(result.deletedTags.some((t) => t.includes("好感"))).toBe(true);
|
||||
expect(result.deletedTags.some((t) => t.includes("aff"))).toBe(true);
|
||||
});
|
||||
|
||||
it("parallel inserts each get a reply_module and multi-module hint", async () => {
|
||||
const driver: AgentDriver = {
|
||||
async run(input) {
|
||||
const outcome = await input.handleStep([
|
||||
{
|
||||
id: "c1",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.美学纲领与交互范式",
|
||||
content: "美学",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
name: "insert",
|
||||
arguments: JSON.stringify({
|
||||
position: "设计.主角设定",
|
||||
content: "主角",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(outcome.kind).toBe("continue");
|
||||
if (outcome.kind === "continue") {
|
||||
const a = JSON.parse(outcome.results[0]!.content) as {
|
||||
reply_module?: string;
|
||||
reply_hint?: string;
|
||||
};
|
||||
const b = JSON.parse(outcome.results[1]!.content) as {
|
||||
reply_module?: string;
|
||||
reply_hint?: string;
|
||||
};
|
||||
expect(a.reply_module).toContain("美学纲领");
|
||||
expect(b.reply_module).toContain("主角设定");
|
||||
expect(a.reply_hint).toMatch(/多个 reply_module|本步已带回模块/);
|
||||
expect(b.reply_hint).toContain("本步已带回模块");
|
||||
}
|
||||
return {
|
||||
iterations: 1,
|
||||
stop: { kind: "text", content: "## 模块 · 美学\n\n## 模块 · 主角" },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
await runDictateTurn({
|
||||
llm: {} as never,
|
||||
dialogue: [{ role: "user", text: "都要" }],
|
||||
driver,
|
||||
handlers: {
|
||||
listProducts: () => [],
|
||||
writeProduct: () => undefined,
|
||||
deleteProduct: () => false,
|
||||
readTag: () => undefined,
|
||||
writeTag: () => undefined,
|
||||
deleteTag: () => false,
|
||||
clearDialogue: () => undefined,
|
||||
lookupInsertFeedback: (tag) =>
|
||||
tag.includes("美学")
|
||||
? formatDictateUserFacingBrief({
|
||||
tag,
|
||||
module: {
|
||||
id: "aesthetics-interaction",
|
||||
name: "美学纲领与交互范式",
|
||||
declaration: "x",
|
||||
artifact: tag,
|
||||
},
|
||||
promptMd: "```probe\n美学追问\n```",
|
||||
})
|
||||
: formatDictateUserFacingBrief({
|
||||
tag,
|
||||
module: {
|
||||
id: "protagonist",
|
||||
name: "主角设定",
|
||||
declaration: "x",
|
||||
artifact: tag,
|
||||
},
|
||||
promptMd: "```probe\n主角追问\n```",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
displayCreationModeLabel,
|
||||
displaySkillPackLabel,
|
||||
displayStageLabel,
|
||||
displayWorkerLabel,
|
||||
formatWorkerDisplayTitle,
|
||||
@@ -13,6 +15,13 @@ describe("display-labels", () => {
|
||||
expect(displayStageLabel("play")).toBe("游玩");
|
||||
});
|
||||
|
||||
it("maps creation intake modes and recipe display names", () => {
|
||||
expect(displayCreationModeLabel("recipe")).toBe("工序编排");
|
||||
expect(displayCreationModeLabel("dictate")).toBe("对话落盘");
|
||||
expect(displaySkillPackLabel("world-simulator")).toBe("回合推演");
|
||||
expect(displaySkillPackLabel("快穿短局")).toBe("快穿短局");
|
||||
});
|
||||
|
||||
it("maps design workers", () => {
|
||||
expect(displayWorkerLabel("design-flow")).toBe("创作 · 流程编排");
|
||||
expect(displayWorkerLabel("design-step")).toBe("创作 · 执行步骤");
|
||||
@@ -24,9 +33,7 @@ describe("display-labels", () => {
|
||||
it("maps creation unit ids", () => {
|
||||
expect(displayWorkerLabel("phase:core")).toBe("单位 · 核心");
|
||||
expect(displayWorkerLabel("fixed:interaction")).toBe("技能 · 交互范式");
|
||||
expect(displayWorkerLabel("fixed:aesthetics-interaction")).toBe(
|
||||
"技能 · 美学纲领与交互范式",
|
||||
);
|
||||
expect(displayWorkerLabel("fixed:protagonist")).toBe("技能 · 主角设定");
|
||||
expect(displayWorkerLabel("worker:narrator")).toBe("执行单元 · 叙事转述");
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseOpeningSealPayload,
|
||||
openingVariablesToTableDoc,
|
||||
reopenCreationFlowRaw,
|
||||
splitOpeningDocument,
|
||||
} from "../src/skills/opening-seal.js";
|
||||
|
||||
const fragment = JSON.stringify({
|
||||
@@ -63,6 +64,117 @@ describe("opening-seal", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads 用户角色 from candidate meta and 正文", () => {
|
||||
const raw = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "开场白与开场变量",
|
||||
正文: {
|
||||
开场白全文: "@玩家站在债契前。",
|
||||
开场白候选: [
|
||||
{
|
||||
meta: {
|
||||
用户角色: { 名字: "沈聿", 简介: "本地放贷人;与这对母女相识但不熟。" },
|
||||
},
|
||||
正文: "@玩家站在债契前。",
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
用户角色: { 名字: "阿泽", 简介: "刚穿进来的社畜。" },
|
||||
},
|
||||
正文: "门外有人喊 @玩家 的名字。",
|
||||
},
|
||||
],
|
||||
开场变量: [{ 名: "债额", 值: 3 }],
|
||||
},
|
||||
});
|
||||
const first = parseOpeningSealPayload(raw, 0);
|
||||
expect(first?.persona).toEqual({
|
||||
name: "沈聿",
|
||||
description: "本地放贷人;与这对母女相识但不熟。",
|
||||
});
|
||||
const second = parseOpeningSealPayload(raw, 1);
|
||||
expect(second?.selectedText).toContain("门外");
|
||||
expect(second?.persona).toEqual({
|
||||
name: "阿泽",
|
||||
description: "刚穿进来的社畜。",
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes present.v1 正文 and per-candidate 开场变量", () => {
|
||||
const packet = {
|
||||
schema: "present.v1",
|
||||
shell: "chat_monitor",
|
||||
blocks: {
|
||||
monitor: { 债额: "三两" },
|
||||
body: "@玩家站在债契前。",
|
||||
},
|
||||
meta: { suggested_actions: ["问债"] },
|
||||
};
|
||||
const raw = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "开场白与开场变量",
|
||||
正文: {
|
||||
开场白全文: packet,
|
||||
开场白候选: [
|
||||
{
|
||||
meta: {
|
||||
用户角色: { 名字: "沈聿", 简介: "放贷人" },
|
||||
开场变量: [{ 名: "债额", 值: 3, 依据: "桌上债契" }],
|
||||
},
|
||||
正文: packet,
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
用户角色: { 名字: "阿泽", 简介: "社畜" },
|
||||
开场变量: [{ 名: "口粮", 值: 1 }],
|
||||
},
|
||||
正文: {
|
||||
schema: "present.v1",
|
||||
shell: "chat_monitor",
|
||||
blocks: { body: "门外有人喊 @玩家。" },
|
||||
},
|
||||
},
|
||||
],
|
||||
开场变量: [{ 名: "债额", 值: 3, 依据: "桌上债契" }],
|
||||
},
|
||||
});
|
||||
const first = parseOpeningSealPayload(raw, 0);
|
||||
expect(first?.candidates).toHaveLength(2);
|
||||
const selected = JSON.parse(first!.selectedText);
|
||||
expect(selected.schema).toBe("present.v1");
|
||||
expect(selected.shell).toBe("chat_monitor");
|
||||
expect(selected.blocks.body).toContain("@玩家");
|
||||
expect(first?.persona).toEqual({ name: "沈聿", description: "放贷人" });
|
||||
expect(first?.variables).toEqual([
|
||||
{ name: "债额", value: 3, note: "桌上债契" },
|
||||
]);
|
||||
const second = parseOpeningSealPayload(raw, 1);
|
||||
expect(second?.persona?.name).toBe("阿泽");
|
||||
expect(second?.variables).toEqual([{ name: "口粮", value: 1 }]);
|
||||
expect(JSON.parse(second!.selectedText).blocks.body).toContain("门外");
|
||||
});
|
||||
|
||||
it("splits YAML frontmatter meta from opening 正文", () => {
|
||||
const nested = splitOpeningDocument(`---
|
||||
用户角色:
|
||||
名字: 沈聿
|
||||
简介: 本地放贷人
|
||||
---
|
||||
清晨,@玩家推开堂屋门。
|
||||
`);
|
||||
expect(nested.persona).toEqual({ name: "沈聿", description: "本地放贷人" });
|
||||
expect(nested.text).toBe("清晨,@玩家推开堂屋门。");
|
||||
|
||||
const dotted = splitOpeningDocument(`---
|
||||
用户角色.名字: 阿泽
|
||||
用户角色.简介: 刚穿进来的社畜
|
||||
---
|
||||
门外有人喊 @玩家。
|
||||
`);
|
||||
expect(dotted.persona).toEqual({ name: "阿泽", description: "刚穿进来的社畜" });
|
||||
expect(dotted.text).toBe("门外有人喊 @玩家。");
|
||||
});
|
||||
|
||||
it("builds table rows from opening variables", () => {
|
||||
const payload = parseOpeningSealPayload(fragment, 0);
|
||||
const doc = openingVariablesToTableDoc(payload!.variables);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import { stringifyTableDoc, createTableFromValues } from "../src/blackboard/table-cells.js";
|
||||
import { buildPlayAuxView } from "../src/skills/play-aux.js";
|
||||
import { SLOT_OPENING_SELECTED_INDEX } from "../src/skills/opening-seal.js";
|
||||
|
||||
describe("play-aux view", () => {
|
||||
it("splits visible and hidden table rows and lists opening candidates", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: "变量.当前",
|
||||
content: stringifyTableDoc(
|
||||
createTableFromValues(
|
||||
{ 口粮: 1, 暗线: "未揭" },
|
||||
"worker:opening-setup",
|
||||
{
|
||||
口粮: { visibility: "visible", editable: true },
|
||||
暗线: { visibility: "hidden", editable: false },
|
||||
},
|
||||
),
|
||||
),
|
||||
source: "runtime",
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.开场白与开场变量",
|
||||
content: JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "开场白与开场变量",
|
||||
正文: {
|
||||
开场白全文: "@玩家站在债契前。",
|
||||
开场白候选: [
|
||||
{
|
||||
meta: { 用户角色: { 名字: "沈聿", 简介: "放贷人" } },
|
||||
正文: "@玩家站在债契前。",
|
||||
},
|
||||
{
|
||||
meta: { 用户角色: { 名字: "阿泽", 简介: "社畜" } },
|
||||
正文: "门外有人喊 @玩家。",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
source: "agent",
|
||||
});
|
||||
const view = buildPlayAuxView(board, { [SLOT_OPENING_SELECTED_INDEX]: 1 });
|
||||
expect(view.table?.rows.find((r) => r.key === "口粮")?.editable).toBe(true);
|
||||
expect(view.table?.rows.find((r) => r.key === "暗线")?.visibility).toBe("hidden");
|
||||
expect(view.table?.rows.find((r) => r.key === "暗线")?.editable).toBe(false);
|
||||
expect(view.openings).toHaveLength(2);
|
||||
expect(view.selectedOpeningIndex).toBe(1);
|
||||
expect(view.openings[1]?.persona?.name).toBe("阿泽");
|
||||
});
|
||||
|
||||
it("previews present.v1 opening body instead of raw JSON", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: "设计.开场白与开场变量",
|
||||
content: JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "开场白与开场变量",
|
||||
正文: {
|
||||
开场白全文: {
|
||||
schema: "present.v1",
|
||||
shell: "chat_monitor",
|
||||
blocks: { body: "@玩家站在债契前,堂屋很静。" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
source: "agent",
|
||||
});
|
||||
const view = buildPlayAuxView(board);
|
||||
expect(view.openings).toHaveLength(1);
|
||||
expect(view.openings[0]?.text).toBe("@玩家站在债契前,堂屋很静。");
|
||||
expect(view.openings[0]?.text).not.toContain("present.v1");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseShellAdaptationFromReplyFormat,
|
||||
presentFromPlainText,
|
||||
presentOutputFromFallback,
|
||||
presentReadablePlain,
|
||||
isPlayFinalVisibleWorker,
|
||||
isPlayGmBodyWorker,
|
||||
isPlayPresentWorker,
|
||||
@@ -146,4 +147,17 @@ describe("play present worker", () => {
|
||||
expect(view.packet.shell).toBe("chat_monitor");
|
||||
expect(view.packet.blocks.body).toBe("她点了点头。");
|
||||
});
|
||||
|
||||
it("presentReadablePlain extracts body from present.v1", () => {
|
||||
expect(
|
||||
presentReadablePlain(
|
||||
JSON.stringify({
|
||||
schema: "present.v1",
|
||||
shell: "spotlight",
|
||||
blocks: { body: "清晨,@玩家推开门。" },
|
||||
}),
|
||||
),
|
||||
).toBe("清晨,@玩家推开门。");
|
||||
expect(presentReadablePlain("门外有人砸门。")).toBe("门外有人砸门。");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
|
||||
const MARKDOWN_SPEC = `# 主角设定:债主(@玩家)
|
||||
|
||||
## 名字
|
||||
沈聿。母女只知道这个姓和这张脸,叫不出更多。
|
||||
|
||||
## 身份
|
||||
本地放贷人之一,业务不大不小。
|
||||
|
||||
## 能做什么
|
||||
手里握着母亲签下的借据。没有超自然金手指。
|
||||
|
||||
## 关键背景(用户补充)
|
||||
许曼的几笔散债被沈聿合并到自己手里。
|
||||
|
||||
---
|
||||
|
||||
# 母女档案(NPC)
|
||||
|
||||
## 母亲:许曼(33岁)
|
||||
不应被当成主角名字。
|
||||
`;
|
||||
|
||||
describe("protagonist overlay", () => {
|
||||
beforeEach(() => {
|
||||
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||
path.join(tmpdir(), "wa-protag-"),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses context-fragment JSON", async () => {
|
||||
const { parseProtagonistSpec } = await import(
|
||||
"../src/persona/protagonist-bind.js"
|
||||
);
|
||||
const spec = parseProtagonistSpec(
|
||||
JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "主角设定",
|
||||
正文: {
|
||||
名字: "林晚",
|
||||
背景: "刚被退婚的庶女",
|
||||
特殊设定: { 身份: "侯府庶女", 金手指: "看见好感数字", 其它: "无" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(spec?.name).toBe("林晚");
|
||||
expect(spec?.cheat).toMatch(/好感/);
|
||||
});
|
||||
|
||||
it("parses markdown headings used by dictate", async () => {
|
||||
const { parseProtagonistSpec, formatProtagonistSummary } = await import(
|
||||
"../src/persona/protagonist-bind.js"
|
||||
);
|
||||
const spec = parseProtagonistSpec(MARKDOWN_SPEC);
|
||||
expect(spec?.name).toBe("沈聿");
|
||||
expect(spec?.identity).toMatch(/放贷/);
|
||||
expect(spec?.cheat).toMatch(/借据/);
|
||||
expect(spec?.background).toMatch(/散债/);
|
||||
expect(formatProtagonistSummary(spec!).length).toBeLessThan(80);
|
||||
expect(formatProtagonistSummary(spec!)).not.toMatch(/\*\*/);
|
||||
});
|
||||
|
||||
it("overlays play persona from the product without touching global cards", async () => {
|
||||
const {
|
||||
PROTAGONIST_TAG,
|
||||
readSessionProtagonist,
|
||||
overlayPlayPersona,
|
||||
} = await import("../src/persona/protagonist-bind.js");
|
||||
const { listPersonas, getActivePersona } = await import(
|
||||
"../src/persona/store.js"
|
||||
);
|
||||
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: PROTAGONIST_TAG,
|
||||
content: JSON.stringify({
|
||||
正文: {
|
||||
名字: "林晚",
|
||||
背景: "刚被退婚的庶女",
|
||||
特殊设定: { 身份: "侯府庶女", 金手指: "看见好感数字" },
|
||||
},
|
||||
}),
|
||||
source: "agent",
|
||||
});
|
||||
|
||||
const before = listPersonas().map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
}));
|
||||
const activeId = getActivePersona()?.id;
|
||||
|
||||
const view = readSessionProtagonist(board);
|
||||
expect(view?.name).toBe("林晚");
|
||||
expect(view?.summary).toMatch(/庶女|好感/);
|
||||
|
||||
expect(overlayPlayPersona("play", board)?.name).not.toBe("林晚");
|
||||
|
||||
const { OPENING_PERSONA_CHOICE_TAG } = await import(
|
||||
"../src/skills/opening-seal.js"
|
||||
);
|
||||
board.write({
|
||||
tag: OPENING_PERSONA_CHOICE_TAG,
|
||||
content: "opening",
|
||||
source: "user",
|
||||
});
|
||||
const play = overlayPlayPersona("play", board);
|
||||
expect(play?.name).toBe("林晚");
|
||||
expect(play?.description).toMatch(/庶女/);
|
||||
expect(overlayPlayPersona("design", board)?.name).not.toBe("林晚");
|
||||
|
||||
board.write({
|
||||
tag: OPENING_PERSONA_CHOICE_TAG,
|
||||
content: "global",
|
||||
source: "user",
|
||||
});
|
||||
expect(overlayPlayPersona("play", board)?.name).not.toBe("林晚");
|
||||
|
||||
expect(
|
||||
listPersonas().map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
})),
|
||||
).toEqual(before);
|
||||
expect(getActivePersona()?.id).toBe(activeId);
|
||||
});
|
||||
|
||||
it("rejects placeholder names", async () => {
|
||||
const { parseProtagonistSpec } = await import(
|
||||
"../src/persona/protagonist-bind.js"
|
||||
);
|
||||
expect(parseProtagonistSpec("名字:@玩家\n背景:无")).toBeNull();
|
||||
expect(parseProtagonistSpec("名字:主角\n背景:无")).toBeNull();
|
||||
expect(parseProtagonistSpec("## 名字\n(待用户给出)\n## 身份\n债主")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses prose fields", async () => {
|
||||
const { parseProtagonistSpec } = await import(
|
||||
"../src/persona/protagonist-bind.js"
|
||||
);
|
||||
const spec = parseProtagonistSpec(
|
||||
"名字:阿泽\n背景:现代社畜刚穿进书里\n金手指:系统面板\n身份:炮灰男配",
|
||||
);
|
||||
expect(spec).toMatchObject({
|
||||
name: "阿泽",
|
||||
background: "现代社畜刚穿进书里",
|
||||
cheat: "系统面板",
|
||||
identity: "炮灰男配",
|
||||
});
|
||||
});
|
||||
|
||||
it("overlays opening meta 用户角色 when the user switches to that preset", async () => {
|
||||
const { readSessionProtagonist, overlayPlayPersona } = await import(
|
||||
"../src/persona/protagonist-bind.js"
|
||||
);
|
||||
const {
|
||||
OPENING_PERSONA_TAG,
|
||||
OPENING_PERSONA_CHOICE_TAG,
|
||||
} = await import("../src/skills/opening-seal.js");
|
||||
const { listPersonas } = await import("../src/persona/store.js");
|
||||
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: OPENING_PERSONA_TAG,
|
||||
content: JSON.stringify({
|
||||
名字: "沈聿",
|
||||
简介: "本地放贷人;与这对母女相识但不熟。",
|
||||
}),
|
||||
source: "runtime",
|
||||
});
|
||||
board.write({
|
||||
tag: OPENING_PERSONA_CHOICE_TAG,
|
||||
content: "opening",
|
||||
source: "user",
|
||||
});
|
||||
|
||||
const before = listPersonas().map((p) => p.name);
|
||||
const view = readSessionProtagonist(board);
|
||||
expect(view?.name).toBe("沈聿");
|
||||
expect(view?.description).toMatch(/放贷人/);
|
||||
|
||||
const play = overlayPlayPersona("play", board);
|
||||
expect(play?.name).toBe("沈聿");
|
||||
expect(play?.description).toMatch(/放贷人/);
|
||||
expect(listPersonas().map((p) => p.name)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatMessage, ToolDefinition } from "../src/llm/client.js";
|
||||
import {
|
||||
describeRequestAwaiting,
|
||||
formatLlmAbortLine,
|
||||
formatLlmFailLine,
|
||||
formatLlmFinishLine,
|
||||
formatLlmSendLine,
|
||||
} from "../src/llm/request-log.js";
|
||||
|
||||
const insertTool: ToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "insert",
|
||||
description: "write",
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
|
||||
describe("describeRequestAwaiting", () => {
|
||||
it("flags first user turn", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: "你是对话落盘创作助手" },
|
||||
{ role: "user", content: "傀儡皇帝恋爱体验……很长一段用户原话" },
|
||||
];
|
||||
expect(describeRequestAwaiting(messages)).toBe("待答用户");
|
||||
});
|
||||
|
||||
it("flags digesting insert without repeating content", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "用户原话" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "insert",
|
||||
arguments: "{\"position\":\"设计.美学纲领与交互范式\",\"content\":\"很长正文\"}",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", content: "{\"ok\":true}", tool_call_id: "c1" },
|
||||
];
|
||||
expect(describeRequestAwaiting(messages)).toBe("待消化 写入产物");
|
||||
const line = formatLlmSendLine({
|
||||
kind: "tools-stream",
|
||||
caller: "dictate_agent",
|
||||
label: "对话落盘 第2轮",
|
||||
messages,
|
||||
tools: [insertTool],
|
||||
});
|
||||
expect(line).toContain("发送 对话落盘 第2轮 · 工具流式");
|
||||
expect(line).toContain("待消化 写入产物");
|
||||
expect(line).toContain("条=系统1+用户1+助手1+工具1");
|
||||
expect(line).toContain("可调 写入产物");
|
||||
expect(line).not.toContain("很长正文");
|
||||
expect(line).not.toContain("用户原话");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLlmSendLine", () => {
|
||||
it("summarizes a dictate first round", () => {
|
||||
const line = formatLlmSendLine({
|
||||
kind: "tools-stream",
|
||||
caller: "dictate_agent",
|
||||
label: "对话落盘 第1轮",
|
||||
messages: [
|
||||
{ role: "system", content: "x".repeat(12000) },
|
||||
{ role: "user", content: "写长文" },
|
||||
],
|
||||
tools: [insertTool],
|
||||
});
|
||||
expect(line).toBe(
|
||||
"发送 对话落盘 第1轮 · 工具流式 待答用户 条=系统1+用户1 可调 写入产物 上下文≈1.2万字",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLlmFinishLine", () => {
|
||||
it("reports completion without dumping body", () => {
|
||||
const line = formatLlmFinishLine(
|
||||
{
|
||||
kind: "tools-stream",
|
||||
caller: "dictate_agent",
|
||||
label: "对话落盘 第1轮",
|
||||
messages: [],
|
||||
},
|
||||
7306,
|
||||
{
|
||||
content: "短确认",
|
||||
reasoning: "",
|
||||
model: "deepseek-v4-pro",
|
||||
toolCalls: [{ id: "1", name: "insert", arguments: "SECRET_BODY" }],
|
||||
usage: { promptTokens: 14385, completionTokens: 413, totalTokens: 14798 },
|
||||
},
|
||||
);
|
||||
expect(line).toContain("完成 对话落盘 第1轮 · 工具流式");
|
||||
expect(line).toContain("7306毫秒");
|
||||
expect(line).toContain("模型=deepseek-v4-pro");
|
||||
expect(line).toContain("调用=写入产物");
|
||||
expect(line).toContain("用量=14385+413");
|
||||
expect(line).not.toContain("SECRET_BODY");
|
||||
expect(line).not.toContain("短确认");
|
||||
});
|
||||
});
|
||||
|
||||
describe("abort and fail lines", () => {
|
||||
it("marks user abort separately from failure", () => {
|
||||
expect(
|
||||
formatLlmAbortLine(
|
||||
{ kind: "tools-stream", caller: "dictate_agent", label: "对话落盘 第1轮" },
|
||||
2100,
|
||||
),
|
||||
).toBe("中断 对话落盘 第1轮 · 工具流式 2100毫秒");
|
||||
expect(
|
||||
formatLlmFailLine(
|
||||
{ kind: "stream", caller: "worker:design-step", label: "设计监控栏" },
|
||||
10787,
|
||||
new Error("fetch failed"),
|
||||
),
|
||||
).toBe("失败 执行单元 design-step · 设计监控栏 · 流式 10787毫秒 fetch failed");
|
||||
});
|
||||
});
|
||||
@@ -48,4 +48,32 @@ describe("table-cells", () => {
|
||||
expect(skipped[0]?.reason).toContain("rev-conflict");
|
||||
expect(doc.rows.find((r) => r.key === "好感")?.value).toBe(10);
|
||||
});
|
||||
|
||||
it("user patches require matching rev and skip hidden cells", async () => {
|
||||
const { patchUserTableCells } = await import("../src/blackboard/table-cells.js");
|
||||
const current = createTableFromValues(
|
||||
{ 口粮: 1, 暗线: "未揭" },
|
||||
"worker:a",
|
||||
{ 口粮: { visibility: "visible" }, 暗线: { visibility: "hidden" } },
|
||||
);
|
||||
current.rows.find((r) => r.key === "口粮")!.rev = 2;
|
||||
const { applied, skipped, doc } = patchUserTableCells(current, [
|
||||
{ key: "口粮", value: 3, expectedRev: 1 },
|
||||
{ key: "暗线", value: "揭了", expectedRev: 1 },
|
||||
]);
|
||||
expect(applied).toEqual([]);
|
||||
expect(skipped.some((s) => s.key === "口粮" && s.reason.includes("rev"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(skipped.some((s) => s.key === "暗线" && s.reason === "not-editable")).toBe(
|
||||
true,
|
||||
);
|
||||
const ok = patchUserTableCells(current, [
|
||||
{ key: "口粮", value: 3, expectedRev: 2 },
|
||||
]);
|
||||
expect(ok.applied).toEqual(["口粮"]);
|
||||
expect(ok.doc.rows.find((r) => r.key === "口粮")?.value).toBe(3);
|
||||
expect(ok.doc.rows.find((r) => r.key === "口粮")?.rev).toBe(3);
|
||||
expect(doc.rows.find((r) => r.key === "口粮")?.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
reprojectValueMaps,
|
||||
serializeValueMapDoc,
|
||||
upsertValueMapEntry,
|
||||
removeValueMapEntry,
|
||||
VALUE_MAP_TAG,
|
||||
} from "../src/skills/value-map.js";
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
seedVariablesFromCatalog,
|
||||
serializeVariableCatalog,
|
||||
upsertVariableField,
|
||||
removeVariableField,
|
||||
VARIABLE_CATALOG_TAG,
|
||||
} from "../src/skills/variable-catalog.js";
|
||||
import { stringifyTableDoc, createTableFromValues, parseTableDoc } from "../src/blackboard/table-cells.js";
|
||||
@@ -47,6 +49,15 @@ describe("variable-catalog", () => {
|
||||
expect(seeded.current).toContain("好感");
|
||||
expect(seeded.initial).toContain("旧港");
|
||||
});
|
||||
|
||||
it("removes a catalog field", () => {
|
||||
const a = upsertVariableField(null, { key: "好感", type: "number", initial: 1 });
|
||||
const b = upsertVariableField(a.doc, { key: "章节", type: "number", initial: 1 });
|
||||
const { doc, error } = removeVariableField(b.doc, "好感");
|
||||
expect(error).toBeUndefined();
|
||||
expect(doc.fields.map((f) => f.key)).toEqual(["章节"]);
|
||||
expect(removeVariableField(doc, "好感").error).toMatch(/无字段/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("value-map", () => {
|
||||
@@ -142,6 +153,20 @@ describe("value-map", () => {
|
||||
expect(lookupValueMapContent(parsed!.maps[0]!, 1)).toBe("第一章");
|
||||
expect(lookupValueMapContent(parsed!.maps[0]!, 9)).toBe("未定");
|
||||
});
|
||||
|
||||
it("removes map by id", () => {
|
||||
const { doc: withMap } = upsertValueMapEntry(null, {
|
||||
id: "aff",
|
||||
field: "好感",
|
||||
target_tag: "上下文.角色态度",
|
||||
bands: [{ min: 0, content: "冷" }],
|
||||
});
|
||||
const { doc, removed, error } = removeValueMapEntry(withMap, "aff");
|
||||
expect(error).toBeUndefined();
|
||||
expect(removed?.id).toBe("aff");
|
||||
expect(doc.maps).toHaveLength(0);
|
||||
expect(removeValueMapEntry(doc, "aff").error).toMatch(/无 id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog tag constants", () => {
|
||||
|
||||
Reference in New Issue
Block a user