预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("收尾");
|
||||
});
|
||||
});
|
||||
@@ -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(["尾注"]);
|
||||
|
||||
Reference in New Issue
Block a user