writer.test.ts 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244
  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 { WriterAgent } from "../agents/writer.js";
  6. import { buildLengthSpec } from "../utils/length-metrics.js";
  7. const ZERO_USAGE = {
  8. promptTokens: 0,
  9. completionTokens: 0,
  10. totalTokens: 0,
  11. } as const;
  12. function createCaptureLogger() {
  13. const infos: string[] = [];
  14. const warnings: string[] = [];
  15. const logger = {
  16. debug() {},
  17. info(message: string) {
  18. infos.push(message);
  19. },
  20. warn(message: string) {
  21. warnings.push(message);
  22. },
  23. error() {},
  24. child() {
  25. return logger;
  26. },
  27. };
  28. return { logger, infos, warnings };
  29. }
  30. describe("WriterAgent", () => {
  31. afterEach(() => {
  32. vi.restoreAllMocks();
  33. });
  34. it("uses compact summary context plus selected long-range evidence during governed settlement", async () => {
  35. const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
  36. const bookDir = join(root, "book");
  37. const storyDir = join(bookDir, "story");
  38. await mkdir(storyDir, { recursive: true });
  39. await Promise.all([
  40. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  41. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
  42. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  43. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  44. writeFile(join(storyDir, "pending_hooks.md"), [
  45. "# Pending Hooks",
  46. "",
  47. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  48. "| --- | --- | --- | --- | --- | --- | --- |",
  49. "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
  50. "| old-seal | 3 | artifact | open | 12 | 40 | Old seal detour |",
  51. "| stale-ledger | 14 | mystery | open | 70 | 120 | Old ledger debt is dormant but unresolved |",
  52. "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
  53. ].join("\n"), "utf-8"),
  54. writeFile(join(storyDir, "chapter_summaries.md"), [
  55. "# Chapter Summaries",
  56. "",
  57. "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
  58. "| 97 | Shrine Ash | Lin Yue | The old shrine proves empty | Frustration rises | none | bitter | setback |",
  59. "| 98 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  60. "| 99 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
  61. ].join("\n"), "utf-8"),
  62. writeFile(join(storyDir, "subplot_board.md"), [
  63. "# 支线进度板",
  64. "",
  65. "| 支线ID | 支线名 | 相关角色 | 起始章 | 最近活跃章 | 距今章数 | 状态 | 进度概述 | 回收ETA |",
  66. "| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
  67. "| SP-mentor | 师债线 | Lin Yue | 8 | 99 | 1 | active | 师债继续推进 | 101 |",
  68. "| SP-seal | 旧印支线 | Guildmaster Ren | 3 | 12 | 88 | closed | 旧印已回收 | 12 |",
  69. ].join("\n"), "utf-8"),
  70. writeFile(join(storyDir, "emotional_arcs.md"), [
  71. "# 情感弧线",
  72. "",
  73. "| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |",
  74. "| --- | --- | --- | --- | --- | --- |",
  75. "| Lin Yue | 40 | 麻木 | 旧印支线拖延 | 4 | 停滞 |",
  76. "| Lin Yue | 99 | 紧绷 | 师债重新压上来 | 8 | 收紧 |",
  77. ].join("\n"), "utf-8"),
  78. writeFile(join(storyDir, "character_matrix.md"), [
  79. "# 角色交互矩阵",
  80. "",
  81. "### 角色档案",
  82. "| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |",
  83. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  84. "| Lin Yue | oath | restraint | clipped | stubborn | self | repay debt | find mentor |",
  85. "| Guildmaster Ren | guild | swagger | loud | opportunistic | rival | stall Mara | seize seal |",
  86. ].join("\n"), "utf-8"),
  87. ]);
  88. const agent = new WriterAgent({
  89. client: {
  90. provider: "openai",
  91. apiFormat: "chat",
  92. stream: false,
  93. defaults: {
  94. temperature: 0.7,
  95. maxTokens: 4096,
  96. thinkingBudget: 0, maxTokensCap: null,
  97. extra: {},
  98. },
  99. },
  100. model: "test-model",
  101. projectRoot: root,
  102. });
  103. const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  104. .mockResolvedValueOnce({
  105. content: [
  106. "=== CHAPTER_TITLE ===",
  107. "A Decision",
  108. "",
  109. "=== CHAPTER_CONTENT ===",
  110. "Lin Yue turned away from the guild trail and chose the mentor debt.",
  111. "",
  112. "=== PRE_WRITE_CHECK ===",
  113. "- ok",
  114. ].join("\n"),
  115. usage: ZERO_USAGE,
  116. })
  117. .mockResolvedValueOnce({
  118. content: "=== OBSERVATIONS ===\n- observed",
  119. usage: ZERO_USAGE,
  120. })
  121. .mockResolvedValueOnce({
  122. content: [
  123. "=== POST_SETTLEMENT ===",
  124. "| 伏笔变动 | mentor-oath 推进 | 同步更新伏笔池 |",
  125. "",
  126. "=== UPDATED_STATE ===",
  127. "状态卡",
  128. "",
  129. "=== UPDATED_HOOKS ===",
  130. "伏笔池",
  131. "",
  132. "=== CHAPTER_SUMMARY ===",
  133. "| 100 | A Decision | Lin Yue | Chooses the mentor debt | Focus narrowed | mentor-oath advanced | tense | decision |",
  134. "",
  135. "=== UPDATED_SUBPLOTS ===",
  136. "支线板",
  137. "",
  138. "=== UPDATED_EMOTIONAL_ARCS ===",
  139. "情感弧线",
  140. "",
  141. "=== UPDATED_CHARACTER_MATRIX ===",
  142. "角色矩阵",
  143. ].join("\n"),
  144. usage: ZERO_USAGE,
  145. });
  146. try {
  147. await agent.writeChapter({
  148. book: {
  149. id: "writer-book",
  150. title: "Writer Book",
  151. platform: "tomato",
  152. genre: "xuanhuan",
  153. status: "active",
  154. targetChapters: 120,
  155. chapterWordCount: 2200,
  156. createdAt: "2026-03-23T00:00:00.000Z",
  157. updatedAt: "2026-03-23T00:00:00.000Z",
  158. },
  159. bookDir,
  160. chapterNumber: 100,
  161. chapterIntent: [
  162. "# Chapter Intent",
  163. "",
  164. "## Goal",
  165. "Bring the focus back to the mentor oath conflict.",
  166. "",
  167. "## Hook Agenda",
  168. "### Must Advance",
  169. "- mentor-oath",
  170. "",
  171. "### Eligible Resolve",
  172. "- none",
  173. "",
  174. "### Stale Debt",
  175. "- stale-ledger",
  176. "",
  177. "### Avoid New Hook Families",
  178. "- none",
  179. ].join("\n"),
  180. contextPackage: {
  181. chapter: 100,
  182. selectedContext: [
  183. {
  184. source: "story/volume_outline.md",
  185. reason: "Anchor the current beat.",
  186. excerpt: "Bring the focus back to the mentor oath conflict.",
  187. },
  188. {
  189. source: "story/chapter_summaries.md#99",
  190. reason: "Relevant episodic memory.",
  191. excerpt: "Locked Gate | Lin Yue chooses the mentor line over the guild line | mentor-oath advanced",
  192. },
  193. {
  194. source: "story/pending_hooks.md#mentor-oath",
  195. reason: "Carry forward unresolved hook.",
  196. excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
  197. },
  198. {
  199. source: "runtime/hook_debt#mentor-oath",
  200. reason: "Explicit hook debt brief for the agenda target.",
  201. excerpt: "mentor-oath | cadence: slow-burn | seed: ch8 River Camp - Mentor debt becomes personal | latest: ch99 Locked Gate - Lin Yue chooses the mentor line over the guild line | unpaid: reveal why the mentor broke the oath",
  202. },
  203. ],
  204. },
  205. ruleStack: {
  206. layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
  207. sections: {
  208. hard: ["current_state"],
  209. soft: ["current_focus"],
  210. diagnostic: ["continuity_audit"],
  211. },
  212. overrideEdges: [],
  213. activeOverrides: [],
  214. },
  215. lengthSpec: buildLengthSpec(220, "zh"),
  216. });
  217. const settlePrompt = (chatSpy.mock.calls[2]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
  218. expect(settlePrompt).toContain("## 本章控制输入");
  219. expect(settlePrompt).toContain("story/chapter_summaries.md#99");
  220. expect(settlePrompt).toContain("| 99 | Locked Gate |");
  221. expect(settlePrompt).toContain("## Hook Debt Briefs");
  222. expect(settlePrompt).toContain("mentor-oath | cadence: slow-burn");
  223. expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | 中程 | Old ledger debt is dormant but unresolved |");
  224. expect(settlePrompt).not.toContain("| 1 | Guild Trail |");
  225. expect(settlePrompt).not.toContain("old-seal");
  226. expect(settlePrompt).not.toContain("Guildmaster Ren");
  227. expect(settlePrompt).not.toContain("| Lin Yue | 40 | 麻木 |");
  228. } finally {
  229. await rm(root, { recursive: true, force: true });
  230. }
  231. });
  232. it("builds structured runtime-state artifacts when settler returns a delta", async () => {
  233. const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-test-"));
  234. const bookDir = join(root, "book");
  235. const storyDir = join(bookDir, "story");
  236. const chaptersDir = join(bookDir, "chapters");
  237. await mkdir(storyDir, { recursive: true });
  238. await mkdir(chaptersDir, { recursive: true });
  239. await Promise.all([
  240. writeFile(join(chaptersDir, "index.json"), JSON.stringify([
  241. { number: 1, title: "Ch1", status: "approved" },
  242. { number: 2, title: "Ch2", status: "approved" },
  243. ]), "utf-8"),
  244. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  245. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\n", "utf-8"),
  246. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  247. writeFile(join(storyDir, "current_state.md"), [
  248. "# Current State",
  249. "",
  250. "| Field | Value |",
  251. "| --- | --- |",
  252. "| Current Chapter | 2 |",
  253. "| Current Goal | Find the vanished mentor |",
  254. "| Current Conflict | Guild pressure keeps colliding with the debt trail |",
  255. "",
  256. ].join("\n"), "utf-8"),
  257. writeFile(join(storyDir, "pending_hooks.md"), [
  258. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  259. "| --- | --- | --- | --- | --- | --- | --- |",
  260. "| mentor-debt | 1 | relationship | open | 2 | 6 | Still unresolved |",
  261. "",
  262. ].join("\n"), "utf-8"),
  263. writeFile(join(storyDir, "chapter_summaries.md"), [
  264. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  265. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  266. "| 2 | Old Ledger | Lin Yue | Lin Yue finds the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |",
  267. "",
  268. ].join("\n"), "utf-8"),
  269. ]);
  270. const agent = new WriterAgent({
  271. client: {
  272. provider: "openai",
  273. apiFormat: "chat",
  274. stream: false,
  275. defaults: {
  276. temperature: 0.7,
  277. maxTokens: 4096,
  278. thinkingBudget: 0, maxTokensCap: null,
  279. extra: {},
  280. },
  281. },
  282. model: "test-model",
  283. projectRoot: root,
  284. });
  285. vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  286. .mockResolvedValueOnce({
  287. content: [
  288. "=== CHAPTER_TITLE ===",
  289. "River Ledger",
  290. "",
  291. "=== CHAPTER_CONTENT ===",
  292. "Lin Yue follows the debt into the river-port ledger.",
  293. "",
  294. "=== PRE_WRITE_CHECK ===",
  295. "- ok",
  296. ].join("\n"),
  297. usage: ZERO_USAGE,
  298. })
  299. .mockResolvedValueOnce({
  300. content: "=== OBSERVATIONS ===\n- observed",
  301. usage: ZERO_USAGE,
  302. })
  303. .mockResolvedValueOnce({
  304. content: [
  305. "=== POST_SETTLEMENT ===",
  306. "- mentor-debt advanced",
  307. "",
  308. "=== RUNTIME_STATE_DELTA ===",
  309. "```json",
  310. JSON.stringify({
  311. chapter: 3,
  312. currentStatePatch: {
  313. currentGoal: "Trace the debt through the river-port ledger.",
  314. currentConflict: "Guild pressure keeps colliding with the debt trail.",
  315. },
  316. hookOps: {
  317. upsert: [
  318. {
  319. hookId: "mentor-debt",
  320. startChapter: 1,
  321. type: "relationship",
  322. status: "progressing",
  323. lastAdvancedChapter: 3,
  324. expectedPayoff: "Reveal the debt.",
  325. notes: "The ledger clue sharpens the line.",
  326. },
  327. ],
  328. resolve: [],
  329. defer: [],
  330. },
  331. chapterSummary: {
  332. chapter: 3,
  333. title: "River Ledger",
  334. characters: "Lin Yue",
  335. events: "Lin Yue follows the debt into the river-port ledger.",
  336. stateChanges: "The debt line sharpens.",
  337. hookActivity: "mentor-debt advanced",
  338. mood: "tense",
  339. chapterType: "investigation",
  340. },
  341. notes: [],
  342. }, null, 2),
  343. "```",
  344. ].join("\n"),
  345. usage: ZERO_USAGE,
  346. });
  347. try {
  348. const output = await agent.writeChapter({
  349. book: {
  350. id: "writer-book",
  351. title: "Writer Book",
  352. platform: "tomato",
  353. genre: "xuanhuan",
  354. status: "active",
  355. targetChapters: 20,
  356. chapterWordCount: 2200,
  357. language: "en",
  358. createdAt: "2026-03-25T00:00:00.000Z",
  359. updatedAt: "2026-03-25T00:00:00.000Z",
  360. },
  361. bookDir,
  362. chapterNumber: 3,
  363. lengthSpec: buildLengthSpec(2200, "en"),
  364. });
  365. expect(output.runtimeStateDelta?.chapter).toBe(3);
  366. expect(output.runtimeStateSnapshot?.manifest.lastAppliedChapter).toBe(3);
  367. expect(output.updatedState).toContain("Trace the debt through the river-port ledger.");
  368. expect(output.updatedHooks).toContain("mentor-debt");
  369. expect(output.updatedChapterSummaries).toContain("River Ledger");
  370. expect(output.chapterSummary).toContain("| 3 | River Ledger |");
  371. } finally {
  372. await rm(root, { recursive: true, force: true });
  373. }
  374. });
  375. it("overrides hallucinated chapter numbers across both delta and summary row", async () => {
  376. const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-hallucinated-chapter-test-"));
  377. const bookDir = join(root, "book");
  378. const storyDir = join(bookDir, "story");
  379. const chaptersDir = join(bookDir, "chapters");
  380. await mkdir(storyDir, { recursive: true });
  381. await mkdir(chaptersDir, { recursive: true });
  382. await Promise.all([
  383. writeFile(join(chaptersDir, "index.json"), JSON.stringify([
  384. { number: 1, title: "Ch1", status: "approved" },
  385. { number: 2, title: "Ch2", status: "approved" },
  386. ]), "utf-8"),
  387. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The city still remembers 1988.\n", "utf-8"),
  388. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\n", "utf-8"),
  389. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  390. writeFile(join(storyDir, "current_state.md"), [
  391. "# Current State",
  392. "",
  393. "| Field | Value |",
  394. "| --- | --- |",
  395. "| Current Chapter | 2 |",
  396. "| Current Goal | Find the vanished mentor |",
  397. "| Current Conflict | Guild pressure keeps colliding with the debt trail |",
  398. "",
  399. ].join("\n"), "utf-8"),
  400. writeFile(join(storyDir, "pending_hooks.md"), [
  401. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  402. "| --- | --- | --- | --- | --- | --- | --- |",
  403. "| mentor-debt | 1 | relationship | open | 2 | 6 | Still unresolved |",
  404. "",
  405. ].join("\n"), "utf-8"),
  406. writeFile(join(storyDir, "chapter_summaries.md"), [
  407. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  408. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  409. "| 2 | Old Ledger | Lin Yue | Lin Yue finds the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |",
  410. "",
  411. ].join("\n"), "utf-8"),
  412. ]);
  413. const agent = new WriterAgent({
  414. client: {
  415. provider: "openai",
  416. apiFormat: "chat",
  417. stream: false,
  418. defaults: {
  419. temperature: 0.7,
  420. maxTokens: 4096,
  421. thinkingBudget: 0, maxTokensCap: null,
  422. extra: {},
  423. },
  424. },
  425. model: "test-model",
  426. projectRoot: root,
  427. });
  428. vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  429. .mockResolvedValueOnce({
  430. content: [
  431. "=== CHAPTER_TITLE ===",
  432. "River Ledger",
  433. "",
  434. "=== CHAPTER_CONTENT ===",
  435. "Lin Yue follows the debt into the river-port ledger. The old wall still carries the year 1988.",
  436. "",
  437. "=== PRE_WRITE_CHECK ===",
  438. "- ok",
  439. ].join("\n"),
  440. usage: ZERO_USAGE,
  441. })
  442. .mockResolvedValueOnce({
  443. content: "=== OBSERVATIONS ===\n- observed",
  444. usage: ZERO_USAGE,
  445. })
  446. .mockResolvedValueOnce({
  447. content: [
  448. "=== POST_SETTLEMENT ===",
  449. "- mentor-debt advanced",
  450. "",
  451. "=== RUNTIME_STATE_DELTA ===",
  452. "```json",
  453. JSON.stringify({
  454. chapter: 1988,
  455. currentStatePatch: {
  456. currentGoal: "Trace the debt through the river-port ledger.",
  457. currentConflict: "Guild pressure keeps colliding with the debt trail.",
  458. },
  459. hookOps: {
  460. upsert: [
  461. {
  462. hookId: "mentor-debt",
  463. startChapter: 1,
  464. type: "relationship",
  465. status: "progressing",
  466. lastAdvancedChapter: 1988,
  467. expectedPayoff: "Reveal the debt.",
  468. notes: "The ledger clue sharpens the line.",
  469. },
  470. ],
  471. resolve: [],
  472. defer: [],
  473. },
  474. chapterSummary: {
  475. chapter: 1988,
  476. title: "River Ledger",
  477. characters: "Lin Yue",
  478. events: "Lin Yue follows the debt into the river-port ledger.",
  479. stateChanges: "The debt line sharpens.",
  480. hookActivity: "mentor-debt advanced",
  481. mood: "tense",
  482. chapterType: "investigation",
  483. },
  484. notes: [],
  485. }, null, 2),
  486. "```",
  487. ].join("\n"),
  488. usage: ZERO_USAGE,
  489. });
  490. try {
  491. const output = await agent.writeChapter({
  492. book: {
  493. id: "writer-book",
  494. title: "Writer Book",
  495. platform: "tomato",
  496. genre: "xuanhuan",
  497. status: "active",
  498. targetChapters: 20,
  499. chapterWordCount: 2200,
  500. language: "en",
  501. createdAt: "2026-03-25T00:00:00.000Z",
  502. updatedAt: "2026-03-25T00:00:00.000Z",
  503. },
  504. bookDir,
  505. chapterNumber: 3,
  506. lengthSpec: buildLengthSpec(2200, "en"),
  507. });
  508. expect(output.runtimeStateDelta?.chapter).toBe(3);
  509. expect(output.runtimeStateDelta?.chapterSummary?.chapter).toBe(3);
  510. expect(output.runtimeStateSnapshot?.manifest.lastAppliedChapter).toBe(3);
  511. expect(output.runtimeStateSnapshot?.hooks.hooks[0]?.lastAdvancedChapter).toBe(3);
  512. expect(output.updatedHooks).toContain("| mentor-debt | 1 | relationship | progressing | 3 |");
  513. expect(output.updatedChapterSummaries).toContain("| 3 | River Ledger |");
  514. expect(output.chapterSummary).toContain("| 3 | River Ledger |");
  515. } finally {
  516. await rm(root, { recursive: true, force: true });
  517. }
  518. });
  519. it("returns the arbiter-resolved delta instead of raw new-hook candidates", async () => {
  520. const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-"));
  521. const bookDir = join(root, "book");
  522. const storyDir = join(bookDir, "story");
  523. const chaptersDir = join(bookDir, "chapters");
  524. await mkdir(storyDir, { recursive: true });
  525. await mkdir(chaptersDir, { recursive: true });
  526. await Promise.all([
  527. writeFile(join(chaptersDir, "index.json"), JSON.stringify([
  528. { number: 1, title: "Ch1", status: "approved" },
  529. { number: 2, title: "Ch2", status: "approved" },
  530. ]), "utf-8"),
  531. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Anonymous messages keep steering the debt trail.\n", "utf-8"),
  532. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nThe anonymous source widens from route to address.\n", "utf-8"),
  533. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  534. writeFile(join(storyDir, "current_state.md"), [
  535. "# Current State",
  536. "",
  537. "| Field | Value |",
  538. "| --- | --- |",
  539. "| Current Chapter | 2 |",
  540. "| Current Goal | Find who fed the route to the anonymous source |",
  541. "",
  542. ].join("\n"), "utf-8"),
  543. writeFile(join(storyDir, "pending_hooks.md"), [
  544. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  545. "| --- | --- | --- | --- | --- | --- | --- |",
  546. "| anonymous-source-scope | 1 | source-risk | open | 2 | Reveal how much the anonymous source already knew about the route. | The source knowledge question remains unresolved. |",
  547. "",
  548. ].join("\n"), "utf-8"),
  549. writeFile(join(storyDir, "chapter_summaries.md"), [
  550. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  551. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  552. "| 2 | Route Leak | Lin Yue | An anonymous source already knew the route | Suspicion sharpens | anonymous-source-scope advanced | tense | mainline |",
  553. "",
  554. ].join("\n"), "utf-8"),
  555. ]);
  556. const agent = new WriterAgent({
  557. client: {
  558. provider: "openai",
  559. apiFormat: "chat",
  560. stream: false,
  561. defaults: {
  562. temperature: 0.7,
  563. maxTokens: 4096,
  564. thinkingBudget: 0, maxTokensCap: null,
  565. extra: {},
  566. },
  567. },
  568. model: "test-model",
  569. projectRoot: root,
  570. });
  571. vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  572. .mockResolvedValueOnce({
  573. content: [
  574. "=== CHAPTER_TITLE ===",
  575. "Address Leak",
  576. "",
  577. "=== CHAPTER_CONTENT ===",
  578. "Lin Yue realizes the anonymous source knew the address, not just the route.",
  579. "",
  580. "=== PRE_WRITE_CHECK ===",
  581. "- ok",
  582. ].join("\n"),
  583. usage: ZERO_USAGE,
  584. })
  585. .mockResolvedValueOnce({
  586. content: "=== OBSERVATIONS ===\n- observed",
  587. usage: ZERO_USAGE,
  588. })
  589. .mockResolvedValueOnce({
  590. content: [
  591. "=== POST_SETTLEMENT ===",
  592. "- source scope widens",
  593. "",
  594. "=== RUNTIME_STATE_DELTA ===",
  595. "```json",
  596. JSON.stringify({
  597. chapter: 3,
  598. hookOps: {
  599. upsert: [],
  600. mention: [],
  601. resolve: [],
  602. defer: [],
  603. },
  604. newHookCandidates: [
  605. {
  606. type: "source-risk",
  607. expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
  608. notes: "This chapter adds the address angle to the anonymous source question.",
  609. },
  610. ],
  611. chapterSummary: {
  612. chapter: 3,
  613. title: "Address Leak",
  614. characters: "Lin Yue",
  615. events: "Lin Yue realizes the anonymous source knew the address.",
  616. stateChanges: "The source knowledge question widens.",
  617. hookActivity: "anonymous-source-scope advanced",
  618. mood: "tight",
  619. chapterType: "investigation",
  620. },
  621. notes: [],
  622. }, null, 2),
  623. "```",
  624. ].join("\n"),
  625. usage: ZERO_USAGE,
  626. });
  627. try {
  628. const output = await agent.writeChapter({
  629. book: {
  630. id: "writer-book",
  631. title: "Writer Book",
  632. platform: "tomato",
  633. genre: "other",
  634. status: "active",
  635. targetChapters: 20,
  636. chapterWordCount: 2200,
  637. language: "en",
  638. createdAt: "2026-03-27T00:00:00.000Z",
  639. updatedAt: "2026-03-27T00:00:00.000Z",
  640. },
  641. bookDir,
  642. chapterNumber: 3,
  643. lengthSpec: buildLengthSpec(2200, "en"),
  644. });
  645. expect(output.runtimeStateDelta?.hookOps.upsert).toEqual([
  646. expect.objectContaining({
  647. hookId: "anonymous-source-scope",
  648. lastAdvancedChapter: 3,
  649. }),
  650. ]);
  651. expect(output.runtimeStateDelta?.newHookCandidates).toEqual([]);
  652. expect(output.updatedHooks).toContain("anonymous-source-scope");
  653. expect(output.updatedHooks).toContain("| anonymous-source-scope | 1 | source-risk | progressing | 3 |");
  654. } finally {
  655. await rm(root, { recursive: true, force: true });
  656. }
  657. });
  658. it("logs localized phase messages for Chinese books", async () => {
  659. const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
  660. const bookDir = join(root, "book");
  661. const storyDir = join(bookDir, "story");
  662. const { logger, infos } = createCaptureLogger();
  663. await mkdir(storyDir, { recursive: true });
  664. await Promise.all([
  665. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n", "utf-8"),
  666. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n", "utf-8"),
  667. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n", "utf-8"),
  668. writeFile(join(storyDir, "current_state.md"), "# 当前状态\n", "utf-8"),
  669. writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n", "utf-8"),
  670. writeFile(join(storyDir, "chapter_summaries.md"), "# 章节摘要\n", "utf-8"),
  671. writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
  672. writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
  673. writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
  674. ]);
  675. const agent = new WriterAgent({
  676. client: {
  677. provider: "openai",
  678. apiFormat: "chat",
  679. stream: false,
  680. defaults: {
  681. temperature: 0.7,
  682. maxTokens: 4096,
  683. thinkingBudget: 0, maxTokensCap: null,
  684. extra: {},
  685. },
  686. },
  687. model: "test-model",
  688. projectRoot: root,
  689. logger,
  690. });
  691. vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  692. .mockResolvedValueOnce({
  693. content: [
  694. "=== CHAPTER_TITLE ===",
  695. "试炼前夜",
  696. "",
  697. "=== CHAPTER_CONTENT ===",
  698. "林越在破庙外停住脚步,想起师门旧债。",
  699. "",
  700. "=== PRE_WRITE_CHECK ===",
  701. "- ok",
  702. ].join("\n"),
  703. usage: ZERO_USAGE,
  704. })
  705. .mockResolvedValueOnce({
  706. content: "=== OBSERVATIONS ===\n- observed",
  707. usage: ZERO_USAGE,
  708. })
  709. .mockResolvedValueOnce({
  710. content: [
  711. "=== POST_SETTLEMENT ===",
  712. "| 伏笔变动 | mentor-oath 推进 | 同步更新伏笔池 |",
  713. "",
  714. "=== UPDATED_STATE ===",
  715. "状态卡",
  716. "",
  717. "=== UPDATED_HOOKS ===",
  718. "伏笔池",
  719. "",
  720. "=== CHAPTER_SUMMARY ===",
  721. "| 1 | 试炼前夜 | 林越 | 林越记起师门旧债 | 决心加深 | mentor-oath advanced | tense | setup |",
  722. "",
  723. "=== UPDATED_SUBPLOTS ===",
  724. "支线板",
  725. "",
  726. "=== UPDATED_EMOTIONAL_ARCS ===",
  727. "情感弧线",
  728. "",
  729. "=== UPDATED_CHARACTER_MATRIX ===",
  730. "角色矩阵",
  731. ].join("\n"),
  732. usage: ZERO_USAGE,
  733. });
  734. try {
  735. await agent.writeChapter({
  736. book: {
  737. id: "writer-book",
  738. title: "Writer Book",
  739. platform: "tomato",
  740. genre: "xuanhuan",
  741. status: "active",
  742. targetChapters: 120,
  743. chapterWordCount: 2200,
  744. language: "zh",
  745. createdAt: "2026-03-23T00:00:00.000Z",
  746. updatedAt: "2026-03-23T00:00:00.000Z",
  747. },
  748. bookDir,
  749. chapterNumber: 1,
  750. lengthSpec: buildLengthSpec(220, "zh"),
  751. });
  752. expect(infos).toEqual(expect.arrayContaining([
  753. "阶段 1:创作正文(第1章)",
  754. "阶段 2:状态结算(第1章,18字)",
  755. "阶段 2a:提取第1章事实",
  756. "阶段 2b:把观察结果回写到真相文件",
  757. ]));
  758. } finally {
  759. await rm(root, { recursive: true, force: true });
  760. }
  761. });
  762. it("injects an English variance brief into governed creative prompts", async () => {
  763. const root = await mkdtemp(join(tmpdir(), "inkos-writer-variance-test-"));
  764. const bookDir = join(root, "book");
  765. const storyDir = join(bookDir, "story");
  766. const chaptersDir = join(bookDir, "chapters");
  767. await mkdir(storyDir, { recursive: true });
  768. await mkdir(chaptersDir, { recursive: true });
  769. await Promise.all([
  770. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The registry seals matter.\n", "utf-8"),
  771. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nForce Mara back toward the ledger trail.\n", "utf-8"),
  772. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
  773. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
  774. writeFile(join(storyDir, "pending_hooks.md"), [
  775. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  776. "| --- | --- | --- | --- | --- | --- | --- |",
  777. "| ledger-fragment | 1 | mystery | open | 3 | 8 | Mara still hides the ledger fragment |",
  778. ].join("\n"), "utf-8"),
  779. writeFile(join(storyDir, "chapter_summaries.md"), [
  780. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  781. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  782. "| 1 | Ledger | Mara | Mara hides the ledger | pressure tightens | none | tense | investigation |",
  783. "| 2 | Ash | Mara,Taryn | Ash falls over the archive | pressure tightens | none | tense | investigation |",
  784. "| 3 | Harbor | Mara,Taryn | The gate stays under watch | pressure tightens | none | tense | investigation |",
  785. ].join("\n"), "utf-8"),
  786. writeFile(join(chaptersDir, "0001_Ledger.md"), "# Chapter 1 Ledger\n\nMara kept the ledger close to her chest. The corridor stayed quiet after the bell. There it was again.\n", "utf-8"),
  787. writeFile(join(chaptersDir, "0002_Ash.md"), "# Chapter 2 Ash\n\nMara kept the ledger close to her chest while the ash fell. The corridor stayed quiet until Taryn stopped. There it was again.\n", "utf-8"),
  788. writeFile(join(chaptersDir, "0003_Harbor.md"), "# Chapter 3 Harbor\n\nMara kept the ledger close to her chest near the harbor gate. The corridor stayed quiet while the guards changed. There it was again.\n", "utf-8"),
  789. ]);
  790. const agent = new WriterAgent({
  791. client: {
  792. provider: "openai",
  793. apiFormat: "chat",
  794. stream: false,
  795. defaults: {
  796. temperature: 0.7,
  797. maxTokens: 4096,
  798. thinkingBudget: 0, maxTokensCap: null,
  799. extra: {},
  800. },
  801. },
  802. model: "test-model",
  803. projectRoot: root,
  804. });
  805. const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  806. .mockResolvedValueOnce({
  807. content: [
  808. "=== CHAPTER_TITLE ===",
  809. "Pressure Ledger",
  810. "",
  811. "=== CHAPTER_CONTENT ===",
  812. "Mara forced Taryn to answer beside the archive window.",
  813. "",
  814. "=== PRE_WRITE_CHECK ===",
  815. "- ok",
  816. ].join("\n"),
  817. usage: ZERO_USAGE,
  818. })
  819. .mockResolvedValueOnce({
  820. content: "=== OBSERVATIONS ===\n- observed",
  821. usage: ZERO_USAGE,
  822. })
  823. .mockResolvedValueOnce({
  824. content: [
  825. "=== POST_SETTLEMENT ===",
  826. "- ledger-fragment advanced",
  827. "",
  828. "=== UPDATED_STATE ===",
  829. "state",
  830. "",
  831. "=== UPDATED_HOOKS ===",
  832. "hooks",
  833. "",
  834. "=== CHAPTER_SUMMARY ===",
  835. "| 4 | Pressure Ledger | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
  836. "",
  837. "=== UPDATED_SUBPLOTS ===",
  838. "subplots",
  839. "",
  840. "=== UPDATED_EMOTIONAL_ARCS ===",
  841. "arcs",
  842. "",
  843. "=== UPDATED_CHARACTER_MATRIX ===",
  844. "matrix",
  845. ].join("\n"),
  846. usage: ZERO_USAGE,
  847. });
  848. try {
  849. await agent.writeChapter({
  850. book: {
  851. id: "writer-book",
  852. title: "Writer Book",
  853. platform: "other",
  854. genre: "other",
  855. status: "active",
  856. targetChapters: 20,
  857. chapterWordCount: 2200,
  858. language: "en",
  859. createdAt: "2026-03-26T00:00:00.000Z",
  860. updatedAt: "2026-03-26T00:00:00.000Z",
  861. },
  862. bookDir,
  863. chapterNumber: 4,
  864. chapterBrief: {
  865. chapter: 4,
  866. goal: "Force Mara back toward the ledger trail.",
  867. chapterType: "confrontation",
  868. isGoldenOpening: false,
  869. beatOutline: [{ phase: "opening" as const, instruction: "Open with tension at the archive window." }],
  870. hookPlan: [{ hookId: "ledger-fragment", movement: "advance" as const, targetEffect: "Mara is forced to confront the ledger." }],
  871. propsAndSetting: ["archive window"],
  872. },
  873. contextPackage: {
  874. chapter: 4,
  875. selectedContext: [
  876. {
  877. source: "story/chapter_summaries.md#3",
  878. reason: "Carry recent pressure into the next chapter.",
  879. excerpt: "The gate stays under watch.",
  880. },
  881. ],
  882. },
  883. ruleStack: {
  884. layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
  885. sections: {
  886. hard: ["current_state"],
  887. soft: ["current_focus"],
  888. diagnostic: ["continuity_audit"],
  889. },
  890. overrideEdges: [],
  891. activeOverrides: [],
  892. },
  893. lengthSpec: buildLengthSpec(2200, "en"),
  894. });
  895. const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
  896. expect(creativePrompt).toContain("## English Variance Brief");
  897. expect(creativePrompt).toContain("High-frequency phrases");
  898. expect(creativePrompt).toContain("Scene obligation");
  899. } finally {
  900. await rm(root, { recursive: true, force: true });
  901. }
  902. });
  903. it("renders explicit title history, mood trail, and canon blocks in governed creative prompts", async () => {
  904. const root = await mkdtemp(join(tmpdir(), "inkos-writer-governed-evidence-test-"));
  905. const bookDir = join(root, "book");
  906. const storyDir = join(bookDir, "story");
  907. await mkdir(storyDir, { recursive: true });
  908. await Promise.all([
  909. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Registry seals still matter.\n", "utf-8"),
  910. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nPush Mara back toward the archive ledger.\n", "utf-8"),
  911. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
  912. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
  913. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- ledger-fragment\n", "utf-8"),
  914. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  915. ]);
  916. const agent = new WriterAgent({
  917. client: {
  918. provider: "openai",
  919. apiFormat: "chat",
  920. stream: false,
  921. defaults: {
  922. temperature: 0.7,
  923. maxTokens: 4096,
  924. thinkingBudget: 0, maxTokensCap: null,
  925. extra: {},
  926. },
  927. },
  928. model: "test-model",
  929. projectRoot: root,
  930. });
  931. const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  932. .mockResolvedValueOnce({
  933. content: [
  934. "=== CHAPTER_TITLE ===",
  935. "Archive Pressure",
  936. "",
  937. "=== CHAPTER_CONTENT ===",
  938. "Mara corners Taryn beside the archive ledger.",
  939. "",
  940. "=== PRE_WRITE_CHECK ===",
  941. "- ok",
  942. ].join("\n"),
  943. usage: ZERO_USAGE,
  944. })
  945. .mockResolvedValueOnce({
  946. content: "=== OBSERVATIONS ===\n- observed",
  947. usage: ZERO_USAGE,
  948. })
  949. .mockResolvedValueOnce({
  950. content: [
  951. "=== POST_SETTLEMENT ===",
  952. "- ledger-fragment advanced",
  953. "",
  954. "=== UPDATED_STATE ===",
  955. "state",
  956. "",
  957. "=== UPDATED_HOOKS ===",
  958. "hooks",
  959. "",
  960. "=== CHAPTER_SUMMARY ===",
  961. "| 4 | Archive Pressure | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
  962. "",
  963. "=== UPDATED_SUBPLOTS ===",
  964. "subplots",
  965. "",
  966. "=== UPDATED_EMOTIONAL_ARCS ===",
  967. "arcs",
  968. "",
  969. "=== UPDATED_CHARACTER_MATRIX ===",
  970. "matrix",
  971. ].join("\n"),
  972. usage: ZERO_USAGE,
  973. });
  974. try {
  975. await agent.writeChapter({
  976. book: {
  977. id: "writer-book",
  978. title: "Writer Book",
  979. platform: "other",
  980. genre: "other",
  981. status: "active",
  982. targetChapters: 20,
  983. chapterWordCount: 2200,
  984. language: "en",
  985. createdAt: "2026-03-26T00:00:00.000Z",
  986. updatedAt: "2026-03-26T00:00:00.000Z",
  987. },
  988. bookDir,
  989. chapterNumber: 4,
  990. chapterBrief: {
  991. chapter: 4,
  992. goal: "Push Mara back toward the archive ledger.",
  993. chapterType: "confrontation",
  994. isGoldenOpening: false,
  995. beatOutline: [{ phase: "opening" as const, instruction: "Open with tension at the archive." }],
  996. hookPlan: [{ hookId: "ledger-fragment", movement: "advance" as const, targetEffect: "Mara is forced to confront the ledger." }],
  997. propsAndSetting: ["archive"],
  998. },
  999. contextPackage: {
  1000. chapter: 4,
  1001. selectedContext: [
  1002. {
  1003. source: "story/chapter_summaries.md#recent_titles",
  1004. reason: "Avoid repeated ledger titles.",
  1005. excerpt: "1: Ledger in Rain | 2: Ledger at Dusk | 3: Harbor Ledger",
  1006. },
  1007. {
  1008. source: "story/chapter_summaries.md#recent_mood_type_trail",
  1009. reason: "Track recent emotional and chapter-type cadence.",
  1010. excerpt: "1: tight / investigation | 2: tight / investigation | 3: tight / investigation",
  1011. },
  1012. {
  1013. source: "story/parent_canon.md",
  1014. reason: "Preserve parent canon constraints.",
  1015. excerpt: "The mentor does not learn about the archive fire until volume two.",
  1016. },
  1017. {
  1018. source: "story/fanfic_canon.md",
  1019. reason: "Preserve extracted fanfic canon constraints.",
  1020. excerpt: "Mara may diverge from the archive route, but the oath debt logic must stay intact.",
  1021. },
  1022. ],
  1023. },
  1024. ruleStack: {
  1025. layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
  1026. sections: {
  1027. hard: ["current_state"],
  1028. soft: ["current_focus"],
  1029. diagnostic: ["continuity_audit"],
  1030. },
  1031. overrideEdges: [],
  1032. activeOverrides: [],
  1033. },
  1034. lengthSpec: buildLengthSpec(2200, "en"),
  1035. });
  1036. const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
  1037. expect(creativePrompt).toContain("## Recent Title History");
  1038. expect(creativePrompt).toContain("Ledger in Rain");
  1039. expect(creativePrompt).toContain("## Recent Mood / Chapter Type Trail");
  1040. expect(creativePrompt).toContain("tight / investigation");
  1041. expect(creativePrompt).toContain("## Canon Evidence");
  1042. expect(creativePrompt).toContain("archive fire until volume two");
  1043. expect(creativePrompt).toContain("oath debt logic must stay intact");
  1044. } finally {
  1045. await rm(root, { recursive: true, force: true });
  1046. }
  1047. });
  1048. it("sanitizes governed control inputs so raw hook ids and control headings do not enter the creative prompt", async () => {
  1049. const root = await mkdtemp(join(tmpdir(), "inkos-writer-hook-agenda-test-"));
  1050. const bookDir = join(root, "book");
  1051. const storyDir = join(bookDir, "story");
  1052. await mkdir(storyDir, { recursive: true });
  1053. await Promise.all([
  1054. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Registry seals still matter.\n", "utf-8"),
  1055. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nPush Mara back toward the archive ledger.\n", "utf-8"),
  1056. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
  1057. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
  1058. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- ledger-fragment\n", "utf-8"),
  1059. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  1060. ]);
  1061. const agent = new WriterAgent({
  1062. client: {
  1063. provider: "openai",
  1064. apiFormat: "chat",
  1065. stream: false,
  1066. defaults: {
  1067. temperature: 0.7,
  1068. maxTokens: 4096,
  1069. thinkingBudget: 0, maxTokensCap: null,
  1070. extra: {},
  1071. },
  1072. },
  1073. model: "test-model",
  1074. projectRoot: root,
  1075. });
  1076. const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  1077. .mockResolvedValueOnce({
  1078. content: [
  1079. "=== CHAPTER_TITLE ===",
  1080. "Archive Pressure",
  1081. "",
  1082. "=== CHAPTER_CONTENT ===",
  1083. "Mara corners Taryn beside the archive ledger.",
  1084. "",
  1085. "=== PRE_WRITE_CHECK ===",
  1086. "- ok",
  1087. ].join("\n"),
  1088. usage: ZERO_USAGE,
  1089. })
  1090. .mockResolvedValueOnce({
  1091. content: "=== OBSERVATIONS ===\n- observed",
  1092. usage: ZERO_USAGE,
  1093. })
  1094. .mockResolvedValueOnce({
  1095. content: [
  1096. "=== POST_SETTLEMENT ===",
  1097. "- ledger-fragment advanced",
  1098. "",
  1099. "=== UPDATED_STATE ===",
  1100. "state",
  1101. "",
  1102. "=== UPDATED_HOOKS ===",
  1103. "hooks",
  1104. "",
  1105. "=== CHAPTER_SUMMARY ===",
  1106. "| 4 | Archive Pressure | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
  1107. "",
  1108. "=== UPDATED_SUBPLOTS ===",
  1109. "subplots",
  1110. "",
  1111. "=== UPDATED_EMOTIONAL_ARCS ===",
  1112. "arcs",
  1113. "",
  1114. "=== UPDATED_CHARACTER_MATRIX ===",
  1115. "matrix",
  1116. ].join("\n"),
  1117. usage: ZERO_USAGE,
  1118. });
  1119. try {
  1120. await agent.writeChapter({
  1121. book: {
  1122. id: "writer-book",
  1123. title: "Writer Book",
  1124. platform: "other",
  1125. genre: "other",
  1126. status: "active",
  1127. targetChapters: 20,
  1128. chapterWordCount: 2200,
  1129. language: "en",
  1130. createdAt: "2026-03-26T00:00:00.000Z",
  1131. updatedAt: "2026-03-26T00:00:00.000Z",
  1132. },
  1133. bookDir,
  1134. chapterNumber: 4,
  1135. chapterBrief: {
  1136. chapter: 4,
  1137. goal: "Push Mara back toward the archive ledger.",
  1138. chapterType: "confrontation",
  1139. isGoldenOpening: false,
  1140. beatOutline: [{ phase: "opening" as const, instruction: "本章要做的是推进 ledger-fragment tension at the archive." }],
  1141. hookPlan: [
  1142. { hookId: "mentor-oath", movement: "advance" as const, targetEffect: "Advance the oath debt with H001 ledger-fragment callback." },
  1143. { hookId: "ledger-fragment", movement: "partial-payoff" as const, targetEffect: "Partially resolve stale-ledger." },
  1144. ],
  1145. propsAndSetting: ["archive"],
  1146. },
  1147. contextPackage: {
  1148. chapter: 4,
  1149. selectedContext: [
  1150. {
  1151. source: "story/pending_hooks.md#mentor-oath",
  1152. reason: "Carry the unresolved oath line.",
  1153. excerpt: "relationship | open | old oath debt",
  1154. },
  1155. ],
  1156. },
  1157. ruleStack: {
  1158. layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
  1159. sections: {
  1160. hard: ["current_state"],
  1161. soft: ["current_focus"],
  1162. diagnostic: ["continuity_audit"],
  1163. },
  1164. overrideEdges: [],
  1165. activeOverrides: [],
  1166. },
  1167. lengthSpec: buildLengthSpec(2200, "en"),
  1168. });
  1169. const systemPrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[0]?.content ?? "";
  1170. const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
  1171. expect(systemPrompt).not.toContain("Hook-A / Hook-B");
  1172. expect(systemPrompt).toContain("真实 hook_id");
  1173. // Enum/identifier fields (hookId, movement, chapterType) are NOT sanitized —
  1174. // the writer needs them to understand which hook to move and what chapter type
  1175. // to write. Free-text fields (goal, instruction, targetEffect) ARE sanitized.
  1176. expect(creativePrompt).not.toContain("## Hook Agenda");
  1177. // hookIds appear verbatim in Hook Plan (identifiers, not free text)
  1178. expect(creativePrompt).toContain("mentor-oath");
  1179. expect(creativePrompt).toContain("ledger-fragment");
  1180. // But slug references INSIDE free text (targetEffect) are sanitized
  1181. expect(creativePrompt).not.toContain("stale-ledger");
  1182. expect(creativePrompt).not.toContain("H001");
  1183. expect(creativePrompt).not.toContain("本章要做的");
  1184. // The goal text should survive sanitization
  1185. expect(creativePrompt).toContain("Push Mara back toward the archive ledger.");
  1186. } finally {
  1187. await rm(root, { recursive: true, force: true });
  1188. }
  1189. });
  1190. });