引入游玩回合与开场收口,抽出可替换 agent 司机并支持运行中止;完善预设洞位、世界书拼装与试跑,并打磨会话运行时与 Web 交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 01:13:48 +08:00
co-authored by Cursor
parent ec474cb946
commit b82da4c5a5
95 changed files with 6781 additions and 1344 deletions
+119 -1
View File
@@ -106,9 +106,127 @@ describe("contextSegments assembly", () => {
outputTags: ["设计.美学纲领与交互范式"],
});
expect(text).toContain("待改底稿");
expect(text).toContain("用户.worker答复");
expect(text).toContain("用户要求");
expect(text).toContain("冲突时以较新的为准");
expect(text).toContain("偏冷");
expect(text).toContain("再冷一点");
expect(text.split("再冷一点").length - 1).toBe(1);
});
it("stale revision note without a draft stays as a user requirement, not revision mode", () => {
const bb = new Blackboard();
const text = assembleWorkerContext({
inputs: {
"用户.修订说明": "上一步留下的意见",
"用户.需求": "网恋对话",
},
segments: [
{
id: "user",
tier: "dynamic",
tags: ["用户.需求", "用户.修订说明"],
label: "## 用户表述",
},
],
blackboard: bb,
workerId: "design-step",
workerName: "创作 · 正文组成",
outputTags: ["设计.正文组成"],
});
expect(text).not.toContain("修订模式");
expect(text).not.toContain("待改底稿");
expect(text).toContain("用户要求");
expect(text).toContain("上一步留下的意见");
expect(text).toContain("网恋对话");
expect(text).toContain("按 SKILL 与上方分区完成任务");
});
it("collapses duplicate user-demand tags in the same segment", () => {
const bb = new Blackboard();
const text = assembleWorkerContext({
inputs: {
"用户.需求": "开场需求\n\n需要正文和选项",
"用户.worker答复": "需要正文和选项",
"用户.最新输入": "需要正文和选项",
},
segments: [
{
id: "user",
tier: "dynamic",
tags: ["用户.需求", "用户.worker答复", "用户.最新输入"],
label: "## 用户表述",
},
],
blackboard: bb,
workerId: "design-step",
workerName: "创作 · 正文组成",
outputTags: ["设计.正文组成"],
});
expect(text.split("需要正文和选项").length - 1).toBe(1);
});
it("keeps earlier user requirements and puts the newer revision last", () => {
const bb = new Blackboard();
bb.write({
tag: "设计.正文组成",
content: '{"shell":"split_board"}',
source: "worker",
});
const text = assembleWorkerContext({
inputs: {
"用户.需求": "需要正文和监控",
"用户.修订说明": "状态放到上面",
"设计.正文组成": '{"shell":"split_board"}',
},
segments: [
{
id: "user",
tier: "dynamic",
tags: ["用户.需求", "用户.修订说明"],
label: "## 用户表述",
},
],
blackboard: bb,
workerId: "design-step",
workerName: "创作 · 正文组成",
outputTags: ["设计.正文组成"],
});
expect(text).toContain("需要正文和监控");
expect(text).toContain("状态放到上面");
expect(text.indexOf("需要正文和监控")).toBeLessThan(text.indexOf("状态放到上面"));
expect(text).toContain("冲突时以较新的为准");
expect(text).toContain("待改底稿");
expect(text).toContain("split_board");
});
it("inherit-existing asks the worker to keep the draft without a revision note", () => {
const bb = new Blackboard();
bb.write({
tag: "设计.美学纲领与交互范式",
content: '{"tone":"warm"}',
source: "worker",
});
const text = assembleWorkerContext({
inputs: {
"设计.美学纲领与交互范式": '{"tone":"warm"}',
},
segments: [
{
id: "inherit-existing",
tier: "static",
tags: ["设计.美学纲领与交互范式"],
label: "## 【既有产物 · 继承修改】在下列正文上继续改",
},
],
blackboard: bb,
workerId: "design-step",
workerName: "创作 · 回头修改 · 美学纲领与交互范式",
outputTags: ["设计.美学纲领与交互范式"],
});
expect(text).toContain("既有产物 · 继承修改");
expect(text).toContain("回头修改");
expect(text).toContain("warm");
expect(text).not.toContain("按 SKILL 与上方分区完成任务");
});
it("falls back to JSON inputs without segments", () => {
+75
View File
@@ -77,6 +77,24 @@ describe("context-fragment.v1", () => {
]);
});
it("does not surface empty 追问 导语 as sidecar assessment", () => {
const side = extractFragmentAskSidecar({
schema: "context-fragment.v1",
brief: "返现都市",
正文: { 美学纲领: { 体验内核: "被宠" } },
自评: {
维度: [{ 名: "交互范式", 分数: 9, 说明: "站位已清" }],
},
追问: {
导语: "为贴近你要的质感,还需确认:",
题目: [],
},
});
expect(side.questions).toHaveLength(0);
expect(side.assessment).toContain("交互范式 9/10");
expect(side.assessment).not.toContain("还需确认");
});
it("isUsableContextFragment requires non-empty 正文", () => {
expect(
isUsableContextFragment({
@@ -125,6 +143,63 @@ describe("context-order.v1", () => {
expect(segs[3].tier).toBe("dynamic");
});
it("accepts agents[] roster and maps gm → world-simulator", () => {
const doc = parseContextOrder({
schema: "context-order.v1",
brief: "旁观+主世界层+转述",
agents: [
{
id: "auditor",
label: "表格管理",
enabled: true,
when: "every_turn",
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
{ order: 1, ref: "设计.变量设计与更新规则", projection: "summary" },
],
},
{
id: "gm",
enabled: true,
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
{ order: 1, ref: "对话.历史", projection: "summary" },
],
},
{ id: "narrator", enabled: true, inserts: [] },
{ id: "perspective", enabled: false, inserts: [] },
{ id: "chance", enabled: false, when: "on_demand", inserts: [] },
],
})!;
expect(doc.play_slots?.auditor).not.toBe(false);
expect(doc.play_slots?.gm).toBe(true);
expect(doc.play_slots?.perspective).toBe(false);
expect(doc.play_slots?.chance).toBe(false);
expect(slotOrderForRef(doc, "auditor")?.label).toBe("表格管理");
expect(slotOrderForRef(doc, "world-simulator")?.inserts.some((i) => i.ref === "对话.历史")).toBe(
true,
);
expect(slotOrderForRef(doc, "role-decide")).toBeUndefined();
});
it("index projection keeps brief, mount and 稳变 without 正文", () => {
const text = projectFragmentContent(
JSON.stringify({
schema: "context-fragment.v1",
技能: "美学纲领与交互范式",
brief: "免疫即权力",
mount: ["world-simulator", "narrator"],
稳变: "stable",
正文: { 美学纲领: { 体验内核: "很长不应出现在 index 里的正文" } },
}),
"index",
);
expect(text).toContain("免疫即权力");
expect(text).toContain("挂载:world-simulator、narrator");
expect(text).toContain("稳变:stable");
expect(text).not.toContain("很长不应出现在 index 里的正文");
});
it("reorders flat list and merges into worker set JSON", () => {
const doc = parseContextOrder({
schema: "context-order.v1",
+192 -12
View File
@@ -290,12 +290,21 @@ describe("creation-flow", () => {
expect(
catalog?.modules.find((m) => m.name === "具体实例")?.repeatable,
).toBe(true);
expect(
catalog?.modules.find((m) => m.name === "生成规则")?.kind,
).toBe("prior-artifact");
expect(
catalog?.modules.find((m) => m.name === "具体实例")?.kind,
).toBe("prior-artifact");
expect(
catalog?.modules.find((m) => m.name === "生成规则")?.params?.[0]?.key,
).toBe("target");
expect(
catalog?.modules.find((m) => m.name === "具体实例")?.params?.[0]?.key,
).toBe("rule_id");
expect(
catalog?.modules.find((m) => m.name === "上下文投影排序")?.auto,
).toBe(true);
const gen = catalog?.modules.find((m) => m.name === "生成规则");
expect(gen?.when).toBeTruthy();
expect(gen?.when_not).toBeTruthy();
@@ -303,9 +312,12 @@ describe("creation-flow", () => {
const block = formatModuleCatalogForAgent(catalog!);
expect(block).toContain("【能力");
expect(block).toContain("美学纲领与交互范式:");
expect(block).toContain("生成规则〔可反复〕");
expect(block).toContain("生成规则〔可反复〕〔先验产物〕");
expect(block).toContain("开场白与开场变量〔收口〕");
expect(block).toContain("上下文投影排序〔程序步〕");
expect(block).toContain("何时用");
expect(block).toContain("何时不用");
expect(block).toContain("规划产物字段");
expect(block).toContain("编排参数");
expect(block).not.toContain("设计.美学纲领与交互范式");
});
@@ -465,7 +477,8 @@ recipes:
expect(loaded.promptBody).toContain("已完成:");
expect(loaded.promptBody).toContain("美学纲领与交互范式");
expect(loaded.promptBody).toContain("已验收");
expect(loaded.promptBody).toContain("禁止再排一次执行");
expect(loaded.promptBody).toContain("禁止再排新建");
expect(loaded.promptBody).toContain("mode=revise");
expect(loaded.promptBody).toContain("生成规则");
});
@@ -506,6 +519,7 @@ recipes:
expect(loaded.promptBody).toContain("美学纲领与交互范式");
expect(loaded.promptBody).toContain("体验契约");
expect(loaded.promptBody).toContain("程序开场");
expect(loaded.promptBody).toContain("【本步参数】");
expect(loaded.worker.inputTags).toContain("创作.能力开场白");
});
@@ -606,24 +620,24 @@ recipes:
expect(view.steps[0]?.params?.target).toBe("怪物");
const catalog = await loadModuleCatalog("dialogue/world-simulator");
expect(
catalog?.modules.find((m) => m.name === "生成规则")?.params?.some(
(p) => p.key === "target" && p.required,
),
).toBe(true);
const rules = catalog?.modules.find((m) => m.name === "生成规则");
expect(rules?.kind).toBe("prior-artifact");
expect(rules?.params?.some((p) => p.key === "target")).toBe(true);
expect(validateCreationFlow(flow, catalog).ok).toBe(true);
const missing = parseCreationFlow(`{
"steps": [{ "name": "生成规则", "depends_on": [] }]
}`)!;
// 编排期允许空壳;必填 params 在确认开干时钉齐
// 先验产物:编排期允许空壳,确认开干也不拦
expect(validateCreationFlow(missing, catalog).ok).toBe(true);
const stepReady = validateStepReadyToRun(missing.steps[0]!, catalog);
expect(stepReady.ok).toBe(false);
expect(stepReady.errors.some((e) => e.includes("target"))).toBe(true);
expect(stepReady.ok).toBe(true);
const missingView = formatCreationFlowForUser(missing, catalog);
expect(missingView.steps[0]?.kind).toBe("prior-artifact");
expect(missingView.steps[0]?.paramsMissing).toBeUndefined();
const block = formatModuleCatalogForAgent(catalog!);
expect(block).toContain("编排参数");
expect(block).toContain("规划产物字段");
expect(block).toContain("target");
});
@@ -648,12 +662,47 @@ recipes:
acceptedStepNames: [],
},
);
expect(loaded.promptBody).toContain("【本步参数】");
expect(loaded.promptBody).toContain("【规划产物】");
expect(loaded.promptBody).not.toContain("勿再问「生成什么");
expect(loaded.promptBody).toContain("target: 怪物");
expect(loaded.promptBody).toContain("rule_id: monsters");
expect(loaded.worker.outputTags).toContain("设计.生成规则");
});
it("injects empty prior-artifact plan and still lets the step run", async () => {
const catalog = parseModuleCatalog(`
modules:
- name: 生成规则
declaration: 可执行规则
artifact: 设计.生成规则
kind: prior-artifact
params:
- key: target
label: 生成对象
required: true
`)!;
const empty = parseCreationFlow(`{
"steps": [{ "name": "生成规则", "depends_on": [] }]
}`)!;
expect(catalog.modules[0]?.kind).toBe("prior-artifact");
expect(validateStepReadyToRun(empty.steps[0]!, catalog).ok).toBe(true);
const loaded = await loadWorkerSkillWithContext(
"world-simulator",
"design-step",
undefined,
{
flowRaw: JSON.stringify({
steps: [{ id: "生成规则", name: "生成规则", depends_on: [] }],
}),
currentStepName: "生成规则",
acceptedStepNames: [],
},
);
expect(loaded.promptBody).toContain("【规划产物】");
expect(loaded.promptBody).toContain("尚未规划本步具体写什么");
});
it("nextPendingStep respects deps and accepted by id", () => {
const flow = parseCreationFlow(`{
"status": "open",
@@ -730,4 +779,135 @@ recipes:
}`)!;
expect(isCreationFlowComplete(legacy, ["美学纲领与交互范式"])).toBe(true);
});
it("allows revising a non-repeatable skill and infers revises + id", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
{ "name": "实现机制", "depends_on": [] },
{ "name": "实现机制", "mode": "回头修改", "depends_on": ["实现机制"] }
]
}`)!;
expect(flow.steps[1]).toMatchObject({
id: "实现机制·改",
name: "实现机制",
mode: "revise",
revises: "实现机制",
});
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
const view = formatCreationFlowForUser(flow, sampleCatalog, ["实现机制"]);
expect(view.steps[1]?.mode).toBe("revise");
expect(view.steps[1]?.occurrence).toBeUndefined();
expect(view.steps[1]?.runState).toBe("current");
});
it("still rejects two fresh copies of a non-repeatable skill", () => {
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("treats a second generation-rules step as fresh, not revise", () => {
const flow = parseCreationFlow(`{
"steps": [
{ "id": "生成规则·怪物", "name": "生成规则", "params": { "target": "怪物" }, "depends_on": [] },
{ "id": "生成规则·NPC", "name": "生成规则", "params": { "target": "NPC" }, "depends_on": ["生成规则·怪物"] }
]
}`)!;
expect(flow.steps[1]?.mode).toBeUndefined();
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
});
it("revise of generation-rules inherits params from the origin step", () => {
const flow = parseCreationFlow(`{
"steps": [
{
"id": "生成规则·怪物",
"name": "生成规则",
"params": { "target": "怪物", "rule_id": "monsters" },
"depends_on": []
},
{
"id": "生成规则·怪物·改",
"name": "生成规则",
"mode": "revise",
"revises": "生成规则·怪物",
"depends_on": ["生成规则·怪物"]
}
]
}`)!;
expect(flow.steps[1]?.params).toEqual({
target: "怪物",
rule_id: "monsters",
});
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
});
it("nextPendingStep prefers a ready revise over a later fresh step", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
{
"name": "美学纲领与交互范式",
"mode": "revise",
"revises": "美学纲领与交互范式",
"depends_on": ["美学纲领与交互范式"]
}
]
}`)!;
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.mode).toBe("revise");
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe(
"美学纲领与交互范式·改",
);
});
it("injects inherit draft and skips opening on a revise design-step", async () => {
const flowRaw = JSON.stringify({
status: "open",
steps: [
{ id: "美学纲领与交互范式", name: "美学纲领与交互范式", depends_on: [] },
{
id: "美学纲领与交互范式·改",
name: "美学纲领与交互范式",
mode: "revise",
revises: "美学纲领与交互范式",
depends_on: ["美学纲领与交互范式"],
},
],
});
const binding = await resolveDesignStepBinding({
skillPackRoot: "dialogue/world-simulator",
flowRaw,
currentStepName: "美学纲领与交互范式·改",
acceptedStepNames: ["美学纲领与交互范式"],
});
expect(binding?.opening).toBeNull();
expect(binding?.inheritTag).toBe("设计.美学纲领与交互范式");
const loaded = await loadWorkerSkillWithContext(
"world-simulator",
"design-step",
undefined,
{
flowRaw,
currentStepName: "美学纲领与交互范式·改",
acceptedStepNames: ["美学纲领与交互范式"],
},
);
expect(loaded.promptBody).toContain("【回头修改】");
expect(loaded.promptBody).not.toContain("程序开场");
expect(loaded.worker.name).toContain("回头修改");
expect(loaded.worker.inputTags).toContain("设计.美学纲领与交互范式");
expect(
loaded.worker.contextSegments?.some((s) => s.id === "inherit-existing"),
).toBe(true);
});
});
+31 -2
View File
@@ -17,7 +17,7 @@ describe("resolveAcceptanceModeForWorker", () => {
expect(mode).toBe("user_confirmed");
});
it("maps review → user_confirmed and continue → no_confirmation", () => {
it("maps review → user_confirmed and continue → no_confirmation in design", () => {
const bb = new Blackboard();
const yaml = JSON.stringify({
version: 1,
@@ -32,7 +32,7 @@ describe("resolveAcceptanceModeForWorker", () => {
slots: {
...createSession().slots,
designInstanceReady: true,
uiLifecycleStage: "play",
uiLifecycleStage: "design",
},
};
@@ -51,6 +51,34 @@ describe("resolveAcceptanceModeForWorker", () => {
}),
).toBe("user_confirmed");
});
it("play ignores worker-set review and auto-continues", () => {
const bb = new Blackboard();
bb.write({
tag: "设计.worker集",
content: JSON.stringify({
version: 1,
workers: [{ ref: "narrator", acceptance: "review" }],
}),
source: "test",
});
const session = {
...createSession(),
slots: {
...createSession().slots,
designInstanceReady: true,
uiLifecycleStage: "play",
playLayerActive: true,
},
};
expect(
resolveAcceptanceModeForWorker({
session,
blackboard: bb,
workerId: "narrator",
}),
).toBe("no_confirmation");
});
});
describe("buildDeclaredWorkerSkill", () => {
@@ -92,5 +120,6 @@ describe("buildDeclaredWorkerSkill", () => {
expect(promptBody).toContain("残酷但不虐主");
expect(promptBody).toContain("主角免疫");
expect(promptBody).toContain("声明驱动");
expect(promptBody).toContain("500~2000");
});
});
+6
View File
@@ -45,5 +45,11 @@ describe("display-labels", () => {
const opening = reviewComposerCopy("opening-generator");
expect(opening.acceptLabel).toBe("选定此开场");
expect(opening.kicker).toBe("待选定");
const setup = reviewComposerCopy("design-step", {
outputTags: ["设计.开场白与开场变量"],
});
expect(setup.acceptLabel).toBe("选定此开场");
expect(setup.kind).toBe("opening");
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
createMockToolCall,
MockLlmProvider,
} from "../src/llm/client.js";
import { createLocalLlmDriver } from "../src/runtime/driver.js";
describe("local LLM driver", () => {
it("continues while tools return results, then stops on text", async () => {
const llm = new MockLlmProvider([
createMockToolCall("lookup", { q: "a" }, "c1"),
"done",
]);
const driver = createLocalLlmDriver(llm);
const seen: string[] = [];
const run = await driver.run({
system: "sys",
messages: [{ role: "user", content: "go" }],
tools: [
{
type: "function",
function: {
name: "lookup",
description: "lookup",
parameters: { type: "object", properties: {} },
},
},
],
handleStep: (calls) => {
seen.push(...calls.map((c) => c.name));
return {
kind: "continue",
results: calls.map((c) => ({ callId: c.id, content: "ok" })),
};
},
});
expect(seen).toEqual(["lookup"]);
expect(run.iterations).toBe(2);
expect(run.stop).toEqual({ kind: "text", content: "done" });
});
it("stops when handleStep concludes, without another model call", async () => {
const llm = new MockLlmProvider([
createMockToolCall("run_worker", { workerId: "x" }, "t1"),
"should-not-be-used",
]);
const driver = createLocalLlmDriver(llm);
const run = await driver.run({
system: "sys",
messages: [{ role: "user", content: "go" }],
tools: [
{
type: "function",
function: {
name: "run_worker",
description: "run",
parameters: { type: "object", properties: {} },
},
},
],
handleStep: (calls) => ({ kind: "conclude", call: calls[0]! }),
});
expect(run.iterations).toBe(1);
expect(run.stop.kind).toBe("conclude");
if (run.stop.kind === "conclude") {
expect(run.stop.call.name).toBe("run_worker");
}
});
it("throws when the iteration cap is hit", async () => {
const llm = new MockLlmProvider([
createMockToolCall("ping", {}, "p1"),
]);
const driver = createLocalLlmDriver(llm);
await expect(
driver.run({
system: "sys",
messages: [{ role: "user", content: "go" }],
tools: [
{
type: "function",
function: {
name: "ping",
description: "ping",
parameters: { type: "object", properties: {} },
},
},
],
maxIterations: 2,
handleStep: (calls) => ({
kind: "continue",
results: calls.map((c) => ({ callId: c.id, content: "pong" })),
}),
}),
).rejects.toThrow(/exceeded 2 iterations/);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
closeCreationFlowRaw,
isOpeningSealArtifact,
parseOpeningSealPayload,
openingVariablesToTableDoc,
} from "../src/skills/opening-seal.js";
const fragment = JSON.stringify({
schema: "context-fragment.v1",
技能: "开场白与开场变量",
brief: "宿舍缺水",
mount: ["narrator"],
正文: {
开场白全文: "你在宿舍醒来,瓶里只剩一口水。",
开场白候选: [
"你在宿舍醒来,瓶里只剩一口水。",
"门外有人砸门。你还没想好要不要开。",
],
开场变量: [{ 名: "口粮", 值: 1, 依据: "开场只剩一口水" }],
},
开放问题: [],
});
describe("opening-seal", () => {
it("detects opening-setup and opening-generator artifacts", () => {
expect(
isOpeningSealArtifact({
workerId: "design-step",
outputTags: ["设计.开场白与开场变量"],
}),
).toBe(true);
expect(
isOpeningSealArtifact({
workerId: "opening-generator",
outputTags: ["输出.开场白"],
}),
).toBe(true);
expect(
isOpeningSealArtifact({
workerId: "design-step",
outputTags: ["设计.美学纲领与交互范式"],
}),
).toBe(false);
});
it("parses 1+ opening candidates and selects by index", () => {
const first = parseOpeningSealPayload(fragment, 0);
expect(first?.candidates).toHaveLength(2);
expect(first?.selectedText).toContain("宿舍醒来");
const second = parseOpeningSealPayload(fragment, 1);
expect(second?.selectedText).toContain("砸门");
expect(second?.variables).toEqual([
{ name: "口粮", value: 1, note: "开场只剩一口水" },
]);
});
it("builds table rows from opening variables", () => {
const payload = parseOpeningSealPayload(fragment, 0);
const doc = openingVariablesToTableDoc(payload!.variables);
expect(doc.rows[0]?.key).toBe("口粮");
expect(doc.rows[0]?.value).toBe(1);
});
it("closes an open creation flow", () => {
const raw = JSON.stringify({
version: 1,
status: "open",
steps: [{ id: "开场白与开场变量", name: "开场白与开场变量", depends_on: [] }],
});
const closed = JSON.parse(closeCreationFlowRaw(raw)!);
expect(closed.status).toBe("closed");
});
});
+227 -3
View File
@@ -209,6 +209,72 @@ describe("phase machine", () => {
]);
});
it("later revision notes accumulate instead of replacing earlier ones", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
outputTags: ["设计.正文组成"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
session = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id, reason: "不如原本的上中下样式" },
}).session;
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
pendingArtifactId: artifact.id,
};
const second = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id, reason: "状态很短,放到上面就行了" },
});
expect(second.session.slots["用户.修订说明"]).toContain("不如原本的上中下样式");
expect(second.session.slots["用户.修订说明"]).toContain("状态很短,放到上面就行了");
});
it("accepting a design-step artifact clears stale revision notes", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
outputTags: ["设计.监控栏"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
slots: {
"用户.修订说明": "上一步的意见",
revisionInstruction: "上一步的意见",
"用户.worker答复": "监控对象是剩余食物",
},
};
session = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
}).session;
expect(session.slots["用户.修订说明"]).toBeUndefined();
expect(session.slots.revisionInstruction).toBeUndefined();
expect(session.slots["用户.worker答复"]).toBeUndefined();
expect(session.slots["用户.需求"]).toContain("监控对象是剩余食物");
expect(session.slots["用户.需求"]).toContain("上一步的意见");
});
it("failed revision rerun falls back to reviewing the previous artifact", () => {
let session = createSession("default");
const artifact = {
@@ -281,7 +347,35 @@ describe("phase machine", () => {
]);
});
it("accepting design-step enters next_intent instead of invoking main agent", () => {
it("accepting design-step worker set proposes next step instead of invoking main agent", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "细化终稿",
outputTags: ["设计.worker集"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.slots.designInstanceReady).toBe(true);
expect(accepted.session.phase).toBe("running");
expect(accepted.session.waitingReason).toBeUndefined();
expect(accepted.effects).toEqual([{ type: "propose_next_creation_step" }]);
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
});
it("accepting design-step proposes next step instead of invoking main agent", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
@@ -300,12 +394,22 @@ describe("phase machine", () => {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.waitingReason?.kind).toBe("next_intent");
expect(accepted.session.phase).toBe("running");
expect(accepted.effects).toEqual([{ type: "propose_next_creation_step" }]);
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
});
const continued = applyEvent(accepted.session, {
it("legacy next_intent still proposes next step and records intent", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "next_intent", afterWorkerId: "design-step" },
};
const continued = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "接下来写怪物生成规则" },
});
@@ -317,4 +421,124 @@ describe("phase machine", () => {
{ type: "propose_next_creation_step" },
]);
});
it("accepting opening-setup seals creation instead of next_intent", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "开场白与开场变量",
outputTags: ["设计.开场白与开场变量"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.waitingReason?.kind).toBe("input");
expect(accepted.session.waitingReason).toMatchObject({
kind: "input",
});
expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]);
expect(
accepted.effects.some((e) => e.type === "invoke_main_agent"),
).toBe(false);
});
it("accepting opening-generator also seals creation", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "opening-generator",
outputTags: ["输出.开场白", "运行.初始变量"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.waitingReason?.kind).toBe("input");
expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]);
});
it("sealed creation still blocks input until play", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "input" },
slots: { creationSealedByOpening: true, designInstanceReady: true },
};
const blocked = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "我买金首饰" },
});
expect(blocked.effects).toEqual([
{ type: "emit_message", message: "创作已收口并保存。请切换到「游玩」开始。" },
]);
expect(blocked.effects.some((e) => e.type === "run_play_turn")).toBe(false);
});
it("play input starts a play turn even after opening seal", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "input" },
slots: {
creationSealedByOpening: true,
designInstanceReady: true,
uiLifecycleStage: "play",
},
};
const play = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "我买金首饰" },
});
expect(play.session.phase).toBe("running");
expect(play.effects).toEqual([{ type: "run_play_turn" }]);
});
it("accepting a play worker continues the play pipeline", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "narrator",
outputTags: ["输出.用户展示"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
slots: {
designInstanceReady: true,
uiLifecycleStage: "play",
playLayerActive: true,
},
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.effects).toEqual([{ type: "continue_play_turn" }]);
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
expect(accepted.effects.some((e) => e.type === "propose_next_creation_step")).toBe(
false,
);
});
});
+80
View File
@@ -5,6 +5,8 @@ import {
MockLlmProvider,
} from "../src/llm/client.js";
import { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js";
import { createSession } from "../src/runtime/phase-machine.js";
import { Blackboard } from "../src/blackboard/blackboard.js";
describe("phase runtime", () => {
it("auto-loads default orchestrator and awaits first user input", async () => {
@@ -47,4 +49,82 @@ describe("phase runtime", () => {
true,
);
});
it("play turn runs declared pipeline without review and waits for input", async () => {
const session = createSession("default");
session.phase = "waiting_user";
session.waitingReason = { kind: "input" };
session.slots = {
designInstanceReady: true,
uiLifecycleStage: "play",
playLayerActive: true,
startupCompleted: true,
};
const blackboard = new Blackboard();
blackboard.write({
tag: "设计.worker集",
content: JSON.stringify({
version: 1,
play_slots: {
auditor: false,
gm: true,
narrator: true,
perspective: false,
},
workers: [
{ ref: "world-simulator", acceptance: "continue" },
{ ref: "narrator", acceptance: "review" },
],
}),
source: "test",
});
const runtime = new PhaseRuntime({
autoStubWorker: true,
initialSession: session,
initialBlackboardItems: blackboard.exportItems(),
});
await runtime.submitInput("我网购一个金首饰试试");
expect(runtime.getSession().waitingReason?.kind).toBe("input");
expect(runtime.getSession().artifacts.map((a) => a.workerId)).toEqual([
"world-simulator",
"narrator",
]);
expect(runtime.getSession().artifacts.every((a) => a.status === "accepted")).toBe(
true,
);
});
it("retryStuckRun restores the in-flight worker snapshot and reruns", 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,
}),
);
const snap = runtime.getLastWorkerRunSnapshot();
expect(snap?.workerId).toBe("design-flow");
const artifactsBefore = runtime.getSession().artifacts.length;
const session = runtime.getSession();
session.phase = "running";
session.waitingReason = undefined;
session.currentWorkerId = "design-flow";
await runtime.retryStuckRun();
expect(runtime.getSession().artifacts.length).toBeGreaterThan(artifactsBefore);
expect(
runtime.getSession().artifacts.filter((a) => a.workerId === "design-flow").length,
).toBeGreaterThanOrEqual(2);
});
it("retryStuckRun throws when nothing is running", async () => {
const runtime = new PhaseRuntime({ autoStubWorker: true });
await runtime.start();
await expect(runtime.retryStuckRun()).rejects.toThrow("当前没有可重试的执行");
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js";
import { assemblePresetMessages } from "../src/preset/assembler.js";
import type { WorldInfoPack } from "../src/preset/world-info-pack.js";
const pack: WorldInfoPack = {
worldBookBefore: [{ id: "lore", name: "舞台", content: "古镇夜雨" }],
history: "用户:昨天来过",
worldBookAfter: [{ id: "vars", name: "变量", content: "HP 3" }],
turn: "我推门",
postTurn: [{ id: "tail", name: "尾注", content: "勿剧透" }],
};
describe("assemblePlayWorkerMessages", () => {
it("walks extended prompt_order: ST pre, our before, history, our after, turn, ST post, postTurn", () => {
const report = importSillyTavernPreset({
prompts: [
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
{
identifier: "charDescription",
name: "Desc",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
},
{ identifier: "jailbreak", name: "PHI", role: "system", content: "PHI" },
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "main", enabled: true },
{ identifier: "charDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "jailbreak", enabled: true },
],
},
],
});
const messages = assemblePlayWorkerMessages({
systemPrompt: "SKILL",
preset: report.preset,
pack,
});
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", "SKILL"],
["system", "MAIN"],
["system", "### 舞台\n\n古镇夜雨"],
["system", "用户:昨天来过"],
["system", "### 变量\n\nHP 3"],
["user", "我推门"],
["system", "PHI"],
["system", "### 尾注\n\n勿剧透"],
]);
});
it("does not fill ST charDescription even if resolver returns content", () => {
const report = importSillyTavernPreset({
prompts: [
{
identifier: "charDescription",
name: "Desc",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
},
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "charDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
],
},
],
});
const messages = assemblePresetMessages(report.preset, (id) => {
if (id === "charDescription") return "CARD";
if (id === "chatHistory") return "HIST";
return null;
});
expect(messages.map((m) => m.content)).toEqual(["HIST"]);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
SLOT_PLAY_LAYER_ACTIVE,
SLOT_PLAY_TURN_QUEUE,
isPlayLayerActive,
readPlayTurnQueue,
withPlayTurnQueue,
} from "../src/skills/play-turn.js";
describe("play-turn slots", () => {
it("reads a trimmed queue and ignores junk", () => {
expect(readPlayTurnQueue(undefined)).toEqual([]);
expect(
readPlayTurnQueue({ [SLOT_PLAY_TURN_QUEUE]: [" auditor ", "", 3, "gm"] }),
).toEqual(["auditor", "gm"]);
});
it("writes queue or clears when empty", () => {
const withIds = withPlayTurnQueue({ a: 1 }, ["gm", "narrator"]);
expect(withIds[SLOT_PLAY_TURN_QUEUE]).toEqual(["gm", "narrator"]);
expect(withPlayTurnQueue(withIds, []).playTurnQueue).toBeUndefined();
});
it("detects play layer flag", () => {
expect(isPlayLayerActive(undefined)).toBe(false);
expect(isPlayLayerActive({ [SLOT_PLAY_LAYER_ACTIVE]: true })).toBe(true);
});
});
+25
View File
@@ -51,6 +51,31 @@ describe("present.v1", () => {
expect(p.shell).toBe("chapter_reader");
expect(p.blocks.body).toBe("hello");
});
it("strips 输出.用户展示 heading from plain body", () => {
const view = parsePresentPacket("### 输出.用户展示\n\n叮!账户来了。");
expect(view.packet.blocks.body).toBe("叮!账户来了。");
expect(String(view.packet.blocks.body)).not.toContain("用户展示");
});
it("strips heading then parses present JSON", () => {
const view = parsePresentPacket(
'### 输出.用户展示\n\n{"schema":"present.v1","shell":"prose","blocks":{"body":"场面很长"}}',
);
expect(view.fallbackPlain).toBe(false);
expect(view.packet.blocks.body).toBe("场面很长");
});
it("strips heading inside blocks.body", () => {
const view = parsePresentPacket(
JSON.stringify({
schema: "present.v1",
shell: "prose",
blocks: { body: "### 输出.用户展示\n正文" },
}),
);
expect(view.packet.blocks.body).toBe("正文");
});
});
describe("shell adaptation from reply-format", () => {
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import {
assemblePresetProbe,
DEFAULT_PROBE_CONTEXT,
PROBE_SYSTEM_PROMPT,
runPresetProbe,
} from "../src/preset/probe.js";
function samplePreset() {
return importSillyTavernPreset({
prompts: [
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
{
identifier: "charDescription",
name: "Desc",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
},
{ identifier: "jailbreak", name: "PHI", role: "system", content: "PHI" },
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "main", enabled: true },
{ identifier: "charDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "jailbreak", enabled: true },
],
},
],
}).preset;
}
describe("assemblePresetProbe", () => {
it("fills the sandwich with sample lore, history, and the current turn", () => {
const messages = assemblePresetProbe(samplePreset(), {
message: "我推开门。",
});
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", PROBE_SYSTEM_PROMPT],
["system", "MAIN"],
["system", `### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`],
["system", DEFAULT_PROBE_CONTEXT.history],
["system", `### 本轮状态\n\n${DEFAULT_PROBE_CONTEXT.loreAfter}`],
["user", "我推开门。"],
["system", "PHI"],
]);
});
it("omits empty optional bags", () => {
const messages = assemblePresetProbe(samplePreset(), {
message: "你好",
loreBefore: "",
history: "",
loreAfter: "",
postTurn: "",
});
expect(messages.map((m) => m.content)).toEqual([
PROBE_SYSTEM_PROMPT,
"MAIN",
"你好",
"PHI",
]);
});
});
describe("runPresetProbe", () => {
it("returns assembled messages without calling a model", async () => {
const result = await runPresetProbe({
preset: samplePreset(),
input: { message: "我推开门。" },
});
expect(result.completed).toBe(false);
expect(result.reply).toBeNull();
expect(result.messages.some((m) => m.content === "我推开门。")).toBe(true);
});
it("returns the model reply when complete is provided", async () => {
const result = await runPresetProbe({
preset: samplePreset(),
input: { message: "我推开门。" },
complete: async () => ({ content: "请进。", reasoning: "短回复" }),
});
expect(result.completed).toBe(true);
expect(result.reply).toBe("请进。");
expect(result.reasoning).toBe("短回复");
});
it("keeps messages when generate fails", async () => {
const result = await runPresetProbe({
preset: samplePreset(),
input: { message: "我推开门。" },
complete: async () => {
throw new Error("upstream down");
},
});
expect(result.completed).toBe(false);
expect(result.error).toBe("upstream down");
expect(result.messages.length).toBeGreaterThan(0);
});
it("rejects an empty turn", async () => {
await expect(
runPresetProbe({
preset: samplePreset(),
input: { message: " " },
}),
).rejects.toThrow("缺少本轮输入");
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type {
ChatMessage,
CompleteResult,
LlmProvider,
} from "../src/llm/client.js";
import { wrapLlmForSession } from "../src/llm/preset-wrapper.js";
import type { LlmTrackingContext } from "../src/llm/token-tracker.js";
import type { PresetPackage } from "../src/types/preset.js";
const samplePreset: PresetPackage = {
id: "p1",
name: "test-preset",
source: "native",
prompts: [
{
id: "sys",
name: "系统",
enabled: true,
role: "system",
content: "PRESET_RULES",
marker: false,
sourceIdentifier: "sys",
},
],
promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }],
generation: { temperature: 0.91, maxOutputTokens: 1234 },
unsupported: [],
importedAt: "2026-01-01T00:00:00.000Z",
};
function recordingProvider(): {
provider: LlmProvider;
received: Array<{ messages: ChatMessage[]; generation?: unknown }>;
} {
const received: Array<{ messages: ChatMessage[]; generation?: unknown }> = [];
const provider: LlmProvider = {
async complete(messages, options): Promise<CompleteResult> {
received.push({ messages, generation: options?.generation });
return { content: "ok", model: "rec-model" };
},
async completeStream(messages, options, callbacks) {
received.push({ messages, generation: options?.generation });
callbacks?.onContentDelta?.("ok");
return { content: "ok", model: "rec-model" };
},
async completeWithTools(messages, options) {
received.push({ messages, generation: options.generation });
return { content: "ok", toolCalls: [], model: "rec-model" };
},
};
return { provider, received };
}
describe("wrapLlmForSession", () => {
it("context trace matches the exact messages and generation sent to the inner LLM", async () => {
const { provider, received } = recordingProvider();
const ctx: LlmTrackingContext = {};
const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset);
await llm.complete(
[{ role: "user", content: "TASK" }],
{ caller: "worker:design-step" },
);
expect(received).toHaveLength(1);
expect(received[0].messages).toEqual([{ role: "user", content: "TASK" }]);
expect(received[0].generation).toMatchObject({
temperature: 0.91,
maxOutputTokens: 1234,
});
expect(ctx.pendingContextTrace?.messages).toEqual([
{ role: "user", content: "TASK" },
]);
expect(ctx.pendingContextTrace?.generation).toMatchObject({
temperature: 0.91,
maxOutputTokens: 1234,
});
expect(ctx.pendingContextTrace?.caller).toBe("worker:design-step");
expect(ctx.pendingContextTrace?.model).toBe("rec-model");
});
it("completeStream also records the merged request", async () => {
const { provider, received } = recordingProvider();
const ctx: LlmTrackingContext = {};
const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset);
const chunks: string[] = [];
await llm.completeStream!(
[{ role: "user", content: "TASK" }],
{ caller: "worker:stream" },
{ onContentDelta: (d) => chunks.push(d) },
);
expect(chunks.join("")).toBe("ok");
expect(received[0].messages).toEqual([{ role: "user", content: "TASK" }]);
expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages);
});
});
+59 -2
View File
@@ -52,16 +52,73 @@ const samplePreset = {
describe("importSillyTavernPreset", () => {
it("picks prompt_order with most enabled content", () => {
const report = importSillyTavernPreset(samplePreset, { name: "test" });
expect(report.enabledCount).toBe(3);
expect(report.preset.promptOrder.map((o) => o.promptId)).toEqual([
"custom-a",
"custom-b",
"main",
"worldBookBefore",
"chatHistory",
"worldBookAfter",
"currentTurn",
"postTurn",
]);
expect(report.enabledCount).toBe(8);
expect(report.preset.generation.temperature).toBe(1);
expect(report.preset.generation.maxOutputTokens).toBe(60000);
expect(report.warnings.some((w) => w.includes("prompt_order"))).toBe(true);
});
it("inserts app markers around chatHistory and leaves ST card/WI empty", () => {
const report = importSillyTavernPreset({
prompts: [
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
{
identifier: "charDescription",
name: "Char Description",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "Chat History",
role: "system",
marker: true,
},
{
identifier: "jailbreak",
name: "Post-History",
role: "system",
content: "PHI",
},
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "main", enabled: true },
{ identifier: "charDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "jailbreak", enabled: true },
],
},
],
});
expect(report.preset.promptOrder.map((o) => o.promptId)).toEqual([
"main",
"charDescription",
"worldBookBefore",
"chatHistory",
"worldBookAfter",
"currentTurn",
"jailbreak",
"postTurn",
]);
const messages = assemblePresetMessages(report.preset);
expect(messages).toEqual([
{ role: "system", content: "MAIN" },
{ role: "system", content: "PHI" },
]);
});
});
describe("assemblePresetMessages", () => {
@@ -79,7 +136,7 @@ describe("listEnabledPresetEntries", () => {
it("lists enabled order with inject flags", () => {
const report = importSillyTavernPreset(samplePreset);
const entries = listEnabledPresetEntries(report.preset);
expect(entries.length).toBe(3);
expect(entries.length).toBe(8);
expect(countInjectingEntries(entries)).toBe(2);
expect(entries.filter((e) => e.willInject).map((e) => e.content)).toEqual([
"instruction A",
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { MockLlmProvider } from "../src/llm/client.js";
import {
isAbortError,
runWithAbortSignal,
throwIfAborted,
} from "../src/llm/run-abort.js";
describe("run abort", () => {
it("isAbortError detects AbortError name", () => {
const err = new Error("This operation was aborted");
err.name = "AbortError";
expect(isAbortError(err)).toBe(true);
expect(isAbortError(new Error("LLM request failed"))).toBe(false);
});
it("throwIfAborted raises AbortError when the signal is aborted", () => {
const ac = new AbortController();
ac.abort();
expect(() => throwIfAborted(ac.signal)).toThrowError(/aborted/i);
});
it("mock LLM respects the active abort signal", async () => {
const llm = new MockLlmProvider(["ok"]);
const ac = new AbortController();
ac.abort();
await expect(
runWithAbortSignal(ac.signal, () => llm.complete([{ role: "user", content: "x" }])),
).rejects.toMatchObject({ name: "AbortError" });
});
});
+9 -2
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
filterBlackboardForInstance,
isRunPhaseBlackboardTag,
materializeInstanceSnapshotPayload,
} from "../src/book/snapshot-filters.js";
@@ -12,6 +11,10 @@ describe("snapshot-filters", () => {
expect(isRunPhaseBlackboardTag("角色.A.设定")).toBe(false);
expect(isRunPhaseBlackboardTag("博弈.规则.草稿")).toBe(false);
expect(isRunPhaseBlackboardTag("运行.事件流")).toBe(true);
expect(isRunPhaseBlackboardTag("输出.开场白")).toBe(false);
expect(isRunPhaseBlackboardTag("运行.初始变量")).toBe(false);
expect(isRunPhaseBlackboardTag("变量.当前")).toBe(false);
expect(isRunPhaseBlackboardTag("对话.历史")).toBe(true);
expect(isRunPhaseBlackboardTag("角色.A.思考")).toBe(true);
expect(isRunPhaseBlackboardTag("世界.当前状态")).toBe(true);
});
@@ -19,6 +22,8 @@ describe("snapshot-filters", () => {
it("materializeInstanceSnapshotPayload removes run blackboard items", () => {
const session = createSession("default");
session.slots.startupCompleted = true;
session.slots.playLayerActive = true;
session.slots.playTurnQueue = ["narrator"];
session.artifacts = [
{
id: "a1",
@@ -44,6 +49,7 @@ describe("snapshot-filters", () => {
{ id: "1", tag: "情境.实验.设定", content: "x", source: "worker", createdAt: "", updatedAt: "" },
{ id: "2", tag: "运行.事件流", content: "y", source: "worker", createdAt: "", updatedAt: "" },
{ id: "3", tag: "角色.A.设定", content: "z", source: "worker", createdAt: "", updatedAt: "" },
{ id: "4", tag: "对话.历史", content: "## 用户\n\n买金", source: "runtime", createdAt: "", updatedAt: "" },
],
});
@@ -53,6 +59,7 @@ describe("snapshot-filters", () => {
]);
expect(runtimeSession.artifacts).toHaveLength(1);
expect(runtimeSession.artifacts[0].workerId).toBe("setup-scenario");
expect(filterBlackboardForInstance).toBeDefined();
expect(runtimeSession.slots.playLayerActive).toBeUndefined();
expect(runtimeSession.slots.playTurnQueue).toBeUndefined();
});
});
+9
View File
@@ -98,5 +98,14 @@ describe("worker-declaration", () => {
},
};
expect(inferLifecycleStage(s2)).toBe("play");
const s3 = {
...sessionWithAcceptedWorkerSet(),
slots: {
...sessionWithAcceptedWorkerSet().slots,
playLayerActive: true,
},
};
expect(inferLifecycleStage(s3)).toBe("play");
});
});
+52 -5
View File
@@ -191,7 +191,7 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("特权与惊惶");
});
it("does not accept a fragment without 正文; keeps 追问 for the LLM loop", () => {
it("keeps incomplete fragment (no 正文) for review; 追问 hangs as sidecar", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
@@ -210,12 +210,12 @@ describe("sanitizeWorkerSetOutputs", () => {
}),
["设计.美学纲领与交互范式"],
);
expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined();
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("还差站位");
expect(result.askUser?.length).toBeGreaterThan(0);
expect(result.askUser?.[0]?.prompt).toContain("你代入吗");
});
it("asks to continue the loop when fragment JSON is unusable", () => {
it("keeps truncated fragment JSON as a reviewable draft", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
@@ -226,7 +226,54 @@ describe("sanitizeWorkerSetOutputs", () => {
}),
["设计.美学纲领与交互范式"],
);
expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined();
expect(result.askUser?.[0]?.prompt).toContain("还没写出可验收的产物");
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("孤立免疫");
expect(result.askUser).toBeUndefined();
});
it("canonicalizes context-order agents[] into slots on the artifact tag", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
"设计.上下文投影排序": {
schema: "context-order.v1",
brief: "主世界层",
agents: [
{
id: "gm",
enabled: true,
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
],
},
],
},
},
summary: "ok",
}),
["设计.上下文投影排序"],
);
const doc = JSON.parse(result.outputs["设计.上下文投影排序"]!);
expect(doc.slots[0].ref).toBe("world-simulator");
expect(doc.play_slots.gm).toBe(true);
});
it("keeps a whole-response truncated JSON as the artifact draft", () => {
const raw =
'{"outputs":{"设计.上下文投影排序":{"schema":"context-order.v1","brief":"旁观+转述","agents":[';
const result = parseWorkerResponseForTest(raw, ["设计.上下文投影排序"]);
expect(result.outputs["设计.上下文投影排序"]).toContain("context-order.v1");
expect(result.askUser).toBeUndefined();
});
it("puts full 输出.用户展示 into preview without tag fence or 4000 cap", () => {
const body = "场面".repeat(2100);
const result = sanitizeWorkerSetOutputs({
outputs: { "输出.用户展示": body },
summary: "ok",
preview: "旧",
});
expect(result.preview).toBe(body);
expect(result.preview).not.toContain("### ");
expect(result.preview.length).toBeGreaterThan(4000);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { Blackboard } from "../src/blackboard/blackboard.js";
import { DIALOGUE_HISTORY_TAG } from "../src/skills/dialogue-history.js";
import {
CURRENT_TURN_TAG,
resolveWorldInfoMarker,
worldInfoPackFromSegments,
} from "../src/preset/world-info-pack.js";
import {
CURRENT_TURN,
WORLD_BOOK_AFTER,
WORLD_BOOK_BEFORE,
} from "../src/preset/markers.js";
import type { ContextSegmentDef } from "../src/skills/context-segments.js";
describe("worldInfoPackFromSegments", () => {
it("splits lore / history / after-history / turn by the two anchors", () => {
const bb = new Blackboard();
const segments: ContextSegmentDef[] = [
{
id: "stage",
tier: "static",
tags: [],
inline: "古镇夜雨",
label: "## 舞台骨架",
},
{
id: "hist",
tier: "dynamic",
tags: [DIALOGUE_HISTORY_TAG],
},
{
id: "vars",
tier: "dynamic",
tags: ["变量.当前"],
label: "## 变量",
},
{
id: "turn",
tier: "dynamic",
tags: [CURRENT_TURN_TAG],
},
{
id: "note",
tier: "dynamic",
tags: [],
inline: "尾注",
label: "## 本轮后",
},
];
const pack = worldInfoPackFromSegments({
segments,
inputs: {
[DIALOGUE_HISTORY_TAG]: "用户:你好",
"变量.当前": "HP 3",
[CURRENT_TURN_TAG]: "我推门进去",
},
blackboard: bb,
});
expect(pack.worldBookBefore.map((e) => e.content)).toEqual(["古镇夜雨"]);
expect(pack.history).toContain("你好");
expect(pack.worldBookAfter.map((e) => e.content)).toEqual(["HP 3"]);
expect(pack.turn).toBe("我推门进去");
expect(pack.postTurn.map((e) => e.content)).toEqual(["尾注"]);
expect(resolveWorldInfoMarker(pack, WORLD_BOOK_BEFORE)).toContain("古镇夜雨");
expect(resolveWorldInfoMarker(pack, WORLD_BOOK_AFTER)).toContain("HP 3");
expect(resolveWorldInfoMarker(pack, CURRENT_TURN)).toBe("我推门进去");
expect(resolveWorldInfoMarker(pack, "charDescription")).toBeNull();
});
});