continuity.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. import { afterEach, describe, expect, it, vi } from "vitest";
  2. import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
  3. import { tmpdir } from "node:os";
  4. import { join } from "node:path";
  5. import { ContinuityAuditor } from "../agents/continuity.js";
  6. const ZERO_USAGE = {
  7. promptTokens: 0,
  8. completionTokens: 0,
  9. totalTokens: 0,
  10. } as const;
  11. describe("ContinuityAuditor", () => {
  12. afterEach(() => {
  13. vi.restoreAllMocks();
  14. });
  15. it("prefers book language override when building audit prompts", async () => {
  16. const root = await mkdtemp(join(tmpdir(), "inkos-auditor-lang-test-"));
  17. const bookDir = join(root, "book");
  18. const storyDir = join(bookDir, "story");
  19. await mkdir(storyDir, { recursive: true });
  20. await Promise.all([
  21. writeFile(
  22. join(bookDir, "book.json"),
  23. JSON.stringify({
  24. id: "english-book",
  25. title: "English Book",
  26. genre: "xuanhuan",
  27. platform: "royalroad",
  28. chapterWordCount: 800,
  29. targetChapters: 60,
  30. status: "active",
  31. language: "en",
  32. createdAt: "2026-03-23T00:00:00.000Z",
  33. updatedAt: "2026-03-23T00:00:00.000Z",
  34. }, null, 2),
  35. "utf-8",
  36. ),
  37. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue keeps the oath token hidden.\n", "utf-8"),
  38. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  39. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  40. writeFile(join(storyDir, "subplot_board.md"), "# Subplot Board\n", "utf-8"),
  41. writeFile(join(storyDir, "emotional_arcs.md"), "# Emotional Arcs\n", "utf-8"),
  42. writeFile(join(storyDir, "character_matrix.md"), "# Character Matrix\n", "utf-8"),
  43. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nReturn to the mentor debt.\n", "utf-8"),
  44. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  45. ]);
  46. const auditor = new ContinuityAuditor({
  47. client: {
  48. provider: "openai",
  49. apiFormat: "chat",
  50. stream: false,
  51. defaults: {
  52. temperature: 0.7,
  53. maxTokens: 4096,
  54. thinkingBudget: 0, maxTokensCap: null,
  55. extra: {},
  56. },
  57. },
  58. model: "test-model",
  59. projectRoot: root,
  60. });
  61. const chatSpy = vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockResolvedValue({
  62. content: JSON.stringify({
  63. passed: true,
  64. issues: [],
  65. summary: "ok",
  66. }),
  67. usage: ZERO_USAGE,
  68. });
  69. try {
  70. await auditor.auditChapter(bookDir, "Chapter body.", 1, "xuanhuan");
  71. const messages = chatSpy.mock.calls[0]?.[0] as
  72. | ReadonlyArray<{ content: string }>
  73. | undefined;
  74. const systemPrompt = messages?.[0]?.content ?? "";
  75. expect(systemPrompt).toContain("ALL OUTPUT MUST BE IN ENGLISH");
  76. } finally {
  77. await rm(root, { recursive: true, force: true });
  78. }
  79. });
  80. it("localizes English audit prompts instead of mixing Chinese control text", async () => {
  81. const root = await mkdtemp(join(tmpdir(), "inkos-auditor-en-prompt-test-"));
  82. const bookDir = join(root, "book");
  83. const storyDir = join(bookDir, "story");
  84. await mkdir(storyDir, { recursive: true });
  85. await Promise.all([
  86. writeFile(
  87. join(bookDir, "book.json"),
  88. JSON.stringify({
  89. id: "english-book",
  90. title: "English Book",
  91. genre: "other",
  92. platform: "royalroad",
  93. chapterWordCount: 800,
  94. targetChapters: 60,
  95. status: "active",
  96. language: "en",
  97. createdAt: "2026-03-23T00:00:00.000Z",
  98. updatedAt: "2026-03-23T00:00:00.000Z",
  99. }, null, 2),
  100. "utf-8",
  101. ),
  102. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara keeps the warehouse key hidden.\n", "utf-8"),
  103. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  104. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  105. writeFile(join(storyDir, "subplot_board.md"), "# Subplot Board\n", "utf-8"),
  106. writeFile(join(storyDir, "emotional_arcs.md"), "# Emotional Arcs\n", "utf-8"),
  107. writeFile(join(storyDir, "character_matrix.md"), "# Character Matrix\n", "utf-8"),
  108. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nCheck Warehouse 9.\n", "utf-8"),
  109. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  110. ]);
  111. const auditor = new ContinuityAuditor({
  112. client: {
  113. provider: "openai",
  114. apiFormat: "chat",
  115. stream: false,
  116. defaults: {
  117. temperature: 0.7,
  118. maxTokens: 4096,
  119. thinkingBudget: 0, maxTokensCap: null,
  120. extra: {},
  121. },
  122. },
  123. model: "test-model",
  124. projectRoot: root,
  125. });
  126. const chatSpy = vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockResolvedValue({
  127. content: JSON.stringify({
  128. passed: true,
  129. issues: [],
  130. summary: "ok",
  131. }),
  132. usage: ZERO_USAGE,
  133. });
  134. try {
  135. await auditor.auditChapter(bookDir, "Chapter body.", 1, "other");
  136. const messages = chatSpy.mock.calls[0]?.[0] as
  137. | ReadonlyArray<{ content: string }>
  138. | undefined;
  139. const systemPrompt = messages?.[0]?.content ?? "";
  140. const userPrompt = messages?.[1]?.content ?? "";
  141. expect(systemPrompt).toContain("Hook Check");
  142. expect(systemPrompt).toContain("Chapter Memo Drift Check");
  143. expect(systemPrompt).not.toContain("Outline Drift Check");
  144. expect(systemPrompt).toContain("stays dormant long enough to feel abandoned");
  145. expect(systemPrompt).toContain("3-question test");
  146. expect(systemPrompt).toContain("same mode long enough to flatten rhythm");
  147. expect(systemPrompt).not.toContain("more than 5 chapters");
  148. expect(systemPrompt).not.toContain("3 straight chapters");
  149. expect(systemPrompt).not.toContain("3+ consecutive chapters");
  150. expect(systemPrompt).not.toContain("伏笔检查");
  151. expect(systemPrompt).not.toContain("大纲偏离检测");
  152. expect(userPrompt).toContain("Review chapter 1.");
  153. expect(userPrompt).toContain("## Current State Card");
  154. expect(userPrompt).toContain("## Pending Hooks");
  155. expect(userPrompt).not.toContain("请审查第1章");
  156. expect(userPrompt).not.toContain("## 当前状态卡");
  157. expect(userPrompt).not.toContain("## 伏笔池");
  158. } finally {
  159. await rm(root, { recursive: true, force: true });
  160. }
  161. });
  162. it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
  163. const root = await mkdtemp(join(tmpdir(), "inkos-auditor-test-"));
  164. const bookDir = join(root, "book");
  165. const storyDir = join(bookDir, "story");
  166. await mkdir(storyDir, { recursive: true });
  167. await Promise.all([
  168. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  169. writeFile(
  170. join(storyDir, "pending_hooks.md"),
  171. [
  172. "# Pending Hooks",
  173. "",
  174. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  175. "| --- | --- | --- | --- | --- | --- | --- |",
  176. "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
  177. "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
  178. "",
  179. ].join("\n"),
  180. "utf-8",
  181. ),
  182. writeFile(
  183. join(storyDir, "chapter_summaries.md"),
  184. [
  185. "# Chapter Summaries",
  186. "",
  187. "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
  188. "| 99 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  189. "",
  190. ].join("\n"),
  191. "utf-8",
  192. ),
  193. writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
  194. writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
  195. writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
  196. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
  197. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  198. ]);
  199. const auditor = new ContinuityAuditor({
  200. client: {
  201. provider: "openai",
  202. apiFormat: "chat",
  203. stream: false,
  204. defaults: {
  205. temperature: 0.7,
  206. maxTokens: 4096,
  207. thinkingBudget: 0, maxTokensCap: null,
  208. extra: {},
  209. },
  210. },
  211. model: "test-model",
  212. projectRoot: root,
  213. });
  214. const chatSpy = vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockResolvedValue({
  215. content: JSON.stringify({
  216. passed: true,
  217. issues: [],
  218. summary: "ok",
  219. }),
  220. usage: ZERO_USAGE,
  221. });
  222. try {
  223. await auditor.auditChapter(
  224. bookDir,
  225. "Chapter body.",
  226. 100,
  227. "xuanhuan",
  228. {
  229. chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
  230. contextPackage: {
  231. chapter: 100,
  232. selectedContext: [
  233. {
  234. source: "story/chapter_summaries.md#99",
  235. reason: "Relevant episodic memory.",
  236. excerpt: "Trial Echo | Mentor left without explanation | mentor-oath advanced",
  237. },
  238. {
  239. source: "story/pending_hooks.md#mentor-oath",
  240. reason: "Carry forward unresolved hook.",
  241. excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
  242. },
  243. ],
  244. },
  245. ruleStack: {
  246. layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
  247. sections: {
  248. hard: ["current_state"],
  249. soft: ["current_focus"],
  250. diagnostic: ["continuity_audit"],
  251. },
  252. overrideEdges: [],
  253. activeOverrides: [],
  254. },
  255. },
  256. );
  257. const messages = chatSpy.mock.calls[0]?.[0] as
  258. | ReadonlyArray<{ content: string }>
  259. | undefined;
  260. const userPrompt = messages?.[1]?.content ?? "";
  261. expect(userPrompt).toContain("story/chapter_summaries.md#99");
  262. expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
  263. expect(userPrompt).not.toContain("| 1 | Guild Trail |");
  264. expect(userPrompt).not.toContain("guild-route | 1 | mystery");
  265. } finally {
  266. await rm(root, { recursive: true, force: true });
  267. }
  268. });
  269. it("injects the chapter memo into the audit prompt for memo-drift checking", async () => {
  270. const root = await mkdtemp(join(tmpdir(), "inkos-auditor-memo-drift-"));
  271. const bookDir = join(root, "book");
  272. const storyDir = join(bookDir, "story");
  273. await mkdir(storyDir, { recursive: true });
  274. await Promise.all([
  275. writeFile(join(storyDir, "current_state.md"), "# Current State\n", "utf-8"),
  276. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  277. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  278. writeFile(join(storyDir, "subplot_board.md"), "# 支线\n", "utf-8"),
  279. writeFile(join(storyDir, "emotional_arcs.md"), "# 情感\n", "utf-8"),
  280. writeFile(join(storyDir, "character_matrix.md"), "# 矩阵\n", "utf-8"),
  281. writeFile(join(storyDir, "style_guide.md"), "# Style\n", "utf-8"),
  282. ]);
  283. const auditor = new ContinuityAuditor({
  284. client: {
  285. provider: "openai",
  286. apiFormat: "chat",
  287. stream: false,
  288. defaults: {
  289. temperature: 0.7,
  290. maxTokens: 4096,
  291. thinkingBudget: 0, maxTokensCap: null,
  292. extra: {},
  293. },
  294. },
  295. model: "test-model",
  296. projectRoot: root,
  297. });
  298. const chatSpy = vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockResolvedValue({
  299. content: JSON.stringify({ passed: true, issues: [], summary: "ok" }),
  300. usage: ZERO_USAGE,
  301. });
  302. const memoBody = [
  303. "## 当前任务",
  304. "陆焚在小巷抢回残刃并离开。",
  305. "",
  306. "## 读者此刻在等什么",
  307. "读者想看他怎么脱身。",
  308. "",
  309. "## 该兑现的 / 暂不掀的",
  310. "兑现:残刃归手;暂不掀:身世。",
  311. "",
  312. "## 日常/过渡承担什么任务",
  313. "开篇小巷场景 → 情绪代入 + 信息植入。",
  314. "",
  315. "## 关键抉择过三连问",
  316. "陆焚选择独自动手的理由是什么?",
  317. "",
  318. "## 章尾必须发生的改变",
  319. "陆焚拿回残刃,被人目击。",
  320. "",
  321. "## 不要做",
  322. "不要写成大段打斗。",
  323. ].join("\n");
  324. try {
  325. await auditor.auditChapter(bookDir, "Chapter body.", 42, "xuanhuan", {
  326. chapterMemo: {
  327. chapter: 42,
  328. goal: "陆焚抢回残刃并离开",
  329. isGoldenOpening: false,
  330. body: memoBody,
  331. threadRefs: [],
  332. },
  333. });
  334. const messages = chatSpy.mock.calls[0]?.[0] as
  335. | ReadonlyArray<{ content: string }>
  336. | undefined;
  337. const systemPrompt = messages?.[0]?.content ?? "";
  338. const userPrompt = messages?.[1]?.content ?? "";
  339. // Prompt declares structure-only scope and sparse-memo legality.
  340. expect(systemPrompt).toContain("审稿边界");
  341. expect(systemPrompt).toContain("你不审文笔");
  342. expect(systemPrompt).toContain("稀疏 memo 是合法状态");
  343. expect(systemPrompt).toContain("章节备忘偏离");
  344. expect(systemPrompt).not.toContain("大纲偏离检测");
  345. // User prompt injects the memo for drift-checking.
  346. expect(userPrompt).toContain("## 章节备忘(用于 memo 偏离检测)");
  347. expect(userPrompt).toContain("goal:陆焚抢回残刃并离开");
  348. expect(userPrompt).toContain("## 章尾必须发生的改变");
  349. // Legacy volume-outline block is gone.
  350. expect(userPrompt).not.toContain("## 卷纲");
  351. } finally {
  352. await rm(root, { recursive: true, force: true });
  353. }
  354. });
  355. });