预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。

- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
This commit is contained in:
2026-10-23 00:13:08 +08:00
parent ed09e5c831
commit 9dc927282f
94 changed files with 11558 additions and 413 deletions
+106
View File
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
bumpKeepStats,
clearKeepStats,
copyKeepStats,
keepStatPercent,
keepStatText,
keepStatTotal,
readKeepStats,
sortKeepStats,
} from "../web/chat-keep-stats.js";
const STORAGE_KEY = "wa-direct-chat-keep-stats-v1";
function fakeStorage() {
const map = new Map();
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, String(v)),
removeItem: (k) => map.delete(k),
clear: () => map.clear(),
};
}
const gpt = { profileId: "p-gpt", profileName: "主配置", model: "gpt-4o" };
const claude = { profileId: "p-claude", profileName: "备用", model: "claude-3-7" };
beforeEach(() => {
globalThis.localStorage = fakeStorage();
});
describe("chat keep stats", () => {
it("counts keeps per conversation and sorts them", () => {
bumpKeepStats("conv-a", gpt);
bumpKeepStats("conv-a", claude);
bumpKeepStats("conv-a", claude);
const stats = readKeepStats("conv-a");
expect(stats.map((s) => s.label)).toEqual(["备用", "主配置"]);
expect(stats[0].count).toBe(2);
expect(keepStatTotal(stats)).toBe(3);
});
it("keeps conversations apart", () => {
bumpKeepStats("conv-a", gpt);
bumpKeepStats("conv-b", claude);
expect(readKeepStats("conv-a").map((s) => s.label)).toEqual(["主配置"]);
expect(readKeepStats("conv-b").map((s) => s.label)).toEqual(["备用"]);
clearKeepStats("conv-a");
expect(readKeepStats("conv-a")).toEqual([]);
expect(readKeepStats("conv-b")).toHaveLength(1);
});
it("subtracts on unkeep and drops the model at zero", () => {
bumpKeepStats("conv-a", gpt);
bumpKeepStats("conv-a", gpt);
expect(readKeepStats("conv-a")[0].count).toBe(2);
bumpKeepStats("conv-a", gpt, -1);
expect(readKeepStats("conv-a")[0].count).toBe(1);
bumpKeepStats("conv-a", gpt, -1);
expect(readKeepStats("conv-a")).toEqual([]);
});
it("treats the same profile + model as the same model even if the window id changes", () => {
bumpKeepStats("conv-a", { ...gpt, id: "w1" });
bumpKeepStats("conv-a", { ...gpt, id: "w2" });
expect(readKeepStats("conv-a")).toHaveLength(1);
expect(readKeepStats("conv-a")[0].count).toBe(2);
});
it("falls back to the profile name when the model is the same string", () => {
const text = keepStatText({ profileName: "本地", model: "本地" });
expect(text.label).toBe("本地");
expect(text.model).toBe("");
});
it("moves the counts when a conversation is copied or promoted", () => {
bumpKeepStats("conv-a", gpt);
bumpKeepStats("conv-a", claude);
bumpKeepStats("conv-a", claude);
copyKeepStats("conv-a", "conv-copy");
expect(readKeepStats("conv-copy").map((s) => s.count)).toEqual([2, 1]);
expect(readKeepStats("conv-a")).toEqual([]);
});
it("survives a broken storage payload", () => {
globalThis.localStorage.setItem(STORAGE_KEY, "{not json");
expect(readKeepStats("conv-a")).toEqual([]);
bumpKeepStats("conv-a", gpt);
expect(readKeepStats("conv-a")).toHaveLength(1);
});
it("sorts equal counts by label and scales bar widths", () => {
const sorted = sortKeepStats([
{ id: "b", label: "乙", model: "", count: 1 },
{ id: "a", label: "甲", model: "", count: 1 },
{ id: "c", label: "丙", model: "", count: 3 },
]);
expect(sorted.map((s) => s.id)).toEqual(["c", "a", "b"]);
expect(keepStatPercent(3, 3)).toBe(100);
expect(keepStatPercent(1, 3)).toBe(33);
expect(keepStatPercent(1, 100)).toBe(6);
expect(keepStatPercent(0, 3)).toBe(0);
expect(keepStatPercent(1, 0)).toBe(0);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { planWheelScroll } from "../web/chat-scroll.js";
describe("planWheelScroll", () => {
it("rolls the card first and leaves the big scroller alone", () => {
const plan = planWheelScroll({
cardScrollTop: 0,
cardScrollMax: 400,
deltaY: 100,
streamScrollTop: 0,
streamScrollMax: 2000,
});
expect(plan.cardDelta).toBe(100);
expect(plan.streamDelta).toBe(0);
expect(plan.rest).toBe(0);
});
it("hands the leftover to the big scroller once the card hits its bottom", () => {
const plan = planWheelScroll({
cardScrollTop: 380,
cardScrollMax: 400,
deltaY: 100,
streamScrollTop: 0,
streamScrollMax: 2000,
});
expect(plan.cardDelta).toBe(20);
expect(plan.streamDelta).toBe(80);
expect(plan.rest).toBe(0);
});
it("skips the card when it is already at the bottom", () => {
const plan = planWheelScroll({
cardScrollTop: 400,
cardScrollMax: 400,
deltaY: 100,
streamScrollTop: 0,
streamScrollMax: 2000,
});
expect(plan.cardDelta).toBe(0);
expect(plan.streamDelta).toBe(100);
});
it("scrolls up the card first, then the big scroller", () => {
const plan = planWheelScroll({
cardScrollTop: 10,
cardScrollMax: 400,
deltaY: -100,
streamScrollTop: 500,
streamScrollMax: 2000,
});
expect(plan.cardDelta).toBe(-10);
expect(plan.streamDelta).toBe(-90);
});
it("treats a card that cannot scroll as no card at all", () => {
const plan = planWheelScroll({
cardScrollTop: 0,
cardScrollMax: 0,
deltaY: 120,
streamScrollTop: 0,
streamScrollMax: 2000,
});
expect(plan.cardDelta).toBe(0);
expect(plan.streamDelta).toBe(120);
});
it("stops at the ends of both scrollers", () => {
const down = planWheelScroll({
cardScrollTop: 400,
cardScrollMax: 400,
deltaY: 100,
streamScrollTop: 2000,
streamScrollMax: 2000,
});
expect(down.cardDelta).toBe(0);
expect(down.streamDelta).toBe(0);
expect(down.rest).toBe(100);
const up = planWheelScroll({
cardScrollTop: 0,
cardScrollMax: 400,
deltaY: -100,
streamScrollTop: 0,
streamScrollMax: 2000,
});
expect(up.rest).toBe(-100);
});
it("keeps fractional trackpad deltas", () => {
const plan = planWheelScroll({
cardScrollTop: 12.5,
cardScrollMax: 400,
deltaY: 7.25,
});
expect(plan.cardDelta).toBeCloseTo(7.25, 5);
expect(plan.rest).toBeCloseTo(0, 5);
});
it("does nothing without a delta", () => {
const plan = planWheelScroll({ cardScrollMax: 400, streamScrollMax: 800 });
expect(plan).toEqual({ cardDelta: 0, streamDelta: 0, rest: 0 });
});
});
+62 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
appendDialogueHistoryTurn,
parseHistoryMessages,
projectDialogueHistory,
buildHistoryFallback,
isDialogueHistoryRef,
@@ -14,18 +15,78 @@ import {
synthesizeContextOrderFromWorkers,
} from "../src/skills/context-order.js";
describe("parseHistoryMessages", () => {
it("turns ## 用户 / ## 助手 blocks into real turns", () => {
const raw = appendDialogueHistoryTurn(
appendDialogueHistoryTurn("", { role: "助手", text: "门开了。" }),
{ role: "用户", text: "我推门。" },
);
expect(parseHistoryMessages(raw)).toEqual([
{ role: "assistant", content: "门开了。" },
{ role: "user", content: "我推门。" },
]);
});
it("accepts 【用户】 and 用户:single-line forms", () => {
expect(parseHistoryMessages("【用户】\n你好\n\n【助手】\n在。")).toEqual([
{ role: "user", content: "你好" },
{ role: "assistant", content: "在。" },
]);
expect(parseHistoryMessages("用户:你好\n助手:在。")).toEqual([
{ role: "user", content: "你好" },
{ role: "assistant", content: "在。" },
]);
});
it("keeps the projection note on the first turn", () => {
const turns = parseHistoryMessages(
"(仅最近 20 段,更早已省略)\n\n## 用户\n\n你好",
);
expect(turns).toHaveLength(1);
expect(turns[0]!.content).toContain("仅最近");
expect(turns[0]!.content).toContain("你好");
});
it("returns [] for text without role markers", () => {
expect(parseHistoryMessages("一段无结构的前情提要")).toEqual([]);
});
it("keeps a body line that looks like markdown heading", () => {
const raw = "## 用户\n\n我说## 这不是块头\n\n## 助手\n\n嗯";
const turns = parseHistoryMessages(raw);
expect(turns).toHaveLength(2);
expect(turns[0]!.content).toContain("这不是块头");
});
});
describe("dialogue history projection", () => {
it("recognizes history refs and projects by level", () => {
expect(isDialogueHistoryRef("对话.历史")).toBe(true);
expect(isDialogueHistoryRef("history")).toBe(true);
const long = Array.from({ length: 30 }, (_, i) => `## 用户\n\n第${i}句`).join(
const long = Array.from({ length: 60 }, (_, i) => `## 用户\n\n第${i}句`).join(
"\n\n",
);
const summary = projectDialogueHistory(long, "summary");
expect(summary).toContain("仅最近");
// 裁到 n 段(summary = 20)
expect(summary.split("## 用户").length - 1).toBe(20);
expect(projectDialogueHistory(long, "fixed")).toContain("不注入");
});
it("keeps the whole window until it doubles(滞回,不每轮位移)", () => {
const turn = (n: number) =>
Array.from({ length: n }, (_, i) => `## 用户\n\n第${i}句`).join("\n\n");
// 20~40 段(summary 的 n=20,高水位=40)原样全给,前缀稳定
expect(projectDialogueHistory(turn(30), "summary")).not.toContain("仅最近");
expect(projectDialogueHistory(turn(30), "summary").split("## 用户").length - 1).toBe(
30,
);
// 超过高水位才裁,且一次裁到 20
expect(projectDialogueHistory(turn(41), "summary").split("## 用户").length - 1).toBe(
20,
);
});
it("assembles history tag in flat segment order", () => {
const bb = new Blackboard();
bb.write({
+223 -2
View File
@@ -8,18 +8,25 @@ import {
assembleDirectChatMessages,
buildChatHistory,
buildDirectChatTaskMessages,
abortReroll,
beginReroll,
buildPriorForWindow,
createConversation,
createShell,
DEFAULT_DIRECT_CHAT_SYSTEM,
duplicateConversation,
EMPTY_SHELL_SLOTS,
getConversation,
mergeTurnReplies,
switchTurnVariant,
writeRerollReplies,
getShell,
listConversations,
listShells,
promoteTemporary,
resolveDirectChatPreset,
runDirectChatTurn,
type DirectChatTurn,
} from "../src/direct-chat/index.js";
function samplePreset() {
@@ -83,13 +90,13 @@ describe("direct-chat history assembly", () => {
expect(contents.some((c) => String(c).includes("契约"))).toBe(true);
});
it("uses default system prompt when slot empty", () => {
it("emits no system message when the slot is empty (酒馆没有隐式系统提示)", () => {
const task = buildDirectChatTaskMessages({
message: "hi",
slots: EMPTY_SHELL_SLOTS,
priorMessages: [],
});
expect(task[0]?.content).toBe(DEFAULT_DIRECT_CHAT_SYSTEM);
expect(task.some((m) => m.role === "system")).toBe(false);
});
});
@@ -147,6 +154,40 @@ describe("direct-chat store", () => {
expect(conversation.name).toBe("升格会话");
expect(conversation.messages).toHaveLength(2);
});
it("duplicates a conversation under the same shell", () => {
const shell = createShell({ name: "壳" });
const conv = createConversation({
shellId: shell.id,
name: "原会话 副本",
windows: [
{ id: "w1", profileId: "p1", profileName: "甲", model: "m-a" },
],
turns: [
{
id: "t1",
user: { content: "问", createdAt: "1" },
replies: [{ windowId: "w1", content: "答" }],
selectedWindowId: "w1",
createdAt: "1",
},
],
});
const copy = duplicateConversation(conv.id);
expect(copy.id).not.toBe(conv.id);
expect(copy.shellId).toBe(shell.id);
expect(copy.name).toBe("原会话 副本");
expect(copy.windows).toHaveLength(1);
expect(copy.windows[0]?.id).not.toBe("w1");
expect(copy.windows[0]?.profileId).toBe("p1");
expect(copy.turns[0]?.user.content).toBe("问");
expect(copy.turns[0]?.replies[0]?.windowId).toBe(copy.windows[0]?.id);
expect(copy.turns[0]?.selectedWindowId).toBe(copy.windows[0]?.id);
const original = getConversation(conv.id);
expect(original?.windows[0]?.id).toBe("w1");
expect(original?.turns).toHaveLength(1);
expect(listConversations(shell.id)).toHaveLength(2);
});
});
describe("direct-chat turn gating", () => {
@@ -230,3 +271,183 @@ describe("buildPriorForWindow", () => {
]);
});
});
function sampleTurn(
id: string,
content: string,
reply: string,
): {
id: string;
user: { content: string; createdAt: string };
replies: Array<{ windowId: string; content: string }>;
createdAt: string;
} {
return {
id,
user: { content, createdAt: "2026-01-01T00:00:00.000Z" },
replies: [{ windowId: "w1", content: reply }],
createdAt: "2026-01-01T00:00:00.000Z",
};
}
describe("direct-chat turn variants", () => {
const turns = () => [sampleTurn("t1", "问1", "答1"), sampleTurn("t2", "问2", "答2")];
it("edits the original line, rerolls, and keeps later turns on the previous version", () => {
const begun = beginReroll(turns(), "t1", "问1改");
expect(begun).not.toBeNull();
expect(begun!.turns.map((t) => t.id)).toEqual(["t1"]);
expect(begun!.turns[0]?.user.content).toBe("问1改");
expect(begun!.turns[0]?.replies).toEqual([]);
expect(begun!.turns[0]?.variantIndex).toBe(1);
expect(begun!.turns[0]?.variants?.[0]?.user.content).toBe("问1");
expect(begun!.turns[0]?.variants?.[0]?.replies[0]?.content).toBe("答1");
expect(begun!.turns[0]?.variants?.[0]?.after[0]?.user.content).toBe("问2");
expect(begun!.prior).toEqual([]);
const withReply = writeRerollReplies(begun!.turns, "t1", [
{ windowId: "w1", content: "新答" },
]);
const back = switchTurnVariant(withReply, "t1", "prev");
expect(back).not.toBeNull();
expect(back!.map((t) => t.user.content)).toEqual(["问1", "问2"]);
expect(back![0]?.replies[0]?.content).toBe("答1");
const again = switchTurnVariant(back!, "t1", "next");
expect(again).not.toBeNull();
expect(again!).toHaveLength(1);
expect(again![0]?.user.content).toBe("问1改");
expect(again![0]?.replies[0]?.content).toBe("新答");
});
it("direct reroll keeps the same user text and a new version", () => {
const begun = beginReroll(turns(), "t2", "问2");
expect(begun!.turns.map((t) => t.user.content)).toEqual(["问1", "问2"]);
expect(begun!.turns[1]?.variantIndex).toBe(1);
expect(begun!.turns[1]?.variants?.[0]?.replies[0]?.content).toBe("答2");
expect(begun!.prior.map((t) => t.id)).toEqual(["t1"]);
});
it("drops an empty reroll when generation produced nothing", () => {
const begun = beginReroll(turns(), "t1", "问1");
const aborted = abortReroll(begun!.turns, "t1");
expect(aborted).not.toBeNull();
expect(aborted!.map((t) => t.user.content)).toEqual(["问1", "问2"]);
expect(aborted![0]?.replies[0]?.content).toBe("答1");
expect(aborted![0]?.variants).toHaveLength(1);
});
it("rejects blank text and unknown turns", () => {
expect(beginReroll(turns(), "t1", " ")).toBeNull();
expect(beginReroll(turns(), "missing", "x")).toBeNull();
expect(switchTurnVariant(turns(), "t1", "next")).toBeNull();
});
it("retries only the failed windows and keeps the rest in place", () => {
const base: DirectChatTurn[] = [
{
id: "t1",
user: { content: "问1", createdAt: "2026-01-01T00:00:00.000Z" },
replies: [
{ windowId: "w1", content: "好的回复" },
{ windowId: "w2", error: "上游 403" },
{ windowId: "w3", error: "超时" },
],
selectedWindowId: "w1",
createdAt: "2026-01-01T00:00:00.000Z",
},
];
const merged = mergeTurnReplies(base, "t1", [
{ windowId: "w2", content: "重试后的回复" },
{ windowId: "w3", error: "还是超时" },
]);
expect(merged).toHaveLength(1);
const turn = merged[0]!;
// 只重试 w2/w3:w1 原样保留,w3 的错误被新错误覆盖
expect(turn.replies.find((r) => r.windowId === "w1")?.content).toBe(
"好的回复",
);
expect(turn.replies.find((r) => r.windowId === "w2")?.content).toBe(
"重试后的回复",
);
expect(turn.replies.find((r) => r.windowId === "w2")?.error).toBeUndefined();
expect(turn.replies.find((r) => r.windowId === "w3")?.error).toBe(
"还是超时",
);
// 不新开版本,且不动「保留此回复」
expect(turn.variants ?? []).toHaveLength(0);
expect(turn.variantIndex).toBe(0);
expect(turn.selectedWindowId).toBe("w1");
// 原数据不被就地修改
expect(base[0]!.replies.find((r) => r.windowId === "w2")?.error).toBe(
"上游 403",
);
});
it("merges a retry into the active version without dropping other versions", () => {
const begun = beginReroll(turns(), "t1", "问1改");
const withReply = writeRerollReplies(begun!.turns, "t1", [
{ windowId: "w1", content: "新答" },
]);
const merged = mergeTurnReplies(withReply, "t1", [
{ windowId: "w1", content: "重试后" },
]);
expect(merged[0]?.replies[0]?.content).toBe("重试后");
expect(merged[0]?.variants).toHaveLength(2);
expect(merged[0]?.variants?.[1]?.replies[0]?.content).toBe("重试后");
expect(merged[0]?.variants?.[0]?.replies[0]?.content).toBe("答1");
});
it("leaves turns untouched when the target turn is gone", () => {
const base = turns();
expect(mergeTurnReplies(base, "missing", [{ windowId: "w1", content: "x" }])).toBe(
base,
);
});
it("roundtrips versions and remaps window ids when copying", () => {
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
path.join(tmpdir(), "wa-direct-chat-variants-"),
);
const shell = createShell({ name: "壳" });
const conv = createConversation({
shellId: shell.id,
windows: [{ id: "w1", profileId: "p1", profileName: "甲" }],
turns: [
{
...sampleTurn("t1", "问", "新"),
variantIndex: 1,
variants: [
{
user: { content: "问", createdAt: "2026-01-01T00:00:00.000Z" },
replies: [{ windowId: "w1", content: "旧" }],
selectedWindowId: "w1",
after: [sampleTurn("t2", "后", "续")],
},
{
user: { content: "问", createdAt: "2026-01-01T00:00:00.000Z" },
replies: [{ windowId: "w1", content: "新" }],
after: [],
},
],
},
],
});
const loaded = getConversation(conv.id);
expect(loaded?.turns[0]?.variantIndex).toBe(1);
expect(loaded?.turns[0]?.variants?.[0]?.replies[0]?.content).toBe("旧");
expect(loaded?.turns[0]?.variants?.[0]?.selectedWindowId).toBe("w1");
expect(loaded?.turns[0]?.variants?.[0]?.after[0]?.user.content).toBe("后");
const copy = duplicateConversation(conv.id);
const wid = copy.windows[0]?.id;
expect(wid).toBeTruthy();
expect(wid).not.toBe("w1");
expect(copy.turns[0]?.replies[0]?.windowId).toBe(wid);
expect(copy.turns[0]?.variants?.[0]?.replies[0]?.windowId).toBe(wid);
expect(copy.turns[0]?.variants?.[0]?.selectedWindowId).toBe(wid);
expect(copy.turns[0]?.variants?.[0]?.after[0]?.replies[0]?.windowId).toBe(wid);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import type { ChatMessage } from "../src/llm/client.js";
import { squashMessages } from "../src/llm/message-squash.js";
const sys = (content: string): ChatMessage => ({ role: "system", content });
const user = (content: string): ChatMessage => ({ role: "user", content });
const asst = (content: string): ChatMessage => ({ role: "assistant", content });
const toolCall: ChatMessage = {
role: "assistant",
content: null,
tool_calls: [
{ id: "1", type: "function", function: { name: "t", arguments: "{}" } },
],
};
const toolResult: ChatMessage = { role: "tool", content: "{}", tool_call_id: "1" };
describe("squashMessages", () => {
it("none returns the list untouched", () => {
const input = [sys("a"), sys("b")];
expect(squashMessages(input, "none")).toBe(input);
});
it("merge joins adjacent same-role messages with a blank line", () => {
const out = squashMessages([sys("a"), sys("b"), user("c")], "merge");
expect(out).toEqual([sys("a\n\nb"), user("c")]);
});
it("merge keeps role changes intact", () => {
const out = squashMessages([sys("a"), user("b"), asst("c")], "merge");
expect(out.map((m) => m.role)).toEqual(["system", "user", "assistant"]);
});
it("never merges tool groups", () => {
const out = squashMessages(
[sys("a"), sys("b"), toolCall, toolResult, asst("x"), asst("y")],
"merge",
);
expect(out.map((m) => m.role)).toEqual([
"system",
"assistant",
"tool",
"assistant",
]);
// 工具组原样保留
expect(out[1]).toBe(toolCall);
expect(out[2]).toBe(toolResult);
});
it("semi folds system into neighbouring messages", () => {
const out = squashMessages(
[sys("规则"), user("问题"), sys("补充"), asst("回答")],
"semi",
);
expect(out).toEqual([user("规则\n\n问题"), asst("补充\n\n回答")]);
});
it("strict makes the first message a user", () => {
const out = squashMessages([asst("前言"), user("问题")], "strict");
expect(out[0]?.role).toBe("user");
expect(out[0]?.content).toBe("前言\n\n问题");
});
it("single-user-turn collapses everything into one user message", () => {
const out = squashMessages(
[sys("规则"), user("问题"), asst("回答")],
"single-user-turn",
);
expect(out).toEqual([user("规则\n\n问题\n\n回答")]);
});
it("falls back to merge when a tool group is present", () => {
const out = squashMessages(
[sys("a"), sys("b"), toolCall, toolResult],
"single-user-turn",
);
expect(out.map((m) => m.role)).toEqual(["system", "assistant", "tool"]);
expect(out[0]?.content).toBe("a\n\nb");
});
it("handles empty input", () => {
expect(squashMessages([], "strict")).toEqual([]);
});
});
describe("system 档(对齐酒馆 squashSystemMessages)", () => {
it("只合并相邻 system,user/assistant 不动", () => {
const out = squashMessages(
[sys("a"), sys("b"), user("u1"), user("u2"), asst("c1"), asst("c2"), sys("d"), sys("e")],
"system",
);
expect(out).toEqual([
sys("a\nb"),
user("u1"),
user("u2"),
asst("c1"),
asst("c2"),
sys("d\ne"),
]);
});
it("不碰工具组", () => {
const out = squashMessages(
[sys("a"), sys("b"), toolCall, toolResult, sys("c"), sys("d")],
"system",
);
expect(out.map((m) => m.role)).toEqual(["system", "assistant", "tool", "system"]);
});
});
+3 -3
View File
@@ -20,11 +20,11 @@ describe("persona store", () => {
} = await import("../src/persona/store.js");
const list = listPersonas();
expect(list.map((p) => p.name)).toEqual(["玩家", CREATION_PLACEHOLDER_NAME]);
expect(getActivePersona()?.name).toBe("玩家");
expect(list.map((p) => p.name)).toEqual(["User", CREATION_PLACEHOLDER_NAME]);
expect(getActivePersona()?.name).toBe("User");
expect(getCreationDefaultPersona()?.name).toBe(CREATION_PLACEHOLDER_NAME);
expect(personaForLifecycle("design")?.name).toBe(CREATION_PLACEHOLDER_NAME);
expect(personaForLifecycle("play")?.name).toBe("玩家");
expect(personaForLifecycle("play")?.name).toBe("User");
});
it("creation default is exclusive and used only in design", async () => {
+113 -11
View File
@@ -1,12 +1,19 @@
import { describe, expect, it } from "vitest";
import type { ChatMessage } from "../src/llm/client.js";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js";
import { assemblePresetMessages } from "../src/preset/assembler.js";
import {
assemblePlayWorkerMessages,
shapeFramedMessages,
} from "../src/preset/play-frame.js";
import { assemblePresetMessages, PRESET_HISTORY_MARK } from "../src/preset/assembler.js";
import type { WorldInfoPack } from "../src/preset/world-info-pack.js";
const pack: WorldInfoPack = {
worldBookBefore: [{ id: "lore", name: "舞台", content: "古镇夜雨" }],
history: "用户:昨天来过",
history: [
{ role: "assistant", content: "昨夜灯灭" },
{ role: "user", content: "昨天来过" },
],
worldBookAfter: [{ id: "vars", name: "变量", content: "HP 3" }],
turn: "我推门",
postTurn: [{ id: "tail", name: "尾注", content: "勿剧透" }],
@@ -52,12 +59,14 @@ describe("assemblePlayWorkerMessages", () => {
});
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", "MAIN"],
// 任务契约并进 main,不再单独一条
["system", "MAIN\n\nSKILL"],
[
"system",
"### 任务契约\n\nSKILL\n\n### 舞台\n\n古镇夜雨",
"### 舞台\n\n古镇夜雨",
],
["system", "用户:昨天来过"],
["assistant", "昨夜灯灭"],
["user", "昨天来过"],
["system", "### 变量\n\nHP 3"],
["user", "我推门"],
["system", "PHI"],
@@ -103,7 +112,7 @@ describe("assemblePlayWorkerMessages", () => {
preset: report.preset,
pack: {
worldBookBefore: [{ id: "lore", name: "设定", content: "港口" }],
history: "昨日对话",
history: [{ role: "user", content: "昨日对话" }],
worldBookAfter: [],
turn: "本轮",
postTurn: [],
@@ -117,7 +126,7 @@ describe("assemblePlayWorkerMessages", () => {
).toBe(true);
});
it("does not fill ST charDescription even if resolver returns content", () => {
it("passes ST card markers to the resolver (play-frame gates filling on the card slot)", () => {
const report = importSillyTavernPreset({
prompts: [
{
@@ -148,7 +157,8 @@ describe("assemblePlayWorkerMessages", () => {
if (id === "chatHistory") return "HIST";
return null;
});
expect(messages.map((m) => m.content)).toEqual(["HIST"]);
// 这里测的是装配器本身:marker 会交给 resolver,填不填由 play-frame 的槽位决定。
expect(messages.map((m) => m.content)).toEqual(["CARD", "HIST"]);
});
it("fills personaDescription and expands @玩家 with the given persona", () => {
@@ -189,7 +199,7 @@ describe("assemblePlayWorkerMessages", () => {
preset: report.preset,
pack: {
worldBookBefore: [],
history: "昨日",
history: [{ role: "assistant", content: "昨日" }],
worldBookAfter: [],
turn: "本轮",
postTurn: [],
@@ -197,6 +207,98 @@ describe("assemblePlayWorkerMessages", () => {
persona: { name: "@玩家", description: "占位人设" },
});
expect(messages.some((m) => m.content === "占位人设")).toBe(true);
expect(messages.some((m) => m.content === "对 @玩家 说话")).toBe(true);
expect(
messages.some((m) => String(m.content).startsWith("对 @玩家 说话")),
).toBe(true);
});
it("injects injection_position=1 entries into the history at injection_depth", () => {
const report = importSillyTavernPreset({
prompts: [
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
},
{
identifier: "deep",
name: "深度破限",
role: "system",
content: "DEEP",
injection_position: 1,
injection_depth: 1,
},
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "main", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "deep", enabled: true },
],
},
],
});
const messages = assemblePlayWorkerMessages({
systemPrompt: "",
preset: report.preset,
pack: {
worldBookBefore: [],
history: [
{ role: "assistant", content: "昨夜灯灭" },
{ role: "user", content: "昨天来过" },
],
worldBookAfter: [],
turn: "我推门",
postTurn: [],
},
persona: null,
});
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", "MAIN"],
["assistant", "昨夜灯灭"],
["system", "DEEP"],
["user", "昨天来过"],
["user", "我推门"],
]);
});
it("merges consecutive same-role history turns but keeps them as turns", () => {
const messages: ChatMessage[] = [
{ role: "system", content: "A" },
{ role: "system", content: "B" },
{ role: "assistant", content: "旧1" },
{ role: "user", content: "旧2" },
{ role: "user", content: "旧3(助手当时没出正文)" },
{ role: "system", content: "DEEP" },
{ role: "assistant", content: "旧4" },
{ role: "system", content: "C" },
{ role: "user", content: "本轮" },
];
for (const index of [2, 3, 4, 5, 6]) {
Object.assign(messages[index] as object, { [PRESET_HISTORY_MARK]: true });
}
const shaped = shapeFramedMessages(messages, "strict");
const roles = shaped.map((m) => m.role);
// 历史还是多轮(没被揉成一条),且连发的两条用户合并为一条
expect(roles).toContain("assistant");
expect(shaped.filter((m) => m.role === "user" && m.content.includes("旧2")))
.toHaveLength(1);
expect(
shaped.some(
(m) => m.role === "user" && m.content.includes("旧2") && m.content.includes("旧3"),
),
).toBe(true);
// 深度插入的 system 不被折掉
expect(shaped.some((m) => m.content === "DEEP")).toBe(true);
// 史前 system 仍按 strict 处理(没出现第二条 user 以外的奇形)
expect(shaped.at(-1)!.role).toBe("user");
});
});
+195
View File
@@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js";
import { parseExampleDialogue } from "../src/preset/examples.js";
const rawPreset = {
new_chat_prompt: "[Start a new Chat]",
new_example_chat_prompt: "[Example Chat]",
prompts: [
{ identifier: "main", name: "main", role: "system", content: "MAIN" },
{
identifier: "dialogueExamples",
name: "examples",
role: "system",
marker: true,
content: "",
},
{
identifier: "chatHistory",
name: "history",
role: "system",
marker: true,
content: "",
},
],
prompt_order: [
{
character_id: 100001,
order: [
{ identifier: "main", enabled: true },
{ identifier: "dialogueExamples", enabled: true },
{ identifier: "chatHistory", enabled: true },
],
},
],
};
const emptyPack = {
worldBookBefore: [],
history: [],
worldBookAfter: [],
turn: "",
postTurn: [],
};
function assemble(overrides: Record<string, unknown> = {}) {
const report = importSillyTavernPreset(rawPreset, { name: "t" });
return assemblePlayWorkerMessages({
systemPrompt: "",
preset: report.preset,
pack: { ...emptyPack, ...overrides },
persona: null,
charName: "ProbeCard",
});
}
describe("酒馆包装层:new_chat_prompt 与示例对话", () => {
it("chatHistory 空时插一条 new_chat_prompt", () => {
const messages = assemble();
const history = messages.filter((m) => m.content === "[Start a new Chat]");
expect(history).toHaveLength(1);
expect(history[0]?.role).toBe("system");
});
it("有历史时 new_chat_prompt 仍排在历史最前", () => {
const messages = assemble({
history: [
{ role: "user", content: "第一句" },
{ role: "assistant", content: "第二句" },
],
});
const texts = messages.map((m) => m.content);
const head = texts.indexOf("[Start a new Chat]");
expect(head).toBeGreaterThanOrEqual(0);
expect(texts.indexOf("第一句")).toBe(head + 1);
expect(texts.indexOf("第二句")).toBe(head + 2);
});
it("示例对话按 <START> 切段,带 example_user / example_assistant", () => {
const messages = assemble({
exampleDialogue:
"<START>\n{{user}}: 今天做什么?\n{{char}}: 读书。\n更多解释",
});
expect(messages.map((m) => m.content)).toContain("[Example Chat]");
const user = messages.find((m) => m.name === "example_user");
const assistant = messages.find((m) => m.name === "example_assistant");
expect(user?.role).toBe("system");
expect(user?.content).toBe("今天做什么?");
expect(assistant?.role).toBe("system");
expect(assistant?.content).toBe("读书。\n更多解释");
});
it("没有示例对话就不发 new_example_chat_prompt", () => {
const texts = assemble().map((m) => m.content);
expect(texts).not.toContain("[Example Chat]");
});
it("人设描述为空时不塞「姓名:X」", () => {
const report = importSillyTavernPreset(
{
prompts: [
{
identifier: "personaDescription",
name: "persona",
role: "system",
marker: true,
content: "",
},
],
prompt_order: [
{ character_id: 100001, order: [{ identifier: "personaDescription", enabled: true }] },
],
},
{ name: "t2" },
);
const messages = assemblePlayWorkerMessages({
systemPrompt: "",
preset: report.preset,
pack: emptyPack,
persona: { name: "User", description: "" },
charName: "ProbeCard",
});
expect(messages.some((m) => String(m.content).startsWith("姓名:"))).toBe(false);
});
});
describe("parseExampleDialogue", () => {
it("多块示例各自成组", () => {
const blocks = parseExampleDialogue({
text: "<START>\nA: 一\nB: 二\n<START>\nA: 三\nB: 四",
userName: "A",
charName: "B",
});
expect(blocks.map((block) => block.length)).toEqual([2, 2]);
expect(blocks[1]?.map((m) => m.content)).toEqual(["三", "四"]);
});
it("续行并入上一条", () => {
const blocks = parseExampleDialogue({
text: "A: 第一行\n第二行\nB: 回复",
userName: "A",
charName: "B",
});
expect(blocks[0]?.map((m) => m.content)).toEqual(["第一行\n第二行", "回复"]);
});
});
describe("角色卡三块(槽位填)", () => {
const rawCardPreset = {
personality_format: "<character_personality>\n{{personality}}\n</character_personality>",
scenario_format: "<scenario>\n{{scenario}}\n</scenario>",
prompts: [
{ identifier: "charDescription", name: "desc", role: "system", marker: true, content: "" },
{ identifier: "charPersonality", name: "pers", role: "system", marker: true, content: "" },
{ identifier: "scenario", name: "scen", role: "system", marker: true, content: "" },
],
prompt_order: [
{
character_id: 100001,
order: [
{ identifier: "charDescription", enabled: true },
{ identifier: "charPersonality", enabled: true },
{ identifier: "scenario", enabled: true },
],
},
],
};
function build(card: unknown) {
const report = importSillyTavernPreset(rawCardPreset, { name: "card" });
return assemblePlayWorkerMessages({
systemPrompt: "",
preset: report.preset,
pack: { ...emptyPack, card },
persona: null,
charName: "ProbeCard",
});
}
it("描述原样发,性格与场景套预设模板", () => {
const messages = build({
description: "DESC",
personality: "PERS",
scenario: "SCEN",
});
const texts = messages.map((m) => m.content);
expect(texts).toContain("DESC");
expect(texts).toContain("<character_personality>\nPERS\n</character_personality>");
expect(texts).toContain("<scenario>\nSCEN\n</scenario>");
});
it("槽位为空就整条不发", () => {
expect(build(undefined)).toEqual([]);
});
});
+246
View File
@@ -0,0 +1,246 @@
/**
* 预设保真度(conformance):预设声明什么,发出去的 messages[] 就得是什么。
*
* 每个用例对应一条「预设是否真的实现了」的断言:
* 顺序 / 双过滤 / 未引用条目 / 角色 / 宏 / 历史多轮 / 深度插入 / 生成参数 / squash
*/
import { describe, expect, it } from "vitest";
import type {
ChatMessage,
CompleteResult,
LlmProvider,
} from "../src/llm/client.js";
import { wrapLlmForSession, resolveSquashForPreset } from "../src/llm/preset-wrapper.js";
import { importSillyTavernPreset, readSquashMode } from "../src/preset/importer.js";
import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js";
import type { WorldInfoPack } from "../src/preset/world-info-pack.js";
type RawPreset = Record<string, unknown>;
/** 一份「把该有的都放上」的 ST 预设:顺序、双过滤、宏、深度插入、未引用条目。 */
function richPreset(extra: RawPreset = {}): RawPreset {
return {
name: "保真度样本",
temperature: 0.77,
top_p: 0.9,
openai_max_tokens: 777,
squash_system_messages: false,
prompts: [
{ identifier: "main", name: "主提示", role: "system", content: "MAIN {{char}}" },
{ identifier: "style", name: "文风", role: "system", content: "文风:克制" },
{
identifier: "personaDescription",
name: "用户角色",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "历史",
role: "system",
marker: true,
},
{
identifier: "deep",
name: "历史里的破限",
role: "system",
content: "DEEP",
injection_position: 1,
injection_depth: 1,
injection_order: 0,
},
{
identifier: "jailbreak",
name: "越狱",
role: "system",
content: "PHI {{user}} / {{persona}}",
},
{ identifier: "vars", name: "变量", role: "system", content: "{{setvar::hp::3}}" },
{
identifier: "readvar",
name: "读变量",
role: "system",
content: "HP={{getvar::hp}}",
},
{ identifier: "off-entry", name: "关掉的条目", role: "system", content: "OFF" },
{
identifier: "order-off",
name: "顺序里关掉",
role: "system",
content: "ORDER_OFF",
},
{ identifier: "orphan", name: "没被顺序引用", role: "system", content: "ORPHAN" },
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "main", enabled: true },
{ identifier: "style", enabled: true },
{ identifier: "personaDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "deep", enabled: true },
{ identifier: "jailbreak", enabled: true },
{ identifier: "vars", enabled: true },
{ identifier: "readvar", enabled: true },
{ identifier: "off-entry", enabled: true },
{ identifier: "order-off", enabled: false },
// orphan 不在这里 → 必须不出现
],
},
],
...extra,
};
}
const pack: WorldInfoPack = {
worldBookBefore: [{ id: "lore", name: "", content: "舞台:古镇夜雨" }],
history: [
{ role: "assistant", content: "门开了。" },
{ role: "user", content: "我推门。" },
],
worldBookAfter: [{ id: "state", name: "", content: "HP 3" }],
turn: "我抬头",
postTurn: [],
};
function build(raw: RawPreset, opts: { charName?: string } = {}): ChatMessage[] {
const report = importSillyTavernPreset(raw);
return assemblePlayWorkerMessages({
systemPrompt: "",
preset: report.preset,
pack,
persona: { name: "林晚", description: "二十岁,缄默" },
charName: opts.charName ?? "长夜行",
});
}
describe("预设保真度:结构", () => {
it("按 prompt_order 顺序、按条目 role 发消息(squash=none 时原样)", () => {
const messages = build(richPreset());
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", "MAIN 长夜行"],
["system", "文风:克制"],
["system", "二十岁,缄默"],
["system", "舞台:古镇夜雨"],
["assistant", "门开了。"],
["system", "DEEP"], // depth=1 → 插在历史倒数第一条之前
["user", "我推门。"],
["system", "HP 3"],
["user", "我抬头"],
["system", "PHI 林晚 / 二十岁,缄默"],
["system", "HP=3"],
["system", "OFF"],
]);
});
it("order.enabled=false 才不发;prompt.enabled=false 照发(跟酒馆一致)", () => {
const raw = richPreset();
const prompts = raw.prompts as Array<Record<string, unknown>>;
prompts.find((p) => p.identifier === "off-entry")!.enabled = false;
const text = build(raw)
.map((m) => m.content)
.join("\n");
expect(text).toContain("OFF");
expect(text).not.toContain("ORDER_OFF");
});
it("prompt_order 没引用的条目一律不发", () => {
const text = build(richPreset())
.map((m) => m.content)
.join("\n");
expect(text).not.toContain("ORPHAN");
});
it("宏在装配期展开,跨条目共享一份变量表", () => {
const text = build(richPreset())
.map((m) => m.content)
.join("\n");
expect(text).toContain("MAIN 长夜行");
expect(text).toContain("PHI 林晚 / 二十岁,缄默");
// setvar 在 vars 条目、getvar 在后面的 readvar 条目
expect(text).toContain("HP=3");
});
it("历史洞展开成真多轮,不是一段文本", () => {
const messages = build(richPreset());
// 两轮各自一条 message,且没有哪一条把两轮揉在一起
expect(messages.some((m) => m.role === "assistant" && m.content === "门开了。"))
.toBe(true);
expect(messages.some((m) => m.role === "user" && m.content === "我推门。"))
.toBe(true);
expect(messages.some((m) => m.content.includes("门开了") && m.content.includes("我推门")))
.toBe(false);
// 变成一条 system 文本也不算:历史里的轮次必须保留 user / assistant
const assistantTurns = messages.filter((m) => m.role === "assistant");
expect(assistantTurns.length).toBeGreaterThan(0);
});
it("injection_position=1 按 depth 从历史底部插", () => {
const messages = build(richPreset());
const index = messages.findIndex((m) => m.content === "DEEP");
const histStart = messages.findIndex((m) => m.content === "门开了。");
const histEnd = messages.findIndex((m) => m.content === "我推门。");
expect(index).toBeGreaterThan(histStart);
expect(index).toBeLessThan(histEnd);
});
it("depth=0 贴在历史末尾(本轮输入之前)", () => {
const raw = richPreset();
const prompts = raw.prompts as Array<Record<string, unknown>>;
prompts.find((p) => p.identifier === "deep")!.injection_depth = 0;
const messages = build(raw);
const order = messages.map((m) => m.content);
expect(order.indexOf("DEEP")).toBe(order.indexOf("我推门。") + 1);
expect(order.indexOf("我抬头")).toBeGreaterThan(order.indexOf("DEEP"));
});
});
describe("预设保真度:squash 与生成参数", () => {
it("读 squash_system_messages:bool / 枚举 / 缺失", () => {
expect(readSquashMode(true)).toBe("system");
expect(readSquashMode(false)).toBe("none");
expect(readSquashMode("strict")).toBe("strict");
expect(readSquashMode(2)).toBe("semi");
expect(readSquashMode(undefined)).toBeUndefined();
expect(readSquashMode("weird")).toBeUndefined();
});
it("预设写了 squash 就跟预设;没写落回缺省 strict", () => {
const withNone = importSillyTavernPreset(richPreset()).preset;
expect(withNone.messagePolicy?.squash).toBe("none");
expect(resolveSquashForPreset(withNone, "follow-preset")).toBe("none");
const withTrue = importSillyTavernPreset(
richPreset({ squash_system_messages: true }),
).preset;
expect(resolveSquashForPreset(withTrue, "follow-preset")).toBe("system");
const rawDefault = richPreset();
delete rawDefault.squash_system_messages;
const byDefault = importSillyTavernPreset(rawDefault).preset;
expect(byDefault.messagePolicy).toBeUndefined();
expect(resolveSquashForPreset(byDefault, "follow-preset")).toBe("strict");
// 设置页显式选的优先于预设
expect(resolveSquashForPreset(withTrue, "semi")).toBe("semi");
});
it("生成参数从预设进请求", async () => {
const seen: Array<Record<string, unknown>> = [];
const provider: LlmProvider = {
async complete(_messages, options): Promise<CompleteResult> {
seen.push((options?.generation ?? {}) as Record<string, unknown>);
return { content: "ok", model: "m" };
},
async completeWithTools() {
return { content: "ok", toolCalls: [], model: "m" };
},
};
const preset = importSillyTavernPreset(richPreset()).preset;
await wrapLlmForSession(provider, undefined, () => preset).complete([
{ role: "user", content: "TASK" },
]);
expect(seen[0]).toMatchObject({ temperature: 0.77, topP: 0.9, maxOutputTokens: 777 });
});
});
+295
View File
@@ -0,0 +1,295 @@
import { describe, expect, it } from "vitest";
import {
buildPresetDiagnostics,
diagnosticsWarnings,
} from "../src/preset/diagnostics.js";
import { importSillyTavernPreset } from "../src/preset/importer.js";
function entry(
id: string,
content: string,
extra: Partial<Record<string, unknown>> = {},
) {
return {
identifier: id,
name: id,
role: "system",
content,
enabled: true,
...extra,
};
}
function rawPreset(prompts: unknown[], extras: Record<string, unknown> = {}) {
return {
prompts,
prompt_order: [
{
character_id: 100001,
order: (prompts as Array<{ identifier: string }>).map((p) => ({
identifier: p.identifier,
enabled: true,
})),
},
],
...extras,
};
}
describe("导入诊断:跨条目标签", () => {
it("detects tags opened in one entry and closed in another", () => {
const report = importSillyTavernPreset(
rawPreset([
entry("a", "<system>开局"),
entry("b", "中间"),
entry("c", "</system>收尾"),
]),
{ name: "t" },
);
const cross = report.diagnostics.tags.crossEntry;
expect(cross).toEqual([{ tag: "system", openIn: "a", closeIn: "c" }]);
expect(
report.warnings.some((w) => w.includes("跨条目标签配对")),
).toBe(true);
});
it("supports CJK tag names", () => {
const report = importSillyTavernPreset(
rawPreset([entry("a", "以下是<对话历史>"), entry("b", "</对话历史>结束")]),
{ name: "t" },
);
expect(report.diagnostics.tags.crossEntry).toEqual([
{ tag: "对话历史", openIn: "a", closeIn: "b" },
]);
});
it("reports tags never closed", () => {
const report = importSillyTavernPreset(
rawPreset([entry("a", "<game>正文")]),
{ name: "t" },
);
expect(report.diagnostics.tags.unclosed).toEqual([
{ tag: "game", openIn: "a" },
]);
});
it("ignores self-closing tags", () => {
const report = importSillyTavernPreset(
rawPreset([entry("a", "换行<br/>结束")]),
{ name: "t" },
);
expect(report.diagnostics.tags.unclosed).toEqual([]);
expect(report.diagnostics.tags.orphanClose).toEqual([]);
});
});
describe("导入诊断:伪工具协议", () => {
it("finds placeholder-style protocols", () => {
const report = importSillyTavernPreset(
rawPreset([
entry(
"sys",
"[Interface]\nactive_interface = %TOOL_NAME%\noutput_channel = %TOOL_NAME%",
),
entry("tool", '[contract]\nstream_target = "display_stream"'),
]),
{ name: "t" },
);
const proto = report.diagnostics.toolProtocol;
expect(proto.entries).toEqual(["sys", "tool"]);
expect(proto.placeholders).toContain("%TOOL_NAME%");
expect(report.warnings.some((w) => w.includes("伪工具协议"))).toBe(true);
});
it("reads SPreset.ToolBindings form into tool name and params", () => {
const report = importSillyTavernPreset(
rawPreset([entry("bound", "所有输出必须调用 game_content 工具")], {
extensions: {
SPreset: {
ToolBindings: {
bound: {
enabled: true,
form: {
name: "game_content",
description: "全部输出在此工具内",
parameters: [{ name: "content", type: "string", required: true }],
},
},
},
},
},
}),
{ name: "t" },
);
expect(report.diagnostics.toolProtocol.bindings).toEqual([
{
entry: "bound",
toolName: "game_content",
params: ["content"],
description: "全部输出在此工具内",
},
]);
});
});
describe("导入诊断:宏与随机宏", () => {
it("separates known from unknown macros", () => {
const report = importSillyTavernPreset(
rawPreset([
entry("a", "{{user}} {{setvar::k::v}}{{getvar::k}}{{turnPlus1}}"),
]),
{ name: "t" },
);
const macros = report.diagnostics.macros;
expect(macros.find((m) => m.name === "{{user}}")?.known).toBe(true);
expect(macros.find((m) => m.name === "{{turnPlus1}}")?.known).toBe(false);
expect(
report.warnings.some((w) => w.includes("未识别宏")),
).toBe(true);
});
it("marks random macros before chatHistory as cache-breaking", () => {
const report = importSillyTavernPreset(
rawPreset([
entry("noise", "{{roll 999}}"),
entry("chatHistory", "", { marker: true }),
]),
{ name: "t" },
);
const noise = report.diagnostics.randomMacros.find(
(r) => r.entry === "noise",
);
expect(noise?.afterHistory).toBe(false);
expect(
report.warnings.some((w) => w.includes("提示缓存每轮失效")),
).toBe(true);
});
it("marks random macros after chatHistory as harmless", () => {
const report = importSillyTavernPreset(
rawPreset([
entry("chatHistory", "", { marker: true }),
entry("noise", "{{roll 999}}"),
]),
{ name: "t" },
);
const noise = report.diagnostics.randomMacros.find(
(r) => r.entry === "noise",
);
expect(noise?.afterHistory).toBe(true);
expect(
report.warnings.some((w) => w.includes("提示缓存每轮失效")),
).toBe(false);
});
});
describe("导入诊断:squash_system_messages 映射", () => {
it.each([
[true, "system"],
[false, "none"],
["semi", "semi"],
["STRICT", "strict"],
[2, "semi"],
[undefined, undefined],
] as const)("%s → %s", (input, expected) => {
const report = importSillyTavernPreset(
rawPreset([entry("a", "x")], { squash_system_messages: input }),
{ name: "t" },
);
expect(report.preset.messagePolicy?.squash).toBe(expected);
});
});
describe("导入诊断:静默失效告警", () => {
it("reports injection_position=1 entries as depth insertions", () => {
const report = importSillyTavernPreset(
rawPreset([
entry("main", "主线"),
entry("deepjail", "深度注入的破限", { injection_position: 1 }),
]),
{ name: "t" },
);
expect(report.diagnostics.silentGaps.deepInjection).toEqual(["deepjail"]);
expect(
report.warnings.some((w) => w.includes("injection_depth")),
).toBe(true);
});
it("ignores depth-injection entries that prompt_order leaves off", () => {
const report = importSillyTavernPreset(
rawPreset(
[entry("main", "主线"), entry("off", "关掉的", { injection_position: 1 })],
{
prompt_order: [
{
character_id: 100001,
order: [
{ identifier: "main", enabled: true },
{ identifier: "off", enabled: false },
],
},
],
},
),
{ name: "t" },
);
expect(report.diagnostics.silentGaps.deepInjection).toEqual([]);
});
it("flags script capabilities that have no native replacement", () => {
const report = importSillyTavernPreset(
rawPreset([entry("main", "主线")], {
extensions: {
tavern_helper: {
scripts: [
{
type: "script",
name: "防截断",
content:
"const r = await fetch(url); const reader = res.body.getReader(); // text/event-stream",
},
],
},
},
}),
{ name: "t" },
);
const script = report.diagnostics.scripts[0];
expect(script?.features).toContain("改写请求 / 拦截 fetch");
expect(script?.features).toContain("流式解包 / 防截断");
expect(report.diagnostics.silentGaps.unportedScriptFeatures[0]?.script).toBe(
"防截断",
);
expect(
report.warnings.some((w) => w.includes("本版本没有替代")),
).toBe(true);
});
it("mentions display-layer regexes leaking their tags", () => {
const report = importSillyTavernPreset(
rawPreset([entry("main", "主线")], {
regex_scripts: [
{ id: "r1", scriptName: "隐藏选项", markdownOnly: true, placement: [2] },
],
}),
{ name: "t" },
);
expect(
report.warnings.some((w) => w.includes("显示层规则")),
).toBe(true);
});
});
describe("diagnosticsWarnings", () => {
it("keeps quiet for a clean preset", () => {
const report = importSillyTavernPreset(
rawPreset([
entry("main", "主线"),
entry("chatHistory", "", { marker: true }),
entry("jailbreak", "收尾"),
]),
{ name: "t" },
);
expect(diagnosticsWarnings(report.diagnostics)).toEqual([]);
});
});
+185
View File
@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import { expandAtDirectives, expandPresetDirectives } from "../src/directives/expand.js";
import { collectMacroNames, createMacroVars, expandMacros } from "../src/directives/macros.js";
import { assemblePresetMessages } from "../src/preset/assembler.js";
import type { PresetPackage } from "../src/types/preset.js";
describe("expandMacros", () => {
it("strips {{// }} comments, including multi-line ones", () => {
const r = expandMacros("前{{// \n声明:禁止外传\n\n}}后");
expect(r.text).toBe("前后");
expect(r.hits.map((h) => h.kind)).toEqual(["comment"]);
});
it("does not expand @ tokens inside a comment", () => {
const r = expandAtDirectives("{{// 不要动 @玩家 }}", {
persona: { name: "阿泽" },
});
expect(r.text).toBe("");
expect(r.hits.map((h) => h.kind)).toEqual(["comment"]);
});
it("{{trim}} expands to empty and asks caller to trim", () => {
const r = expandMacros(" {{// 备注 }}{{trim}}\n正文 ");
expect(r.text).toBe(" \n正文 ");
expect(r.trim).toBe(true);
expect(expandAtDirectives(" {{trim}}\n正文 ").text).toBe("正文");
});
it("{{user}} and {{char}}", () => {
const r = expandMacros("{{user}} 与 {{char}}", {
user: "林晚",
char: "长夜行",
});
expect(r.text).toBe("林晚 与 长夜行");
});
it("{{char}} falls back to 作品", () => {
expect(expandMacros("{{char}}").text).toBe("作品");
});
it("{{persona}} expands to the persona description", () => {
expect(expandMacros("{{persona}}", { persona: "二十岁,缄默" }).text).toBe(
"二十岁,缄默",
);
// 没设人设 → 空串(与酒馆一致)
expect(expandMacros("[{{persona}}]").text).toBe("[]");
});
it("treats {{persona}} as a known macro in the import report", () => {
expect(collectMacroNames("{{persona}} {{charPrompt}}")).toContain(
"{{persona}}",
);
});
it("setvar then getvar in the same scope", () => {
const vars = createMacroVars();
expect(expandMacros("{{setvar::a::你好}}", { vars }).text).toBe("");
expect(expandMacros("{{getvar::a}}!", { vars }).text).toBe("你好!");
});
it("getvar on unset variable is empty", () => {
expect(expandMacros("[{{getvar::nope}}]").text).toBe("[]");
});
it("keeps whitespace inside setvar values", () => {
const vars = createMacroVars();
expandMacros("{{setvar::set1:: }}", { vars });
expect(vars.get("set1")).toBe(" ");
});
it("addvar appends", () => {
const vars = createMacroVars();
expandMacros("{{setvar::k::a}}{{addvar::k::b}}", { vars });
expect(vars.get("k")).toBe("ab");
});
it("{{roll N}} yields an integer in range", () => {
for (let i = 0; i < 20; i++) {
const value = Number(expandMacros("{{roll 60}}").text);
expect(Number.isInteger(value)).toBe(true);
expect(value).toBeGreaterThanOrEqual(1);
expect(value).toBeLessThanOrEqual(60);
}
});
it("{{roll:1d6}} and {{roll::d9}} roll dice", () => {
for (let i = 0; i < 20; i++) {
const six = Number(expandMacros("{{roll:1d6}}").text);
expect(six).toBeGreaterThanOrEqual(1);
expect(six).toBeLessThanOrEqual(6);
const nine = Number(expandMacros("{{roll::d9}}").text);
expect(nine).toBeGreaterThanOrEqual(1);
expect(nine).toBeLessThanOrEqual(9);
}
});
it("does not roll when allowDice is false", () => {
expect(expandMacros("{{roll 60}}", { allowDice: false }).text).toBe(
"{{roll 60}}",
);
});
it("leaves unknown macros untouched", () => {
const r = expandMacros("{{turnPlus1}} {{MEMORY_TABLE}} {{ }}");
expect(r.text).toBe("{{turnPlus1}} {{MEMORY_TABLE}} {{ }}");
expect(r.hits).toEqual([]);
});
it("collectMacroNames reports unknown macros by raw text", () => {
const names = collectMacroNames(
"{{// c}}{{user}}{{setvar::a::b}}{{turnPlus1}}{{roll 9}}",
);
expect(names).toContain("{{comment}}");
expect(names).toContain("{{user}}");
expect(names).toContain("{{setvar}}");
expect(names).toContain("{{roll}}");
expect(names.some((n) => n.includes("turnPlus1"))).toBe(true);
});
});
function presetWith(prompts: Array<{ id: string; content: string }>): PresetPackage {
return {
id: "p",
name: "p",
source: "sillytavern",
prompts: prompts.map((p) => ({
id: p.id,
name: p.id,
enabled: true,
role: "system" as const,
content: p.content,
marker: false,
sourceIdentifier: p.id,
})),
promptOrder: prompts.map((p, i) => ({
promptId: p.id,
enabled: true,
orderIndex: i,
})),
generation: {},
unsupported: [],
importedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("preset assembly macro scope", () => {
it("setvar in an earlier entry is visible to getvar in a later entry", () => {
const preset = presetWith([
{ id: "a", content: "{{setvar::set1:: }}{{// 预设说明 }}" },
{ id: "b", content: "规则{{getvar::set1}}结束" },
]);
const vars = createMacroVars();
const messages = assemblePresetMessages(
preset,
() => null,
(content) => expandPresetDirectives(content, { vars, user: "阿泽" }).text,
);
expect(messages.map((m) => m.content)).toEqual(["规则 结束"]);
});
it("expands {{char}} from assembly context", () => {
const preset = presetWith([{ id: "a", content: "<{{char}}>正文</{{char}}>" }]);
const messages = assemblePresetMessages(
preset,
() => null,
(content) => expandPresetDirectives(content, { charName: "长夜行" }).text,
);
expect(messages[0]?.content).toBe("<长夜行>正文</长夜行>");
});
});
describe("{{// 注释 }} 配平", () => {
it("注释里嵌套宏不会漏出尾巴", () => {
const text = expandMacros("{{// 注释:把 {{user}} 换成别的\n}}|用户:{{user}}|", {
user: "User",
}).text;
expect(text).toBe("|用户:User|");
});
it("普通注释照旧吃掉", () => {
expect(expandMacros("A{{// 说明 }}B", {}).text).toBe("AB");
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import { assemblePresetProbe, runPresetProbe } from "../src/preset/probe.js";
function linuxLikePreset() {
return importSillyTavernPreset(
{
prompts: [
{ identifier: "main", name: "主体", role: "system", content: "{{user}} 写" },
{ identifier: "chatHistory", name: "历史", role: "system", content: "", marker: true },
{
identifier: "guide",
name: "工具说明",
role: "system",
content: "active_interface = %TOOL_NAME%\nstream_target = display_stream",
},
],
prompt_order: [
{
character_id: 100001,
order: [
{ identifier: "main", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "guide", enabled: true },
],
},
],
squash_system_messages: true,
},
{ name: "linux-like" },
).preset;
}
describe("probe 展示实际发出的形态", () => {
it("reports the squash mode and the tool plan", async () => {
const preset = linuxLikePreset();
const result = await runPresetProbe({
preset,
input: { message: "继续" },
options: { charName: "长夜行" },
});
expect(result.squashMode).toBe("system");
expect(result.toolPlan?.present).toBe(true);
expect(result.toolPlan?.payloadFields).toContain("display_stream");
});
it("expands {{user}} and drops entries that become empty", () => {
const preset = linuxLikePreset();
const messages = assemblePresetProbe(
preset,
{ message: "继续" },
{ charName: "长夜行" },
);
const all = messages.map((m) => m.content).join("\n");
expect(all).not.toContain("{{user}}");
// 换成当前人设名(探针缺省用选中人设)
expect(all).toMatch(/\S+ 写/);
});
it("merges adjacent system messages in the displayed sequence", () => {
const preset = linuxLikePreset();
const messages = assemblePresetProbe(
preset,
{ message: "继续" },
{ squash: "merge" },
);
const roles = messages.map((m) => m.role);
for (let i = 1; i < roles.length; i++) {
if (roles[i] === "system") {
expect(roles[i - 1]).not.toBe("system");
}
}
});
});
+24 -18
View File
@@ -41,16 +41,16 @@ function samplePreset() {
describe("assemblePresetProbe", () => {
it("fills the sandwich with sample lore, history, and the current turn", () => {
const messages = assemblePresetProbe(samplePreset(), {
message: "我推开门。",
});
const messages = assemblePresetProbe(
samplePreset(),
{ message: "我推开门。" },
{ squash: "none" },
);
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", "MAIN"],
[
"system",
`### 任务契约\n\n${PROBE_SYSTEM_PROMPT}\n\n### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`,
],
// 任务契约并进预设自有的 main 条目,不再单独一条
["system", `MAIN\n\n${PROBE_SYSTEM_PROMPT}`],
["system", `### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`],
["system", DEFAULT_PROBE_CONTEXT.history],
["user", "我推开门。"],
["system", "PHI"],
@@ -58,17 +58,20 @@ describe("assemblePresetProbe", () => {
});
it("omits empty optional bags", () => {
const messages = assemblePresetProbe(samplePreset(), {
message: "你好",
loreBefore: "",
history: "",
loreAfter: "",
postTurn: "",
});
const messages = assemblePresetProbe(
samplePreset(),
{
message: "你好",
loreBefore: "",
history: "",
loreAfter: "",
postTurn: "",
},
{ squash: "none" },
);
expect(messages.map((m) => m.content)).toEqual([
"MAIN",
`### 任务契约\n\n${PROBE_SYSTEM_PROMPT}`,
`MAIN\n\n${PROBE_SYSTEM_PROMPT}`,
"你好",
"PHI",
]);
@@ -84,7 +87,10 @@ describe("runPresetProbe", () => {
expect(result.completed).toBe(false);
expect(result.reply).toBeNull();
expect(result.messages.some((m) => m.content === "我推开门。")).toBe(true);
// 缺省 strict:system 段会折进 user,所以只断言正文没丢
expect(
result.messages.map((m) => m.content).join("\n"),
).toContain("我推开门。");
});
it("returns the model reply when complete is provided", async () => {
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import {
applyRegexScripts,
hasRegexFor,
readRegexScripts,
runRegexScript,
type PresetRegexScript,
} from "../src/preset/regex-scripts.js";
const base: PresetRegexScript = {
name: "t",
findRegex: "/<choices>[\\s\\S]*?<\\/choices>/g",
replaceString: "",
trimStrings: [],
placement: [2],
promptOnly: true,
markdownOnly: false,
disabled: false,
runOnEdit: false,
substituteRegex: 0,
minDepth: null,
maxDepth: null,
approximateSubstitute: false,
};
describe("readRegexScripts", () => {
it("从 raw.regex_scripts / extensions.regex_scripts / SPreset.RegexBinding 读并去重", () => {
const raw = {
regex_scripts: [
{ id: "a", scriptName: "A", findRegex: "/x/g", placement: [1], promptOnly: true },
],
extensions: {
regex_scripts: [
{ id: "a", scriptName: "A", findRegex: "/x/g", placement: [1], promptOnly: true },
{ id: "b", scriptName: "B", findRegex: "/y/g", placement: [2], markdownOnly: true },
],
SPreset: {
RegexBinding: {
regexes: [
{ id: "c", scriptName: "C", findRegex: "/z/g", placement: [2] },
],
},
},
},
};
const scripts = readRegexScripts(raw);
expect(scripts.map((s) => s.name)).toEqual(["A", "B", "C"]);
expect(scripts[1]?.markdownOnly).toBe(true);
expect(scripts[2]?.promptOnly).toBe(false);
});
it("没有 findRegex 的条目跳过", () => {
expect(readRegexScripts({ regex_scripts: [{ id: "x" }] })).toEqual([]);
});
});
describe("runRegexScript(对齐酒馆 runRegexScript)", () => {
it("支持 {{match}} 与 $1 / 命名组", () => {
expect(
runRegexScript("前言<choices>a</choices>后记", {
...base,
replaceString: "[$1]",
findRegex: "/<choices>(.*?)<\\/choices>/g",
}),
).toBe("前言[a]后记");
expect(
runRegexScript("abc", {
...base,
replaceString: "<<$<x>>>",
findRegex: "/(?<x>b)/g",
}),
).toBe("a<<b>>c");
expect(
runRegexScript("打勾", { ...base, replaceString: "✓{{match}}", findRegex: "/打/g" }),
).toBe("✓打勾");
});
it("trimStrings 从匹配片段里剔掉", () => {
expect(
runRegexScript("前((去掉))后", {
...base,
findRegex: "/\\(\\((.*?)\\)\\)/g",
replaceString: "$1",
trimStrings: ["去掉"],
}),
).toBe("前后");
});
it("坏正则原样返回", () => {
expect(runRegexScript("x", { ...base, findRegex: "/([/g" })).toBe("x");
});
});
describe("applyRegexScripts(方向 / placement / depth)", () => {
it("prompt 只跑 promptOnly,display 只跑 markdownOnly,raw 只跑两者都不是的", () => {
const scripts: PresetRegexScript[] = [
{ ...base, name: "p", promptOnly: true, findRegex: "/A/g", replaceString: "P" },
{ ...base, name: "d", promptOnly: false, markdownOnly: true, findRegex: "/A/g", replaceString: "D" },
{ ...base, name: "r", promptOnly: false, markdownOnly: false, findRegex: "/A/g", replaceString: "R" },
];
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "prompt" })).toBe("P");
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "display" })).toBe("D");
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "raw" })).toBe("R");
});
it("placement 不匹配就不跑", () => {
const scripts: PresetRegexScript[] = [{ ...base, findRegex: "/A/g", replaceString: "P" }];
expect(applyRegexScripts("A", scripts, { placement: 1, direction: "prompt" })).toBe("A");
});
it("depth 超出 min/max 就跳过", () => {
const scripts: PresetRegexScript[] = [
{ ...base, findRegex: "/A/g", replaceString: "P", minDepth: 2 },
];
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "prompt", depth: 1 })).toBe("A");
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "prompt", depth: 3 })).toBe("P");
});
it("disabled 的脚本不跑", () => {
const scripts: PresetRegexScript[] = [{ ...base, disabled: true, findRegex: "/A/g", replaceString: "P" }];
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "prompt" })).toBe("A");
});
it("substituteRegex=RAW 时先展开 findRegex 里的宏", () => {
const scripts: PresetRegexScript[] = [
{ ...base, findRegex: "/{{char}}/g", replaceString: "X", substituteRegex: 1 },
];
expect(
applyRegexScripts("角色名", scripts, {
placement: 2,
direction: "prompt",
expand: (text) => text.replaceAll("{{char}}", "角色名"),
}),
).toBe("X");
});
it("hasRegexFor 快速判断", () => {
const scripts: PresetRegexScript[] = [{ ...base }];
expect(hasRegexFor(scripts, "prompt", 2)).toBe(true);
expect(hasRegexFor(scripts, "display", 2)).toBe(false);
});
});
describe("装配时对对话历史跑发送前正则", () => {
it("user/assistant 分别按 placement 1/2 处理,depth 生效", async () => {
const { importSillyTavernPreset } = await import("../src/preset/importer.js");
const { assemblePlayWorkerMessages } = await import("../src/preset/play-frame.js");
const report = importSillyTavernPreset(
{
prompts: [
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
content: "",
},
],
prompt_order: [
{ character_id: 100001, order: [{ identifier: "chatHistory", enabled: true }] },
],
regex_scripts: [
{
id: "strip",
scriptName: "去选项",
findRegex: "/<choices>[\\s\\S]*?<\\/choices>/g",
replaceString: "",
placement: [2],
promptOnly: true,
},
{
id: "user-only",
scriptName: "改用户输入",
findRegex: "/你好/g",
replaceString: "您好",
placement: [1],
promptOnly: true,
},
],
},
{ name: "regex-history" },
);
const messages = assemblePlayWorkerMessages({
systemPrompt: "",
preset: report.preset,
pack: {
worldBookBefore: [],
history: [
{ role: "user", content: "你好" },
{ role: "assistant", content: "回答<choices>ABC</choices>" },
],
worldBookAfter: [],
turn: "",
postTurn: [],
},
persona: null,
charName: null,
});
expect(messages.map((m) => m.content)).toEqual(["您好", "回答"]);
expect(messages.map((m) => m.role)).toEqual(["user", "assistant"]);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import {
declarePresetTools,
extractDeclaredPayload,
} from "../src/preset/tool-declaration.js";
function importRaw(raw: unknown, name: string) {
return importSillyTavernPreset(raw, { name }).preset;
}
describe("声明式工具(预设数据 → 真 tools)", () => {
it("从 ToolBindings 的 form 生成 tools", () => {
const preset = importRaw(
{
prompts: [{ identifier: "main", name: "main", role: "system", content: "MAIN" }],
prompt_order: [
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
],
extensions: {
SPreset: {
ToolBindings: {
aaa: {
enabled: true,
resolvedName: "game_content",
form: {
name: "game_content",
description: "# 你的全部输出应当在此工具内。",
parameters: [{ name: "content", type: "string", required: true }],
},
},
},
},
},
},
"bindings",
);
const declared = declarePresetTools(preset);
expect(declared?.source).toBe("bindings");
expect(declared?.tools).toHaveLength(1);
const fn = declared!.tools[0]!.function;
expect(fn.name).toBe("game_content");
expect(fn.description).toContain("全部输出");
expect(fn.parameters).toEqual({
type: "object",
properties: { content: { type: "string" } },
required: ["content"],
});
expect(declared?.payloadFields).toEqual(["content"]);
});
it("只有伪工具协议条目时从正文取工具名与字段", () => {
const preset = importRaw(
{
prompts: [
{
identifier: "guide-sys",
name: "【ToolCall引导】系统声明",
role: "system",
content:
"调用 %TOOL_NAME% 才能输出正文;执行宿 dispatch_stream_payload_34d98628087f3798。",
},
{
identifier: "guide-field",
name: "【ToolCall引导】字段说明",
role: "system",
content: "stream_target = \"display_stream\"\n正文写在这里。",
},
],
prompt_order: [
{
character_id: 100001,
order: [
{ identifier: "guide-sys", enabled: true },
{ identifier: "guide-field", enabled: true },
],
},
],
},
"protocol",
);
const declared = declarePresetTools(preset);
expect(declared?.source).toBe("protocol");
const fn = declared!.tools[0]!.function;
expect(fn.name).toBe("dispatch_stream_payload_34d98628087f3798");
expect(declared?.payloadFields).toContain("display_stream");
const params = fn.parameters as { properties: Record<string, unknown>; required: string[] };
expect(Object.keys(params.properties)).toContain("display_stream");
expect(params.required).toContain("display_stream");
});
it("没有工具协议就返回 null", () => {
const preset = importRaw(
{
prompts: [{ identifier: "main", name: "main", role: "system", content: "MAIN" }],
prompt_order: [
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
],
},
"plain",
);
expect(declarePresetTools(preset)).toBeNull();
});
});
describe("extractDeclaredPayload", () => {
it("按字段优先级取正文", () => {
expect(
extractDeclaredPayload(JSON.stringify({ display_stream: "正文" }), ["display_stream"]),
).toBe("正文");
});
it("字段不在时退回任意字符串字段", () => {
expect(extractDeclaredPayload(JSON.stringify({ other: "兜底" }), ["content"])).toBe("兜底");
});
it("坏 JSON 返回 null", () => {
expect(extractDeclaredPayload("{oops", ["content"])).toBeNull();
});
});
+300
View File
@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import type { ToolDefinition } from "../src/llm/client.js";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import {
clearToolPlanCache,
decorateTools,
normalizeToolCalls,
resolveToolPlan,
} from "../src/preset/tool-policy.js";
const submitTool: ToolDefinition = {
type: "function",
function: {
name: "submit",
description: "提交产物",
parameters: {
type: "object",
properties: {
content: { type: "string" },
outputTags: { type: "array", items: { type: "string" } },
},
required: ["content", "outputTags"],
},
},
};
const readTool: ToolDefinition = {
type: "function",
function: {
name: "read_blackboard",
description: "读正文",
parameters: {
type: "object",
properties: { tags: { type: "array" } },
required: ["tags"],
},
},
};
function presetWithBindings() {
return importSillyTavernPreset(
{
prompts: [
{ identifier: "main", name: "main", role: "system", content: "主线" },
{
identifier: "bound",
name: "防截断2.0",
role: "system",
content: "所有输出必须调用 game_content 工具,放在 content 参数中",
},
],
prompt_order: [
{
character_id: 100001,
order: [
{ identifier: "main", enabled: true },
{ identifier: "bound", enabled: true },
],
},
],
extensions: {
SPreset: {
ToolBindings: {
bound: {
enabled: true,
form: {
name: "game_content",
description: "# 你的全部输出应当在此工具内。",
parameters: [{ name: "content", type: "string", required: true }],
},
},
},
},
},
},
{ name: "Reborn-like" },
).preset;
}
function presetWithPlaceholders() {
return importSillyTavernPreset(
{
prompts: [
{
identifier: "guide",
name: "【ToolCall引导】工具说明",
role: "system",
content: "[Interface]\nactive_interface = %TOOL_NAME%\nstream_target = display_stream",
},
],
prompt_order: [
{
character_id: 100001,
order: [{ identifier: "guide", enabled: true }],
},
],
},
{ name: "linux-like" },
).preset;
}
describe("resolveToolPlan", () => {
it("prefers SPreset.ToolBindings for the target tool and payload field", () => {
const plan = resolveToolPlan(presetWithBindings());
expect(plan.present).toBe(true);
expect(plan.targetName).toBe("game_content");
expect(plan.payloadFields).toEqual(["content"]);
// 描述只取作者写在 ToolBindings 里的那句,不抄条目正文
expect(plan.description).toBe("# 你的全部输出应当在此工具内。");
});
it("falls back to placeholders when no binding exists", () => {
const plan = resolveToolPlan(presetWithPlaceholders());
expect(plan.present).toBe(true);
expect(plan.targetName).toBeNull();
expect(plan.payloadFields).toContain("display_stream");
});
it("is quiet when the preset has no tool protocol", () => {
const preset = importSillyTavernPreset(
{
prompts: [{ identifier: "main", name: "main", role: "system", content: "只写正文" }],
prompt_order: [
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
],
},
{ name: "plain" },
).preset;
expect(resolveToolPlan(preset).present).toBe(false);
});
});
describe("tool plan 缓存", () => {
it("returns a stale plan until the cache is cleared", () => {
const preset = presetWithPlaceholders();
expect(resolveToolPlan(preset).present).toBe(true);
// 模拟用户把所有条目关掉后保存(id 与 importedAt 不变)
const edited = {
...preset,
promptOrder: preset.promptOrder.map((o) => ({ ...o, enabled: false })),
};
expect(resolveToolPlan(edited).present).toBe(true);
clearToolPlanCache(edited.id);
expect(resolveToolPlan(edited).present).toBe(false);
});
});
describe("decorateTools", () => {
it("leaves tools alone when the preset has no tool protocol", () => {
const preset = importSillyTavernPreset(
{
prompts: [{ identifier: "main", name: "main", role: "system", content: "写" }],
prompt_order: [
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
],
},
{ name: "plain" },
).preset;
const out = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(preset),
});
expect(out.tools).toEqual([submitTool]);
expect(out.aliases).toEqual([]);
});
it("app mode never changes the tool table", () => {
const out = decorateTools({
mode: "app",
tools: [submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
expect(out.tools).toEqual([submitTool]);
});
it("adapt adds the declared tool and aliases it onto the host tool", () => {
const out = decorateTools({
mode: "adapt",
tools: [readTool, submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
expect(out.tools.map((t) => t.function.name)).toEqual([
"read_blackboard",
"submit",
"game_content",
]);
expect(out.aliases).toEqual([
{ from: "game_content", to: "submit", payloadFields: ["content"] },
]);
});
it("adapt folds placeholder payload fields into the host schema", () => {
const out = decorateTools({
mode: "adapt",
tools: [readTool, submitTool],
plan: resolveToolPlan(presetWithPlaceholders()),
});
expect(out.tools).toHaveLength(2);
const submit = out.tools.find((t) => t.function.name === "submit")!;
const properties = (submit.function.parameters as {
properties: Record<string, unknown>;
}).properties;
expect(Object.keys(properties)).toContain("display_stream");
expect(submit.function.description).toContain("display_stream");
expect(out.aliases).toEqual([]);
});
it("adapt creates a payload tool when there is no host tool", () => {
const out = decorateTools({
mode: "adapt",
tools: [],
plan: resolveToolPlan(presetWithBindings()),
});
expect(out.tools.map((t) => t.function.name)).toEqual(["game_content"]);
});
it("preset mode sends only the preset tool", () => {
const out = decorateTools({
mode: "preset",
tools: [readTool, submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
expect(out.tools.map((t) => t.function.name)).toEqual(["game_content"]);
});
});
describe("normalizeToolCalls", () => {
it("rewrites an aliased call onto the host tool and its primary field", () => {
const decoration = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
const calls = normalizeToolCalls(
[
{
id: "1",
name: "game_content",
arguments: JSON.stringify({ content: "第一章", extra: 1 }),
},
],
decoration,
decoration.tools,
);
expect(calls[0]?.name).toBe("submit");
const args = JSON.parse(calls[0]!.arguments) as Record<string, unknown>;
// content 已经落到宿主工具的主字段上,不再重复
expect(args.content).toBe("第一章");
expect(args.extra).toBe(1);
});
it("leaves unrelated calls untouched", () => {
const decoration = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
const call = { id: "2", name: "submit", arguments: '{"content":"x"}' };
expect(normalizeToolCalls([call], decoration, decoration.tools)).toEqual([
call,
]);
});
it("renames an aliased call even without a payload", () => {
const decoration = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
const call = { id: "3", name: "game_content", arguments: "{}" };
expect(normalizeToolCalls([call], decoration, decoration.tools)).toEqual([
{ ...call, name: "submit" },
]);
});
it("folds a payload field onto the host primary field", () => {
const decoration = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(presetWithPlaceholders()),
});
const calls = normalizeToolCalls(
[
{
id: "4",
name: "submit",
arguments: JSON.stringify({ display_stream: "正文" }),
},
],
decoration,
decoration.tools,
);
const args = JSON.parse(calls[0]!.arguments) as Record<string, unknown>;
expect(args.content).toBe("正文");
expect(args).not.toHaveProperty("display_stream");
});
});
+32 -1
View File
@@ -1,4 +1,19 @@
import { describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
// 这些用例只测夹心与整形,不该被本机 ~/.writing-agent/settings.json 里的
// messageSquash / presetToolPolicy 覆盖,跑的时候给一份干净的临时数据目录。
let isolatedDataDir = "";
beforeAll(() => {
isolatedDataDir = mkdtempSync(path.join(os.tmpdir(), "wa-preset-wrapper-"));
process.env.WRITING_AGENT_DATA_DIR = isolatedDataDir;
});
afterAll(() => {
delete process.env.WRITING_AGENT_DATA_DIR;
if (isolatedDataDir) rmSync(isolatedDataDir, { recursive: true, force: true });
});
import type {
ChatMessage,
CompleteResult,
@@ -25,6 +40,8 @@ const samplePreset: PresetPackage = {
],
promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }],
generation: { temperature: 0.91, maxOutputTokens: 1234, reasoningEffort: "max" },
// 这组用例测的是夹心,不是整形;整形单独有用例。
messagePolicy: { squash: "none" },
unsupported: [],
importedAt: "2026-01-01T00:00:00.000Z",
};
@@ -163,4 +180,18 @@ describe("wrapLlmForSession", () => {
await llm.complete([{ role: "user", content: "TASK" }]);
expect(received[0].messages[0].content).toBe("称呼 @玩家");
});
it("defaults to strict squash when the preset says nothing", async () => {
const preset: PresetPackage = { ...samplePreset };
delete (preset as { messagePolicy?: unknown }).messagePolicy;
const { provider, received } = recordingProvider();
const llm = wrapLlmForSession(provider, undefined, () => preset);
await llm.complete([{ role: "user", content: "TASK" }]);
// strict:system 折进 user,首条为 user
expect(received[0].messages.map((m) => m.role)).toEqual(["user"]);
expect(received[0].messages[0]!.content).toContain("PRESET_RULES");
expect(received[0].messages[0]!.content).toContain("TASK");
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
isCompleteJson,
salvageTruncatedPayload,
} from "../src/llm/truncation.js";
describe("isCompleteJson", () => {
it("accepts complete objects and empty", () => {
expect(isCompleteJson('{"content":"x"}')).toBe(true);
expect(isCompleteJson("")).toBe(true);
});
it("rejects truncated objects", () => {
expect(isCompleteJson('{"content":"第一章')).toBe(false);
});
});
describe("salvageTruncatedPayload", () => {
it("returns null when the field never started", () => {
expect(salvageTruncatedPayload('{"content', ["content"])).toBeNull();
expect(salvageTruncatedPayload('{"other":"x"}', ["content"])).toBeNull();
});
it("keeps the generated text before the cut", () => {
const raw = '{"content":"第一章\\n她推开门';
expect(salvageTruncatedPayload(raw, ["content"])).toEqual({
field: "content",
text: "第一章\n她推开门",
});
});
it("stops at the closing quote when the value is complete", () => {
const raw = '{"content":"正文","outputTags":["a"';
expect(salvageTruncatedPayload(raw, ["content"])?.text).toBe("正文");
});
it("tries fields in order", () => {
const raw = '{"display_stream":"走了","content":"没到"';
expect(salvageTruncatedPayload(raw, ["content", "display_stream"])?.field).toBe(
"content",
);
expect(
salvageTruncatedPayload('{"display_stream":"走了', [
"content",
"display_stream",
])?.field,
).toBe("display_stream");
});
it("decodes unicode escapes and skips dangling escapes", () => {
expect(
salvageTruncatedPayload('{"content":"\\u7b2c\\u4e00\\u7ae0', [
"content",
])?.text,
).toBe("第一章");
expect(
salvageTruncatedPayload('{"content":"收尾\\', ["content"])?.text,
).toBe("收尾");
});
});
+1 -1
View File
@@ -58,7 +58,7 @@ describe("worldInfoPackFromSegments", () => {
blackboard: bb,
});
expect(pack.worldBookBefore.map((e) => e.content)).toEqual(["古镇夜雨"]);
expect(pack.history).toContain("你好");
expect(pack.history.map((m) => m.content).join("\n")).toContain("你好");
expect(pack.worldBookAfter.map((e) => e.content)).toEqual(["HP 3"]);
expect(pack.turn).toBe("我推门进去");
expect(pack.postTurn.map((e) => e.content)).toEqual(["尾注"]);