writer.test.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065
  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. await mkdir(storyDir, { recursive: true });
  237. await Promise.all([
  238. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  239. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\n", "utf-8"),
  240. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  241. writeFile(join(storyDir, "current_state.md"), [
  242. "# Current State",
  243. "",
  244. "| Field | Value |",
  245. "| --- | --- |",
  246. "| Current Chapter | 2 |",
  247. "| Current Goal | Find the vanished mentor |",
  248. "| Current Conflict | Guild pressure keeps colliding with the debt trail |",
  249. "",
  250. ].join("\n"), "utf-8"),
  251. writeFile(join(storyDir, "pending_hooks.md"), [
  252. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  253. "| --- | --- | --- | --- | --- | --- | --- |",
  254. "| mentor-debt | 1 | relationship | open | 2 | 6 | Still unresolved |",
  255. "",
  256. ].join("\n"), "utf-8"),
  257. writeFile(join(storyDir, "chapter_summaries.md"), [
  258. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  259. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  260. "| 2 | Old Ledger | Lin Yue | Lin Yue finds the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |",
  261. "",
  262. ].join("\n"), "utf-8"),
  263. ]);
  264. const agent = new WriterAgent({
  265. client: {
  266. provider: "openai",
  267. apiFormat: "chat",
  268. stream: false,
  269. defaults: {
  270. temperature: 0.7,
  271. maxTokens: 4096,
  272. thinkingBudget: 0, maxTokensCap: null,
  273. extra: {},
  274. },
  275. },
  276. model: "test-model",
  277. projectRoot: root,
  278. });
  279. vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  280. .mockResolvedValueOnce({
  281. content: [
  282. "=== CHAPTER_TITLE ===",
  283. "River Ledger",
  284. "",
  285. "=== CHAPTER_CONTENT ===",
  286. "Lin Yue follows the debt into the river-port ledger.",
  287. "",
  288. "=== PRE_WRITE_CHECK ===",
  289. "- ok",
  290. ].join("\n"),
  291. usage: ZERO_USAGE,
  292. })
  293. .mockResolvedValueOnce({
  294. content: "=== OBSERVATIONS ===\n- observed",
  295. usage: ZERO_USAGE,
  296. })
  297. .mockResolvedValueOnce({
  298. content: [
  299. "=== POST_SETTLEMENT ===",
  300. "- mentor-debt advanced",
  301. "",
  302. "=== RUNTIME_STATE_DELTA ===",
  303. "```json",
  304. JSON.stringify({
  305. chapter: 3,
  306. currentStatePatch: {
  307. currentGoal: "Trace the debt through the river-port ledger.",
  308. currentConflict: "Guild pressure keeps colliding with the debt trail.",
  309. },
  310. hookOps: {
  311. upsert: [
  312. {
  313. hookId: "mentor-debt",
  314. startChapter: 1,
  315. type: "relationship",
  316. status: "progressing",
  317. lastAdvancedChapter: 3,
  318. expectedPayoff: "Reveal the debt.",
  319. notes: "The ledger clue sharpens the line.",
  320. },
  321. ],
  322. resolve: [],
  323. defer: [],
  324. },
  325. chapterSummary: {
  326. chapter: 3,
  327. title: "River Ledger",
  328. characters: "Lin Yue",
  329. events: "Lin Yue follows the debt into the river-port ledger.",
  330. stateChanges: "The debt line sharpens.",
  331. hookActivity: "mentor-debt advanced",
  332. mood: "tense",
  333. chapterType: "investigation",
  334. },
  335. notes: [],
  336. }, null, 2),
  337. "```",
  338. ].join("\n"),
  339. usage: ZERO_USAGE,
  340. });
  341. try {
  342. const output = await agent.writeChapter({
  343. book: {
  344. id: "writer-book",
  345. title: "Writer Book",
  346. platform: "tomato",
  347. genre: "xuanhuan",
  348. status: "active",
  349. targetChapters: 20,
  350. chapterWordCount: 2200,
  351. language: "en",
  352. createdAt: "2026-03-25T00:00:00.000Z",
  353. updatedAt: "2026-03-25T00:00:00.000Z",
  354. },
  355. bookDir,
  356. chapterNumber: 3,
  357. lengthSpec: buildLengthSpec(2200, "en"),
  358. });
  359. expect(output.runtimeStateDelta?.chapter).toBe(3);
  360. expect(output.runtimeStateSnapshot?.manifest.lastAppliedChapter).toBe(3);
  361. expect(output.updatedState).toContain("Trace the debt through the river-port ledger.");
  362. expect(output.updatedHooks).toContain("mentor-debt");
  363. expect(output.updatedChapterSummaries).toContain("River Ledger");
  364. expect(output.chapterSummary).toContain("| 3 | River Ledger |");
  365. } finally {
  366. await rm(root, { recursive: true, force: true });
  367. }
  368. });
  369. it("returns the arbiter-resolved delta instead of raw new-hook candidates", async () => {
  370. const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-"));
  371. const bookDir = join(root, "book");
  372. const storyDir = join(bookDir, "story");
  373. await mkdir(storyDir, { recursive: true });
  374. await Promise.all([
  375. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Anonymous messages keep steering the debt trail.\n", "utf-8"),
  376. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nThe anonymous source widens from route to address.\n", "utf-8"),
  377. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  378. writeFile(join(storyDir, "current_state.md"), [
  379. "# Current State",
  380. "",
  381. "| Field | Value |",
  382. "| --- | --- |",
  383. "| Current Chapter | 2 |",
  384. "| Current Goal | Find who fed the route to the anonymous source |",
  385. "",
  386. ].join("\n"), "utf-8"),
  387. writeFile(join(storyDir, "pending_hooks.md"), [
  388. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  389. "| --- | --- | --- | --- | --- | --- | --- |",
  390. "| 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. |",
  391. "",
  392. ].join("\n"), "utf-8"),
  393. writeFile(join(storyDir, "chapter_summaries.md"), [
  394. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  395. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  396. "| 2 | Route Leak | Lin Yue | An anonymous source already knew the route | Suspicion sharpens | anonymous-source-scope advanced | tense | mainline |",
  397. "",
  398. ].join("\n"), "utf-8"),
  399. ]);
  400. const agent = new WriterAgent({
  401. client: {
  402. provider: "openai",
  403. apiFormat: "chat",
  404. stream: false,
  405. defaults: {
  406. temperature: 0.7,
  407. maxTokens: 4096,
  408. thinkingBudget: 0, maxTokensCap: null,
  409. extra: {},
  410. },
  411. },
  412. model: "test-model",
  413. projectRoot: root,
  414. });
  415. vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  416. .mockResolvedValueOnce({
  417. content: [
  418. "=== CHAPTER_TITLE ===",
  419. "Address Leak",
  420. "",
  421. "=== CHAPTER_CONTENT ===",
  422. "Lin Yue realizes the anonymous source knew the address, not just the route.",
  423. "",
  424. "=== PRE_WRITE_CHECK ===",
  425. "- ok",
  426. ].join("\n"),
  427. usage: ZERO_USAGE,
  428. })
  429. .mockResolvedValueOnce({
  430. content: "=== OBSERVATIONS ===\n- observed",
  431. usage: ZERO_USAGE,
  432. })
  433. .mockResolvedValueOnce({
  434. content: [
  435. "=== POST_SETTLEMENT ===",
  436. "- source scope widens",
  437. "",
  438. "=== RUNTIME_STATE_DELTA ===",
  439. "```json",
  440. JSON.stringify({
  441. chapter: 3,
  442. hookOps: {
  443. upsert: [],
  444. mention: [],
  445. resolve: [],
  446. defer: [],
  447. },
  448. newHookCandidates: [
  449. {
  450. type: "source-risk",
  451. expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
  452. notes: "This chapter adds the address angle to the anonymous source question.",
  453. },
  454. ],
  455. chapterSummary: {
  456. chapter: 3,
  457. title: "Address Leak",
  458. characters: "Lin Yue",
  459. events: "Lin Yue realizes the anonymous source knew the address.",
  460. stateChanges: "The source knowledge question widens.",
  461. hookActivity: "anonymous-source-scope advanced",
  462. mood: "tight",
  463. chapterType: "investigation",
  464. },
  465. notes: [],
  466. }, null, 2),
  467. "```",
  468. ].join("\n"),
  469. usage: ZERO_USAGE,
  470. });
  471. try {
  472. const output = await agent.writeChapter({
  473. book: {
  474. id: "writer-book",
  475. title: "Writer Book",
  476. platform: "tomato",
  477. genre: "other",
  478. status: "active",
  479. targetChapters: 20,
  480. chapterWordCount: 2200,
  481. language: "en",
  482. createdAt: "2026-03-27T00:00:00.000Z",
  483. updatedAt: "2026-03-27T00:00:00.000Z",
  484. },
  485. bookDir,
  486. chapterNumber: 3,
  487. lengthSpec: buildLengthSpec(2200, "en"),
  488. });
  489. expect(output.runtimeStateDelta?.hookOps.upsert).toEqual([
  490. expect.objectContaining({
  491. hookId: "anonymous-source-scope",
  492. lastAdvancedChapter: 3,
  493. }),
  494. ]);
  495. expect(output.runtimeStateDelta?.newHookCandidates).toEqual([]);
  496. expect(output.updatedHooks).toContain("anonymous-source-scope");
  497. expect(output.updatedHooks).toContain("| anonymous-source-scope | 1 | source-risk | progressing | 3 |");
  498. } finally {
  499. await rm(root, { recursive: true, force: true });
  500. }
  501. });
  502. it("logs localized phase messages for Chinese books", async () => {
  503. const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
  504. const bookDir = join(root, "book");
  505. const storyDir = join(bookDir, "story");
  506. const { logger, infos } = createCaptureLogger();
  507. await mkdir(storyDir, { recursive: true });
  508. await Promise.all([
  509. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n", "utf-8"),
  510. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n", "utf-8"),
  511. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n", "utf-8"),
  512. writeFile(join(storyDir, "current_state.md"), "# 当前状态\n", "utf-8"),
  513. writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n", "utf-8"),
  514. writeFile(join(storyDir, "chapter_summaries.md"), "# 章节摘要\n", "utf-8"),
  515. writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
  516. writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
  517. writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
  518. ]);
  519. const agent = new WriterAgent({
  520. client: {
  521. provider: "openai",
  522. apiFormat: "chat",
  523. stream: false,
  524. defaults: {
  525. temperature: 0.7,
  526. maxTokens: 4096,
  527. thinkingBudget: 0, maxTokensCap: null,
  528. extra: {},
  529. },
  530. },
  531. model: "test-model",
  532. projectRoot: root,
  533. logger,
  534. });
  535. vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  536. .mockResolvedValueOnce({
  537. content: [
  538. "=== CHAPTER_TITLE ===",
  539. "试炼前夜",
  540. "",
  541. "=== CHAPTER_CONTENT ===",
  542. "林越在破庙外停住脚步,想起师门旧债。",
  543. "",
  544. "=== PRE_WRITE_CHECK ===",
  545. "- ok",
  546. ].join("\n"),
  547. usage: ZERO_USAGE,
  548. })
  549. .mockResolvedValueOnce({
  550. content: "=== OBSERVATIONS ===\n- observed",
  551. usage: ZERO_USAGE,
  552. })
  553. .mockResolvedValueOnce({
  554. content: [
  555. "=== POST_SETTLEMENT ===",
  556. "| 伏笔变动 | mentor-oath 推进 | 同步更新伏笔池 |",
  557. "",
  558. "=== UPDATED_STATE ===",
  559. "状态卡",
  560. "",
  561. "=== UPDATED_HOOKS ===",
  562. "伏笔池",
  563. "",
  564. "=== CHAPTER_SUMMARY ===",
  565. "| 1 | 试炼前夜 | 林越 | 林越记起师门旧债 | 决心加深 | mentor-oath advanced | tense | setup |",
  566. "",
  567. "=== UPDATED_SUBPLOTS ===",
  568. "支线板",
  569. "",
  570. "=== UPDATED_EMOTIONAL_ARCS ===",
  571. "情感弧线",
  572. "",
  573. "=== UPDATED_CHARACTER_MATRIX ===",
  574. "角色矩阵",
  575. ].join("\n"),
  576. usage: ZERO_USAGE,
  577. });
  578. try {
  579. await agent.writeChapter({
  580. book: {
  581. id: "writer-book",
  582. title: "Writer Book",
  583. platform: "tomato",
  584. genre: "xuanhuan",
  585. status: "active",
  586. targetChapters: 120,
  587. chapterWordCount: 2200,
  588. language: "zh",
  589. createdAt: "2026-03-23T00:00:00.000Z",
  590. updatedAt: "2026-03-23T00:00:00.000Z",
  591. },
  592. bookDir,
  593. chapterNumber: 1,
  594. lengthSpec: buildLengthSpec(220, "zh"),
  595. });
  596. expect(infos).toEqual(expect.arrayContaining([
  597. "阶段 1:创作正文(第1章)",
  598. "阶段 2:状态结算(第1章,18字)",
  599. "阶段 2a:提取第1章事实",
  600. "阶段 2b:把观察结果回写到真相文件",
  601. ]));
  602. } finally {
  603. await rm(root, { recursive: true, force: true });
  604. }
  605. });
  606. it("injects an English variance brief into governed creative prompts", async () => {
  607. const root = await mkdtemp(join(tmpdir(), "inkos-writer-variance-test-"));
  608. const bookDir = join(root, "book");
  609. const storyDir = join(bookDir, "story");
  610. const chaptersDir = join(bookDir, "chapters");
  611. await mkdir(storyDir, { recursive: true });
  612. await mkdir(chaptersDir, { recursive: true });
  613. await Promise.all([
  614. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The registry seals matter.\n", "utf-8"),
  615. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nForce Mara back toward the ledger trail.\n", "utf-8"),
  616. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
  617. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
  618. writeFile(join(storyDir, "pending_hooks.md"), [
  619. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  620. "| --- | --- | --- | --- | --- | --- | --- |",
  621. "| ledger-fragment | 1 | mystery | open | 3 | 8 | Mara still hides the ledger fragment |",
  622. ].join("\n"), "utf-8"),
  623. writeFile(join(storyDir, "chapter_summaries.md"), [
  624. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  625. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  626. "| 1 | Ledger | Mara | Mara hides the ledger | pressure tightens | none | tense | investigation |",
  627. "| 2 | Ash | Mara,Taryn | Ash falls over the archive | pressure tightens | none | tense | investigation |",
  628. "| 3 | Harbor | Mara,Taryn | The gate stays under watch | pressure tightens | none | tense | investigation |",
  629. ].join("\n"), "utf-8"),
  630. 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"),
  631. 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"),
  632. 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"),
  633. ]);
  634. const agent = new WriterAgent({
  635. client: {
  636. provider: "openai",
  637. apiFormat: "chat",
  638. stream: false,
  639. defaults: {
  640. temperature: 0.7,
  641. maxTokens: 4096,
  642. thinkingBudget: 0, maxTokensCap: null,
  643. extra: {},
  644. },
  645. },
  646. model: "test-model",
  647. projectRoot: root,
  648. });
  649. const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  650. .mockResolvedValueOnce({
  651. content: [
  652. "=== CHAPTER_TITLE ===",
  653. "Pressure Ledger",
  654. "",
  655. "=== CHAPTER_CONTENT ===",
  656. "Mara forced Taryn to answer beside the archive window.",
  657. "",
  658. "=== PRE_WRITE_CHECK ===",
  659. "- ok",
  660. ].join("\n"),
  661. usage: ZERO_USAGE,
  662. })
  663. .mockResolvedValueOnce({
  664. content: "=== OBSERVATIONS ===\n- observed",
  665. usage: ZERO_USAGE,
  666. })
  667. .mockResolvedValueOnce({
  668. content: [
  669. "=== POST_SETTLEMENT ===",
  670. "- ledger-fragment advanced",
  671. "",
  672. "=== UPDATED_STATE ===",
  673. "state",
  674. "",
  675. "=== UPDATED_HOOKS ===",
  676. "hooks",
  677. "",
  678. "=== CHAPTER_SUMMARY ===",
  679. "| 4 | Pressure Ledger | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
  680. "",
  681. "=== UPDATED_SUBPLOTS ===",
  682. "subplots",
  683. "",
  684. "=== UPDATED_EMOTIONAL_ARCS ===",
  685. "arcs",
  686. "",
  687. "=== UPDATED_CHARACTER_MATRIX ===",
  688. "matrix",
  689. ].join("\n"),
  690. usage: ZERO_USAGE,
  691. });
  692. try {
  693. await agent.writeChapter({
  694. book: {
  695. id: "writer-book",
  696. title: "Writer Book",
  697. platform: "other",
  698. genre: "other",
  699. status: "active",
  700. targetChapters: 20,
  701. chapterWordCount: 2200,
  702. language: "en",
  703. createdAt: "2026-03-26T00:00:00.000Z",
  704. updatedAt: "2026-03-26T00:00:00.000Z",
  705. },
  706. bookDir,
  707. chapterNumber: 4,
  708. chapterIntent: "# Chapter Intent\n\n## Goal\nForce Mara back toward the ledger trail.\n",
  709. contextPackage: {
  710. chapter: 4,
  711. selectedContext: [
  712. {
  713. source: "story/chapter_summaries.md#3",
  714. reason: "Carry recent pressure into the next chapter.",
  715. excerpt: "The gate stays under watch.",
  716. },
  717. ],
  718. },
  719. ruleStack: {
  720. layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
  721. sections: {
  722. hard: ["current_state"],
  723. soft: ["current_focus"],
  724. diagnostic: ["continuity_audit"],
  725. },
  726. overrideEdges: [],
  727. activeOverrides: [],
  728. },
  729. lengthSpec: buildLengthSpec(2200, "en"),
  730. });
  731. const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
  732. expect(creativePrompt).toContain("## English Variance Brief");
  733. expect(creativePrompt).toContain("High-frequency phrases");
  734. expect(creativePrompt).toContain("Scene obligation");
  735. } finally {
  736. await rm(root, { recursive: true, force: true });
  737. }
  738. });
  739. it("renders explicit title history, mood trail, and canon blocks in governed creative prompts", async () => {
  740. const root = await mkdtemp(join(tmpdir(), "inkos-writer-governed-evidence-test-"));
  741. const bookDir = join(root, "book");
  742. const storyDir = join(bookDir, "story");
  743. await mkdir(storyDir, { recursive: true });
  744. await Promise.all([
  745. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Registry seals still matter.\n", "utf-8"),
  746. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nPush Mara back toward the archive ledger.\n", "utf-8"),
  747. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
  748. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
  749. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- ledger-fragment\n", "utf-8"),
  750. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  751. ]);
  752. const agent = new WriterAgent({
  753. client: {
  754. provider: "openai",
  755. apiFormat: "chat",
  756. stream: false,
  757. defaults: {
  758. temperature: 0.7,
  759. maxTokens: 4096,
  760. thinkingBudget: 0, maxTokensCap: null,
  761. extra: {},
  762. },
  763. },
  764. model: "test-model",
  765. projectRoot: root,
  766. });
  767. const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  768. .mockResolvedValueOnce({
  769. content: [
  770. "=== CHAPTER_TITLE ===",
  771. "Archive Pressure",
  772. "",
  773. "=== CHAPTER_CONTENT ===",
  774. "Mara corners Taryn beside the archive ledger.",
  775. "",
  776. "=== PRE_WRITE_CHECK ===",
  777. "- ok",
  778. ].join("\n"),
  779. usage: ZERO_USAGE,
  780. })
  781. .mockResolvedValueOnce({
  782. content: "=== OBSERVATIONS ===\n- observed",
  783. usage: ZERO_USAGE,
  784. })
  785. .mockResolvedValueOnce({
  786. content: [
  787. "=== POST_SETTLEMENT ===",
  788. "- ledger-fragment advanced",
  789. "",
  790. "=== UPDATED_STATE ===",
  791. "state",
  792. "",
  793. "=== UPDATED_HOOKS ===",
  794. "hooks",
  795. "",
  796. "=== CHAPTER_SUMMARY ===",
  797. "| 4 | Archive Pressure | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
  798. "",
  799. "=== UPDATED_SUBPLOTS ===",
  800. "subplots",
  801. "",
  802. "=== UPDATED_EMOTIONAL_ARCS ===",
  803. "arcs",
  804. "",
  805. "=== UPDATED_CHARACTER_MATRIX ===",
  806. "matrix",
  807. ].join("\n"),
  808. usage: ZERO_USAGE,
  809. });
  810. try {
  811. await agent.writeChapter({
  812. book: {
  813. id: "writer-book",
  814. title: "Writer Book",
  815. platform: "other",
  816. genre: "other",
  817. status: "active",
  818. targetChapters: 20,
  819. chapterWordCount: 2200,
  820. language: "en",
  821. createdAt: "2026-03-26T00:00:00.000Z",
  822. updatedAt: "2026-03-26T00:00:00.000Z",
  823. },
  824. bookDir,
  825. chapterNumber: 4,
  826. chapterIntent: "# Chapter Intent\n\n## Goal\nPush Mara back toward the archive ledger.\n",
  827. contextPackage: {
  828. chapter: 4,
  829. selectedContext: [
  830. {
  831. source: "story/chapter_summaries.md#recent_titles",
  832. reason: "Avoid repeated ledger titles.",
  833. excerpt: "1: Ledger in Rain | 2: Ledger at Dusk | 3: Harbor Ledger",
  834. },
  835. {
  836. source: "story/chapter_summaries.md#recent_mood_type_trail",
  837. reason: "Track recent emotional and chapter-type cadence.",
  838. excerpt: "1: tight / investigation | 2: tight / investigation | 3: tight / investigation",
  839. },
  840. {
  841. source: "story/parent_canon.md",
  842. reason: "Preserve parent canon constraints.",
  843. excerpt: "The mentor does not learn about the archive fire until volume two.",
  844. },
  845. {
  846. source: "story/fanfic_canon.md",
  847. reason: "Preserve extracted fanfic canon constraints.",
  848. excerpt: "Mara may diverge from the archive route, but the oath debt logic must stay intact.",
  849. },
  850. ],
  851. },
  852. ruleStack: {
  853. layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
  854. sections: {
  855. hard: ["current_state"],
  856. soft: ["current_focus"],
  857. diagnostic: ["continuity_audit"],
  858. },
  859. overrideEdges: [],
  860. activeOverrides: [],
  861. },
  862. lengthSpec: buildLengthSpec(2200, "en"),
  863. });
  864. const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
  865. expect(creativePrompt).toContain("## Recent Title History");
  866. expect(creativePrompt).toContain("Ledger in Rain");
  867. expect(creativePrompt).toContain("## Recent Mood / Chapter Type Trail");
  868. expect(creativePrompt).toContain("tight / investigation");
  869. expect(creativePrompt).toContain("## Canon Evidence");
  870. expect(creativePrompt).toContain("archive fire until volume two");
  871. expect(creativePrompt).toContain("oath debt logic must stay intact");
  872. } finally {
  873. await rm(root, { recursive: true, force: true });
  874. }
  875. });
  876. it("renders an explicit hook agenda block and removes placeholder hook ids from the governed write contract", async () => {
  877. const root = await mkdtemp(join(tmpdir(), "inkos-writer-hook-agenda-test-"));
  878. const bookDir = join(root, "book");
  879. const storyDir = join(bookDir, "story");
  880. await mkdir(storyDir, { recursive: true });
  881. await Promise.all([
  882. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Registry seals still matter.\n", "utf-8"),
  883. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nPush Mara back toward the archive ledger.\n", "utf-8"),
  884. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
  885. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
  886. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- ledger-fragment\n", "utf-8"),
  887. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  888. ]);
  889. const agent = new WriterAgent({
  890. client: {
  891. provider: "openai",
  892. apiFormat: "chat",
  893. stream: false,
  894. defaults: {
  895. temperature: 0.7,
  896. maxTokens: 4096,
  897. thinkingBudget: 0, maxTokensCap: null,
  898. extra: {},
  899. },
  900. },
  901. model: "test-model",
  902. projectRoot: root,
  903. });
  904. const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
  905. .mockResolvedValueOnce({
  906. content: [
  907. "=== CHAPTER_TITLE ===",
  908. "Archive Pressure",
  909. "",
  910. "=== CHAPTER_CONTENT ===",
  911. "Mara corners Taryn beside the archive ledger.",
  912. "",
  913. "=== PRE_WRITE_CHECK ===",
  914. "- ok",
  915. ].join("\n"),
  916. usage: ZERO_USAGE,
  917. })
  918. .mockResolvedValueOnce({
  919. content: "=== OBSERVATIONS ===\n- observed",
  920. usage: ZERO_USAGE,
  921. })
  922. .mockResolvedValueOnce({
  923. content: [
  924. "=== POST_SETTLEMENT ===",
  925. "- ledger-fragment advanced",
  926. "",
  927. "=== UPDATED_STATE ===",
  928. "state",
  929. "",
  930. "=== UPDATED_HOOKS ===",
  931. "hooks",
  932. "",
  933. "=== CHAPTER_SUMMARY ===",
  934. "| 4 | Archive Pressure | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
  935. "",
  936. "=== UPDATED_SUBPLOTS ===",
  937. "subplots",
  938. "",
  939. "=== UPDATED_EMOTIONAL_ARCS ===",
  940. "arcs",
  941. "",
  942. "=== UPDATED_CHARACTER_MATRIX ===",
  943. "matrix",
  944. ].join("\n"),
  945. usage: ZERO_USAGE,
  946. });
  947. try {
  948. await agent.writeChapter({
  949. book: {
  950. id: "writer-book",
  951. title: "Writer Book",
  952. platform: "other",
  953. genre: "other",
  954. status: "active",
  955. targetChapters: 20,
  956. chapterWordCount: 2200,
  957. language: "en",
  958. createdAt: "2026-03-26T00:00:00.000Z",
  959. updatedAt: "2026-03-26T00:00:00.000Z",
  960. },
  961. bookDir,
  962. chapterNumber: 4,
  963. chapterIntent: [
  964. "# Chapter Intent",
  965. "",
  966. "## Goal",
  967. "Push Mara back toward the archive ledger.",
  968. "",
  969. "## Hook Agenda",
  970. "### Must Advance",
  971. "- mentor-oath",
  972. "",
  973. "### Eligible Resolve",
  974. "- ledger-fragment",
  975. "",
  976. "### Stale Debt",
  977. "- stale-ledger",
  978. "",
  979. "### Avoid New Hook Families",
  980. "- relationship",
  981. ].join("\n"),
  982. contextPackage: {
  983. chapter: 4,
  984. selectedContext: [
  985. {
  986. source: "story/pending_hooks.md#mentor-oath",
  987. reason: "Carry the unresolved oath line.",
  988. excerpt: "relationship | open | old oath debt",
  989. },
  990. ],
  991. },
  992. ruleStack: {
  993. layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
  994. sections: {
  995. hard: ["current_state"],
  996. soft: ["current_focus"],
  997. diagnostic: ["continuity_audit"],
  998. },
  999. overrideEdges: [],
  1000. activeOverrides: [],
  1001. },
  1002. lengthSpec: buildLengthSpec(2200, "en"),
  1003. });
  1004. const systemPrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[0]?.content ?? "";
  1005. const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
  1006. expect(systemPrompt).not.toContain("Hook-A / Hook-B");
  1007. expect(systemPrompt).toContain("真实 hook_id");
  1008. expect(creativePrompt).toContain("## Explicit Hook Agenda");
  1009. expect(creativePrompt).toContain("mentor-oath");
  1010. expect(creativePrompt).toContain("ledger-fragment");
  1011. expect(creativePrompt).toContain("stale-ledger");
  1012. expect(creativePrompt).toContain("relationship");
  1013. } finally {
  1014. await rm(root, { recursive: true, force: true });
  1015. }
  1016. });
  1017. });