import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { importSillyTavernPreset } from "../src/preset/importer.js"; import { appendTurnMessages, 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() { return importSillyTavernPreset({ prompts: [ { identifier: "main", name: "Main", role: "system", content: "MAIN" }, { identifier: "charDescription", name: "Desc", role: "system", marker: true, }, { identifier: "chatHistory", name: "History", role: "system", marker: true, }, { identifier: "jailbreak", name: "PHI", role: "system", content: "PHI" }, ], prompt_order: [ { character_id: 1, order: [ { identifier: "main", enabled: true }, { identifier: "charDescription", enabled: true }, { identifier: "chatHistory", enabled: true }, { identifier: "jailbreak", enabled: true }, ], }, ], }).preset; } describe("direct-chat history assembly", () => { it("joins seed history with prior turns", () => { expect( buildChatHistory("种子", [ { role: "user", content: "你好" }, { role: "assistant", content: "嗨" }, ]), ).toBe("种子\n\n【用户】\n你好\n\n【助手】\n嗨"); }); it("fills sandwich with seed + transcript + turn", () => { const messages = assembleDirectChatMessages(samplePreset(), { message: "继续", slots: { ...EMPTY_SHELL_SLOTS, systemPrompt: "契约", loreBefore: "设定A", seedHistory: "旧史", }, priorMessages: [{ role: "user", content: "上一句" }], }); const contents = messages.map((m) => m.content); expect(contents.some((c) => String(c).includes("设定A"))).toBe(true); expect(contents.some((c) => String(c).includes("旧史"))).toBe(true); expect(contents.some((c) => String(c).includes("上一句"))).toBe(true); expect(contents).toContain("继续"); expect(contents.some((c) => String(c).includes("契约"))).toBe(true); }); it("emits no system message when the slot is empty (酒馆没有隐式系统提示)", () => { const task = buildDirectChatTaskMessages({ message: "hi", slots: EMPTY_SHELL_SLOTS, priorMessages: [], }); expect(task.some((m) => m.role === "system")).toBe(false); }); }); describe("direct-chat store", () => { beforeEach(() => { process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( path.join(tmpdir(), "wa-direct-chat-"), ); }); it("roundtrips shell and conversation", () => { const shell = createShell({ name: "测试壳", slots: { ...EMPTY_SHELL_SLOTS, loreBefore: "世界" }, }); expect(getShell(shell.id)?.loreBefore).toBe("世界"); const conv = createConversation({ shellId: shell.id, name: "会话1", messages: [ { id: "m1", role: "user", content: "你好", createdAt: new Date().toISOString(), }, ], }); expect(listShells()[0]?.conversationCount).toBe(1); expect(listConversations(shell.id)).toHaveLength(1); expect(getConversation(conv.id)?.messages[0]?.content).toBe("你好"); }); it("promotes temporary messages into persistent shell+conversation", () => { const { shell, conversation } = promoteTemporary({ shell: { name: "从临时", slots: { loreBefore: "L" } }, conversationName: "升格会话", messages: [ { id: "u1", role: "user", content: "问", createdAt: new Date().toISOString(), }, { id: "a1", role: "assistant", content: "答", createdAt: new Date().toISOString(), }, ], }); expect(shell.name).toBe("从临时"); expect(shell.loreBefore).toBe("L"); 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", () => { it("rejects when no preset is available", async () => { process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( path.join(tmpdir(), "wa-direct-chat-preset-"), ); await expect( runDirectChatTurn({ message: "你好", slots: EMPTY_SHELL_SLOTS, priorMessages: [], presetId: null, complete: false, }), ).rejects.toThrow(/预设/); }); it("appends user/assistant and keeps contextTrace on latest", () => { const messages = appendTurnMessages({ prior: [], userText: "问", reply: "答", contextTrace: { caller: "direct-chat", createdAt: new Date().toISOString(), messages: [{ role: "user", content: "x" }], charCount: 1, }, keepTraceLatest: 3, }); expect(messages).toHaveLength(2); expect(messages[1]?.contextTrace?.caller).toBe("direct-chat"); }); }); describe("resolveDirectChatPreset", () => { it("throws for missing explicit preset id", () => { expect(() => resolveDirectChatPreset("missing-preset-xyz")).toThrow( /预设不存在/, ); }); }); describe("buildPriorForWindow", () => { const turns = [ { id: "t1", user: { content: "问1", createdAt: "1" }, replies: [ { windowId: "a", content: "A1" }, { windowId: "b", content: "B1" }, ], createdAt: "1", }, { id: "t2", user: { content: "问2", createdAt: "2" }, replies: [ { windowId: "a", content: "A2" }, { windowId: "b", content: "B2" }, ], selectedWindowId: "a", createdAt: "2", }, ]; it("uses each window's own reply when nothing selected", () => { expect(buildPriorForWindow([turns[0]!], "b")).toEqual([ { role: "user", content: "问1" }, { role: "assistant", content: "B1" }, ]); }); it("forces selected reply into every window's prior", () => { expect(buildPriorForWindow(turns, "b")).toEqual([ { role: "user", content: "问1" }, { role: "assistant", content: "B1" }, { role: "user", content: "问2" }, { role: "assistant", content: "A2" }, ]); }); }); 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); }); });