planner.test.ts 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
  3. import { tmpdir } from "node:os";
  4. import { join } from "node:path";
  5. import type { BookConfig } from "../models/book.js";
  6. import { PlannerAgent } from "../agents/planner.js";
  7. describe("PlannerAgent", () => {
  8. let root: string;
  9. let bookDir: string;
  10. let storyDir: string;
  11. let book: BookConfig;
  12. beforeEach(async () => {
  13. root = await mkdtemp(join(tmpdir(), "inkos-planner-test-"));
  14. bookDir = join(root, "books", "planner-book");
  15. storyDir = join(bookDir, "story");
  16. await mkdir(join(storyDir, "runtime"), { recursive: true });
  17. book = {
  18. id: "planner-book",
  19. title: "Planner Book",
  20. platform: "tomato",
  21. genre: "xuanhuan",
  22. status: "active",
  23. targetChapters: 20,
  24. chapterWordCount: 3000,
  25. createdAt: "2026-03-22T00:00:00.000Z",
  26. updatedAt: "2026-03-22T00:00:00.000Z",
  27. };
  28. await Promise.all([
  29. writeFile(
  30. join(storyDir, "author_intent.md"),
  31. "# Author Intent\n\nKeep the book emotionally centered on the mentor-student bond.\n",
  32. "utf-8",
  33. ),
  34. writeFile(
  35. join(storyDir, "current_focus.md"),
  36. "# Current Focus\n\nBring the focus back to the mentor conflict before opening new subplots.\n",
  37. "utf-8",
  38. ),
  39. writeFile(
  40. join(storyDir, "story_bible.md"),
  41. "# Story Bible\n\n- The jade seal cannot be destroyed.\n",
  42. "utf-8",
  43. ),
  44. writeFile(
  45. join(storyDir, "volume_outline.md"),
  46. "# Volume Outline\n\n## Chapter 3\nTrack the merchant guild's escape route.\n",
  47. "utf-8",
  48. ),
  49. writeFile(
  50. join(storyDir, "book_rules.md"),
  51. "---\nprohibitions:\n - Do not reveal the mastermind\n---\n\n# Book Rules\n",
  52. "utf-8",
  53. ),
  54. writeFile(
  55. join(storyDir, "current_state.md"),
  56. "# Current State\n\n- Lin Yue still hides the broken oath token.\n",
  57. "utf-8",
  58. ),
  59. writeFile(
  60. join(storyDir, "pending_hooks.md"),
  61. "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n",
  62. "utf-8",
  63. ),
  64. writeFile(
  65. join(storyDir, "chapter_summaries.md"),
  66. "# Chapter Summaries\n\n| 2 | Trial fallout | Mentor left without explanation |\n",
  67. "utf-8",
  68. ),
  69. ]);
  70. });
  71. afterEach(async () => {
  72. await rm(root, { recursive: true, force: true });
  73. });
  74. it("uses current focus as the chapter goal when no outline node is available", async () => {
  75. await writeFile(
  76. join(storyDir, "volume_outline.md"),
  77. "# Volume Outline\n",
  78. "utf-8",
  79. );
  80. const planner = new PlannerAgent({
  81. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  82. model: "test-model",
  83. projectRoot: root,
  84. bookId: book.id,
  85. });
  86. const result = await planner.planChapter({
  87. book,
  88. bookDir,
  89. chapterNumber: 3,
  90. });
  91. expect(result.intent.goal).toContain("mentor conflict");
  92. await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict");
  93. });
  94. it("prefers a matched outline node over ordinary current focus text", async () => {
  95. await Promise.all([
  96. writeFile(
  97. join(storyDir, "current_focus.md"),
  98. [
  99. "# Current Focus",
  100. "",
  101. "## Active Focus",
  102. "",
  103. "Pull the next chapter back toward the mentor fallout instead of the guild route.",
  104. "",
  105. ].join("\n"),
  106. "utf-8",
  107. ),
  108. writeFile(
  109. join(storyDir, "volume_outline.md"),
  110. [
  111. "# Volume Outline",
  112. "",
  113. "## Chapter 3",
  114. "Track the merchant guild's escape route through the western canal.",
  115. "",
  116. ].join("\n"),
  117. "utf-8",
  118. ),
  119. ]);
  120. const planner = new PlannerAgent({
  121. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  122. model: "test-model",
  123. projectRoot: root,
  124. bookId: book.id,
  125. });
  126. const result = await planner.planChapter({
  127. book,
  128. bookDir,
  129. chapterNumber: 3,
  130. });
  131. expect(result.intent.outlineNode).toContain("merchant guild's escape route");
  132. expect(result.intent.goal).toContain("merchant guild's escape route");
  133. expect(result.intent.goal).not.toContain("mentor fallout");
  134. expect(result.intent.conflicts).toEqual([]);
  135. });
  136. it("lets explicit local override focus beat the matched outline node", async () => {
  137. await Promise.all([
  138. writeFile(
  139. join(storyDir, "current_focus.md"),
  140. [
  141. "# Current Focus",
  142. "",
  143. "## Active Focus",
  144. "",
  145. "Keep pressure on the guild route in the background.",
  146. "",
  147. "## Local Override",
  148. "",
  149. "Stay inside the mentor debt confrontation first and delay the canal pursuit by one chapter.",
  150. "",
  151. ].join("\n"),
  152. "utf-8",
  153. ),
  154. writeFile(
  155. join(storyDir, "volume_outline.md"),
  156. [
  157. "# Volume Outline",
  158. "",
  159. "## Chapter 3",
  160. "Track the merchant guild's escape route through the western canal.",
  161. "",
  162. ].join("\n"),
  163. "utf-8",
  164. ),
  165. ]);
  166. const planner = new PlannerAgent({
  167. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  168. model: "test-model",
  169. projectRoot: root,
  170. bookId: book.id,
  171. });
  172. const result = await planner.planChapter({
  173. book,
  174. bookDir,
  175. chapterNumber: 3,
  176. });
  177. expect(result.intent.outlineNode).toContain("merchant guild's escape route");
  178. expect(result.intent.goal).toContain("mentor debt confrontation");
  179. expect(result.intent.goal).not.toContain("merchant guild's escape route");
  180. expect(result.intent.conflicts).toEqual(expect.arrayContaining([
  181. expect.objectContaining({
  182. type: "outline_vs_current_focus",
  183. resolution: "allow explicit current focus override",
  184. }),
  185. ]));
  186. });
  187. it("keeps external context above both outline anchors and current focus", async () => {
  188. await Promise.all([
  189. writeFile(
  190. join(storyDir, "current_focus.md"),
  191. [
  192. "# Current Focus",
  193. "",
  194. "## Active Focus",
  195. "",
  196. "Pull the next chapter back toward the mentor fallout instead of the guild route.",
  197. "",
  198. ].join("\n"),
  199. "utf-8",
  200. ),
  201. writeFile(
  202. join(storyDir, "volume_outline.md"),
  203. [
  204. "# Volume Outline",
  205. "",
  206. "## Chapter 3",
  207. "Track the merchant guild's escape route through the western canal.",
  208. "",
  209. ].join("\n"),
  210. "utf-8",
  211. ),
  212. ]);
  213. const planner = new PlannerAgent({
  214. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  215. model: "test-model",
  216. projectRoot: root,
  217. bookId: book.id,
  218. });
  219. const result = await planner.planChapter({
  220. book,
  221. bookDir,
  222. chapterNumber: 3,
  223. externalContext: "Ignore the canal pursuit for now and force the next chapter into the mentor debt confrontation.",
  224. });
  225. expect(result.intent.goal).toContain("mentor debt confrontation");
  226. expect(result.intent.goal).not.toContain("merchant guild's escape route");
  227. expect(result.intent.conflicts).toEqual(expect.arrayContaining([
  228. expect.objectContaining({
  229. type: "outline_vs_request",
  230. resolution: "allow local outline deferral",
  231. }),
  232. ]));
  233. });
  234. it("emits structured directives when fallback planning, chapter type repetition, and title collapse stack up", async () => {
  235. book = {
  236. ...book,
  237. genre: "other",
  238. language: "en",
  239. };
  240. await Promise.all([
  241. writeFile(
  242. join(storyDir, "author_intent.md"),
  243. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  244. "utf-8",
  245. ),
  246. writeFile(
  247. join(storyDir, "current_focus.md"),
  248. [
  249. "# Current Focus",
  250. "",
  251. "## Active Focus",
  252. "",
  253. "(Describe what the next 1-3 chapters should prioritize.)",
  254. "",
  255. ].join("\n"),
  256. "utf-8",
  257. ),
  258. writeFile(
  259. join(storyDir, "volume_outline.md"),
  260. [
  261. "# Volume Outline",
  262. "",
  263. "## Chapter 8",
  264. "Expose the registry clerk's hidden ledger in the floodgate archive.",
  265. "",
  266. ].join("\n"),
  267. "utf-8",
  268. ),
  269. writeFile(
  270. join(storyDir, "chapter_summaries.md"),
  271. [
  272. "# Chapter Summaries",
  273. "",
  274. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  275. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  276. "| 1 | Ledger in Rain | Taryn | Taryn checks the first false folio | None | hook advanced | tight | investigation |",
  277. "| 2 | Ledger at Dusk | Taryn | Taryn questions the dock clerk | None | hook advanced | tight | investigation |",
  278. "| 3 | Ledger Below | Taryn | Taryn searches the under-archive | None | hook advanced | tight | investigation |",
  279. "",
  280. ].join("\n"),
  281. "utf-8",
  282. ),
  283. ]);
  284. const planner = new PlannerAgent({
  285. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  286. model: "test-model",
  287. projectRoot: root,
  288. bookId: book.id,
  289. });
  290. const result = await planner.planChapter({
  291. book,
  292. bookDir,
  293. chapterNumber: 4,
  294. });
  295. expect(result.intent.arcDirective).toContain("fallback");
  296. expect(result.intent.sceneDirective).toContain("investigation");
  297. expect(result.intent.titleDirective?.toLowerCase()).toContain("ledger");
  298. expect(result.intent.moodDirective).toBeUndefined();
  299. });
  300. it("emits a mood directive when recent chapters are all high-tension", async () => {
  301. book = {
  302. ...book,
  303. genre: "other",
  304. language: "zh",
  305. };
  306. await Promise.all([
  307. writeFile(
  308. join(storyDir, "volume_outline.md"),
  309. "# Volume Outline\n\n## Chapter 5\n进入新的地点。\n",
  310. "utf-8",
  311. ),
  312. writeFile(
  313. join(storyDir, "chapter_summaries.md"),
  314. [
  315. "# Chapter Summaries",
  316. "",
  317. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  318. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  319. "| 1 | 暗巷追踪 | 周谨川 | 追踪目标 | None | none | 紧张、压抑 | 悬念验证章 |",
  320. "| 2 | 旧楼对峙 | 周谨川 | 对峙 | None | none | 冷硬、逼仄 | 冲突章 |",
  321. "| 3 | 夜色围堵 | 周谨川 | 围堵 | None | none | 肃杀、凝重 | 追击章 |",
  322. "| 4 | 地下通道 | 周谨川 | 逃脱 | None | none | 压迫、窒息 | 逃亡章 |",
  323. "",
  324. ].join("\n"),
  325. "utf-8",
  326. ),
  327. ]);
  328. const planner = new PlannerAgent({
  329. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  330. model: "test-model",
  331. projectRoot: root,
  332. bookId: book.id,
  333. });
  334. const result = await planner.planChapter({
  335. book,
  336. bookDir,
  337. chapterNumber: 5,
  338. });
  339. expect(result.intent.moodDirective).toBeDefined();
  340. expect(result.intent.moodDirective).toContain("降调");
  341. expect(result.intent.moodDirective).toContain("日常");
  342. });
  343. it("does not emit a mood directive when recent moods are varied", async () => {
  344. book = {
  345. ...book,
  346. genre: "other",
  347. language: "en",
  348. };
  349. await Promise.all([
  350. writeFile(
  351. join(storyDir, "volume_outline.md"),
  352. "# Volume Outline\n\n## Chapter 5\nMove to the harbor.\n",
  353. "utf-8",
  354. ),
  355. writeFile(
  356. join(storyDir, "chapter_summaries.md"),
  357. [
  358. "# Chapter Summaries",
  359. "",
  360. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  361. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  362. "| 1 | Morning Calm | Taryn | A quiet walk | None | none | warm, gentle | slice-of-life |",
  363. "| 2 | Sudden Rain | Taryn | Storm arrives | None | none | tense, ominous | tension |",
  364. "| 3 | Harbor Light | Taryn | Finds shelter | None | none | hopeful, light | transition |",
  365. "| 4 | The Letter | Taryn | Reads bad news | None | none | melancholy, reflective | introspection |",
  366. "",
  367. ].join("\n"),
  368. "utf-8",
  369. ),
  370. ]);
  371. const planner = new PlannerAgent({
  372. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  373. model: "test-model",
  374. projectRoot: root,
  375. bookId: book.id,
  376. });
  377. const result = await planner.planChapter({
  378. book,
  379. bookDir,
  380. chapterNumber: 5,
  381. });
  382. expect(result.intent.moodDirective).toBeUndefined();
  383. });
  384. it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => {
  385. await Promise.all([
  386. writeFile(
  387. join(storyDir, "current_focus.md"),
  388. [
  389. "# Current Focus",
  390. "",
  391. "## Active Focus",
  392. "",
  393. "(Describe what the next 1-3 chapters should prioritize.)",
  394. "",
  395. ].join("\n"),
  396. "utf-8",
  397. ),
  398. writeFile(
  399. join(storyDir, "volume_outline.md"),
  400. "# Volume Outline\n",
  401. "utf-8",
  402. ),
  403. ]);
  404. const planner = new PlannerAgent({
  405. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  406. model: "test-model",
  407. projectRoot: root,
  408. bookId: book.id,
  409. });
  410. const result = await planner.planChapter({
  411. book,
  412. bookDir,
  413. chapterNumber: 3,
  414. });
  415. expect(result.intent.goal).toContain("mentor-student bond");
  416. expect(result.intent.goal).not.toContain("Describe what the next 1-3 chapters should prioritize");
  417. });
  418. it("uses bullet-style volume outline chapter nodes as the fallback goal when control docs are placeholders", async () => {
  419. await Promise.all([
  420. writeFile(
  421. join(storyDir, "author_intent.md"),
  422. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  423. "utf-8",
  424. ),
  425. writeFile(
  426. join(storyDir, "current_focus.md"),
  427. [
  428. "# Current Focus",
  429. "",
  430. "## Active Focus",
  431. "",
  432. "(Describe what the next 1-3 chapters should prioritize.)",
  433. "",
  434. ].join("\n"),
  435. "utf-8",
  436. ),
  437. writeFile(
  438. join(storyDir, "volume_outline.md"),
  439. [
  440. "## Volume 1",
  441. "**Chapter range:** 1-8",
  442. "",
  443. "**Key turning points:**",
  444. "- **Chapter 3:** Track the merchant guild's escape route through the western canal.",
  445. "",
  446. ].join("\n"),
  447. "utf-8",
  448. ),
  449. ]);
  450. const planner = new PlannerAgent({
  451. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  452. model: "test-model",
  453. projectRoot: root,
  454. bookId: book.id,
  455. });
  456. const result = await planner.planChapter({
  457. book,
  458. bookDir,
  459. chapterNumber: 3,
  460. });
  461. expect(result.intent.outlineNode).toContain("merchant guild's escape route");
  462. expect(result.intent.goal).toContain("merchant guild's escape route");
  463. expect(result.intent.goal).not.toContain("Advance chapter 3 with clear narrative focus.");
  464. });
  465. it("uses the next paragraph for bold standalone English chapter labels instead of capturing markdown markers", async () => {
  466. book = {
  467. ...book,
  468. genre: "other",
  469. language: "en",
  470. };
  471. await Promise.all([
  472. writeFile(
  473. join(storyDir, "current_focus.md"),
  474. [
  475. "# Current Focus",
  476. "",
  477. "## Active Focus",
  478. "",
  479. "(Describe what the next 1-3 chapters should prioritize.)",
  480. "",
  481. ].join("\n"),
  482. "utf-8",
  483. ),
  484. writeFile(
  485. join(storyDir, "volume_outline.md"),
  486. [
  487. "## Volume 1 - The Dead Examiner",
  488. "**Chapter Range:** 1-12",
  489. "",
  490. "**Key Turning Points:**",
  491. "- Ch1: Renn dies after summoning Taryn to review irregular treaty folios.",
  492. "",
  493. "### Golden First Three Chapters Rule",
  494. "",
  495. "**Chapter 2:**",
  496. "Show Taryn's edge through action, not exposition. He uses registry numbering logic to identify which folios are decoys and which conceal a ledger fragment.",
  497. "",
  498. ].join("\n"),
  499. "utf-8",
  500. ),
  501. ]);
  502. const planner = new PlannerAgent({
  503. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  504. model: "test-model",
  505. projectRoot: root,
  506. bookId: book.id,
  507. });
  508. const result = await planner.planChapter({
  509. book,
  510. bookDir,
  511. chapterNumber: 2,
  512. });
  513. expect(result.intent.outlineNode).toContain("Show Taryn's edge through action");
  514. expect(result.intent.outlineNode).not.toBe("**");
  515. expect(result.intent.goal).toContain("Show Taryn's edge through action");
  516. expect(result.intent.goal).not.toBe("**");
  517. });
  518. it("does not confuse Chapter 1 with Chapter 10 when matching exact English chapter labels", async () => {
  519. book = {
  520. ...book,
  521. genre: "other",
  522. language: "en",
  523. };
  524. await Promise.all([
  525. writeFile(
  526. join(storyDir, "current_focus.md"),
  527. [
  528. "# Current Focus",
  529. "",
  530. "## Active Focus",
  531. "",
  532. "(Describe what the next 1-3 chapters should prioritize.)",
  533. "",
  534. ].join("\n"),
  535. "utf-8",
  536. ),
  537. writeFile(
  538. join(storyDir, "volume_outline.md"),
  539. [
  540. "# Volume Outline",
  541. "",
  542. "### Chapter 10",
  543. "This late-volume node should not be selected for chapter one.",
  544. "",
  545. "### Chapter 1",
  546. "Open with the dead examiner and the sealed folio dispute.",
  547. "",
  548. ].join("\n"),
  549. "utf-8",
  550. ),
  551. ]);
  552. const planner = new PlannerAgent({
  553. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  554. model: "test-model",
  555. projectRoot: root,
  556. bookId: book.id,
  557. });
  558. const result = await planner.planChapter({
  559. book,
  560. bookDir,
  561. chapterNumber: 1,
  562. });
  563. expect(result.intent.outlineNode).toContain("dead examiner");
  564. expect(result.intent.outlineNode).not.toContain("late-volume");
  565. expect(result.intent.goal).toContain("dead examiner");
  566. });
  567. it("uses inline Chinese exact chapter labels with a title suffix", async () => {
  568. await Promise.all([
  569. writeFile(
  570. join(storyDir, "author_intent.md"),
  571. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  572. "utf-8",
  573. ),
  574. writeFile(
  575. join(storyDir, "current_focus.md"),
  576. [
  577. "# Current Focus",
  578. "",
  579. "## Active Focus",
  580. "",
  581. "(Describe what the next 1-3 chapters should prioritize.)",
  582. "",
  583. ].join("\n"),
  584. "utf-8",
  585. ),
  586. writeFile(
  587. join(storyDir, "volume_outline.md"),
  588. [
  589. "# Volume Outline",
  590. "",
  591. "第 7 章:在码头接头并截住逃跑账房。",
  592. "",
  593. ].join("\n"),
  594. "utf-8",
  595. ),
  596. ]);
  597. const planner = new PlannerAgent({
  598. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  599. model: "test-model",
  600. projectRoot: root,
  601. bookId: book.id,
  602. });
  603. const result = await planner.planChapter({
  604. book,
  605. bookDir,
  606. chapterNumber: 7,
  607. });
  608. expect(result.intent.outlineNode).toContain("在码头接头");
  609. expect(result.intent.goal).toContain("在码头接头");
  610. expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
  611. });
  612. it("uses standalone Chinese chapter-range labels when the chapter falls inside the range", async () => {
  613. await Promise.all([
  614. writeFile(
  615. join(storyDir, "author_intent.md"),
  616. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  617. "utf-8",
  618. ),
  619. writeFile(
  620. join(storyDir, "current_focus.md"),
  621. [
  622. "# Current Focus",
  623. "",
  624. "## Active Focus",
  625. "",
  626. "(Describe what the next 1-3 chapters should prioritize.)",
  627. "",
  628. ].join("\n"),
  629. "utf-8",
  630. ),
  631. writeFile(
  632. join(storyDir, "volume_outline.md"),
  633. [
  634. "# Volume Outline",
  635. "",
  636. "第1-6章",
  637. "Stay with the early city setup and mentor fallout.",
  638. "",
  639. "第7-20章",
  640. "Track the merchant guild's escape route through the western canal.",
  641. "",
  642. ].join("\n"),
  643. "utf-8",
  644. ),
  645. ]);
  646. const planner = new PlannerAgent({
  647. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  648. model: "test-model",
  649. projectRoot: root,
  650. bookId: book.id,
  651. });
  652. const result = await planner.planChapter({
  653. book,
  654. bookDir,
  655. chapterNumber: 7,
  656. });
  657. expect(result.intent.outlineNode).toContain("merchant guild's escape route");
  658. expect(result.intent.goal).toContain("merchant guild's escape route");
  659. expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
  660. });
  661. it("uses standalone English chapter-range labels at the start of the range", async () => {
  662. book = {
  663. ...book,
  664. genre: "other",
  665. language: "en",
  666. };
  667. await Promise.all([
  668. writeFile(
  669. join(storyDir, "author_intent.md"),
  670. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  671. "utf-8",
  672. ),
  673. writeFile(
  674. join(storyDir, "current_focus.md"),
  675. [
  676. "# Current Focus",
  677. "",
  678. "## Active Focus",
  679. "",
  680. "(Describe what the next 1-3 chapters should prioritize.)",
  681. "",
  682. ].join("\n"),
  683. "utf-8",
  684. ),
  685. writeFile(
  686. join(storyDir, "volume_outline.md"),
  687. [
  688. "# Volume Outline",
  689. "",
  690. "Chapter 1-3",
  691. "Keep the opening pressure on the first examiner.",
  692. "",
  693. "Chapter 4-6",
  694. "Recover the sealed ledger before dawn.",
  695. "",
  696. ].join("\n"),
  697. "utf-8",
  698. ),
  699. ]);
  700. const planner = new PlannerAgent({
  701. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  702. model: "test-model",
  703. projectRoot: root,
  704. bookId: book.id,
  705. });
  706. const result = await planner.planChapter({
  707. book,
  708. bookDir,
  709. chapterNumber: 4,
  710. });
  711. expect(result.intent.outlineNode).toContain("sealed ledger");
  712. expect(result.intent.goal).toContain("sealed ledger");
  713. expect(result.intent.outlineNode).not.toContain("6");
  714. expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
  715. });
  716. it("uses the next paragraph for bold standalone English chapter-range labels instead of bleeding into the next range", async () => {
  717. book = {
  718. ...book,
  719. genre: "other",
  720. language: "en",
  721. };
  722. await Promise.all([
  723. writeFile(
  724. join(storyDir, "author_intent.md"),
  725. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  726. "utf-8",
  727. ),
  728. writeFile(
  729. join(storyDir, "current_focus.md"),
  730. [
  731. "# Current Focus",
  732. "",
  733. "## Active Focus",
  734. "",
  735. "(Describe what the next 1-3 chapters should prioritize.)",
  736. "",
  737. ].join("\n"),
  738. "utf-8",
  739. ),
  740. writeFile(
  741. join(storyDir, "volume_outline.md"),
  742. [
  743. "# Volume Outline",
  744. "",
  745. "**Chapter 1-3:**",
  746. "Keep the opening pressure on the first examiner.",
  747. "",
  748. "**Chapter 4-6:**",
  749. "Recover the sealed ledger before dawn.",
  750. "",
  751. "**Chapter 7-9:**",
  752. "Trigger the registry fire and expose the false witness.",
  753. "",
  754. ].join("\n"),
  755. "utf-8",
  756. ),
  757. ]);
  758. const planner = new PlannerAgent({
  759. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  760. model: "test-model",
  761. projectRoot: root,
  762. bookId: book.id,
  763. });
  764. const result = await planner.planChapter({
  765. book,
  766. bookDir,
  767. chapterNumber: 4,
  768. });
  769. expect(result.intent.outlineNode).toContain("sealed ledger");
  770. expect(result.intent.outlineNode).not.toContain("registry fire");
  771. expect(result.intent.goal).toContain("sealed ledger");
  772. });
  773. it("falls back to the first outline directive when no standalone range matches", async () => {
  774. await Promise.all([
  775. writeFile(
  776. join(storyDir, "author_intent.md"),
  777. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  778. "utf-8",
  779. ),
  780. writeFile(
  781. join(storyDir, "current_focus.md"),
  782. [
  783. "# Current Focus",
  784. "",
  785. "## Active Focus",
  786. "",
  787. "(Describe what the next 1-3 chapters should prioritize.)",
  788. "",
  789. ].join("\n"),
  790. "utf-8",
  791. ),
  792. writeFile(
  793. join(storyDir, "volume_outline.md"),
  794. [
  795. "# Volume Outline",
  796. "",
  797. "第1-6章",
  798. "Stay with the early city setup and mentor fallout.",
  799. "",
  800. "第7-20章",
  801. "Track the merchant guild's escape route through the western canal.",
  802. "",
  803. ].join("\n"),
  804. "utf-8",
  805. ),
  806. ]);
  807. const planner = new PlannerAgent({
  808. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  809. model: "test-model",
  810. projectRoot: root,
  811. bookId: book.id,
  812. });
  813. const result = await planner.planChapter({
  814. book,
  815. bookDir,
  816. chapterNumber: 25,
  817. });
  818. expect(result.intent.outlineNode).toContain("Stay with the early city setup");
  819. expect(result.intent.goal).toContain("Stay with the early city setup");
  820. expect(result.intent.outlineNode).not.toBe("第1-6章");
  821. expect(result.intent.goal).not.toContain("merchant guild's escape route");
  822. });
  823. it("preserves hard facts from state and canon in mustKeep", async () => {
  824. const planner = new PlannerAgent({
  825. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  826. model: "test-model",
  827. projectRoot: root,
  828. bookId: book.id,
  829. });
  830. const result = await planner.planChapter({
  831. book,
  832. bookDir,
  833. chapterNumber: 3,
  834. });
  835. expect(result.intent.mustKeep).toContain("Lin Yue still hides the broken oath token.");
  836. expect(result.intent.mustKeep).toContain("The jade seal cannot be destroyed.");
  837. });
  838. it("records conflicts when the external request diverges from the outline", async () => {
  839. const planner = new PlannerAgent({
  840. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  841. model: "test-model",
  842. projectRoot: root,
  843. bookId: book.id,
  844. });
  845. const result = await planner.planChapter({
  846. book,
  847. bookDir,
  848. chapterNumber: 3,
  849. externalContext: "Ignore the guild chase and bring the focus back to mentor conflict.",
  850. });
  851. expect(result.intent.conflicts).toHaveLength(1);
  852. expect(result.intent.conflicts[0]?.type).toBe("outline_vs_request");
  853. await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("outline_vs_request");
  854. });
  855. it("writes compact memory snapshots instead of inlining the full history", async () => {
  856. await Promise.all([
  857. writeFile(
  858. join(storyDir, "pending_hooks.md"),
  859. [
  860. "# Pending Hooks",
  861. "",
  862. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  863. "| --- | --- | --- | --- | --- | --- | --- |",
  864. "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
  865. "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
  866. "| old-seal | 3 | artifact | resolved | 3 | 3 | Jade seal already recovered |",
  867. "",
  868. ].join("\n"),
  869. "utf-8",
  870. ),
  871. writeFile(
  872. join(storyDir, "chapter_summaries.md"),
  873. [
  874. "# Chapter Summaries",
  875. "",
  876. "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
  877. "| 2 | City Watch | Patrols sweep the market | Search widens | None | guild-route advanced | urgent | investigation |",
  878. "| 3 | Seal Vault | Lin Yue finds the seal vault | The jade seal returns | Seal secured | old-seal resolved | solemn | reveal |",
  879. "| 4 | Empty Road | The group loses the convoy | Doubts grow | Travel fatigue | none | grim | travel |",
  880. "| 5 | Burned Shrine | Shrine clues point nowhere | Friction rises | Lin Yue distrusts allies | none | bitter | setback |",
  881. "| 6 | Quiet Ledger | Merchant records stay hidden | No breakthrough | Cash runs thin | none | weary | transition |",
  882. "| 7 | Broken Letter | A torn letter mentions the mentor | Suspicion returns | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
  883. "| 8 | River Camp | Lin Yue meets old witnesses | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
  884. "| 9 | Trial Echo | The trial fallout resurfaces | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  885. "| 10 | Locked Gate | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | Oath token is still hidden | mentor-oath advanced | focused | decision |",
  886. "",
  887. ].join("\n"),
  888. "utf-8",
  889. ),
  890. ]);
  891. const planner = new PlannerAgent({
  892. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  893. model: "test-model",
  894. projectRoot: root,
  895. bookId: book.id,
  896. });
  897. const result = await planner.planChapter({
  898. book,
  899. bookDir,
  900. chapterNumber: 11,
  901. externalContext: "Bring the focus back to the mentor oath conflict with Lin Yue.",
  902. });
  903. const intentMarkdown = await readFile(result.runtimePath, "utf-8");
  904. expect(intentMarkdown).toContain("mentor-oath");
  905. expect(intentMarkdown).toContain("| 10 | Locked Gate |");
  906. expect(intentMarkdown).not.toContain("| 1 | Guild Trail |");
  907. expect(intentMarkdown).not.toContain("| old-seal | 3 | artifact | resolved |");
  908. });
  909. it("renders English memory snapshot headers for English books", async () => {
  910. book = {
  911. ...book,
  912. genre: "other",
  913. language: "en",
  914. };
  915. await Promise.all([
  916. writeFile(
  917. join(storyDir, "pending_hooks.md"),
  918. [
  919. "# Pending Hooks",
  920. "",
  921. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  922. "| --- | --- | --- | --- | --- | --- | --- |",
  923. "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
  924. "",
  925. ].join("\n"),
  926. "utf-8",
  927. ),
  928. writeFile(
  929. join(storyDir, "chapter_summaries.md"),
  930. [
  931. "# Chapter Summaries",
  932. "",
  933. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  934. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  935. "| 10 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
  936. "",
  937. ].join("\n"),
  938. "utf-8",
  939. ),
  940. ]);
  941. const planner = new PlannerAgent({
  942. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  943. model: "test-model",
  944. projectRoot: root,
  945. bookId: book.id,
  946. });
  947. const result = await planner.planChapter({
  948. book,
  949. bookDir,
  950. chapterNumber: 11,
  951. externalContext: "Bring the focus back to the mentor oath conflict with Lin Yue.",
  952. });
  953. const intentMarkdown = await readFile(result.runtimePath, "utf-8");
  954. expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |");
  955. expect(intentMarkdown).toContain("| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |");
  956. expect(intentMarkdown).not.toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |");
  957. expect(intentMarkdown).not.toContain("| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |");
  958. });
  959. it("derives structured current_focus markdown into goal, avoids, and style emphasis", async () => {
  960. await Promise.all([
  961. writeFile(
  962. join(storyDir, "current_focus.md"),
  963. [
  964. "# Current Focus",
  965. "",
  966. "## Active Focus",
  967. "",
  968. "- Bring the focus back to Lin Yue's private confrontation with the mentor debt.",
  969. "- Keep the chapter centered on a missing record, not a whole-conspiracy overview.",
  970. "- Surface one concrete evidence trail the next chapter can pursue.",
  971. "",
  972. "## Avoid",
  973. "",
  974. "- Do not turn this chapter into a citywide survey of every faction.",
  975. "- Do not use summary-heavy moralizing paragraphs.",
  976. "",
  977. ].join("\n"),
  978. "utf-8",
  979. ),
  980. writeFile(
  981. join(storyDir, "story_bible.md"),
  982. [
  983. "# Story Bible",
  984. "",
  985. "- --",
  986. "- The jade seal cannot be destroyed.",
  987. "",
  988. ].join("\n"),
  989. "utf-8",
  990. ),
  991. writeFile(
  992. join(storyDir, "volume_outline.md"),
  993. "# Volume Outline\n",
  994. "utf-8",
  995. ),
  996. ]);
  997. const planner = new PlannerAgent({
  998. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  999. model: "test-model",
  1000. projectRoot: root,
  1001. bookId: book.id,
  1002. });
  1003. const result = await planner.planChapter({
  1004. book,
  1005. bookDir,
  1006. chapterNumber: 2,
  1007. });
  1008. expect(result.intent.goal).toContain("private confrontation");
  1009. expect(result.intent.goal).toContain("missing record");
  1010. expect(result.intent.mustAvoid).toEqual(expect.arrayContaining([
  1011. "Do not turn this chapter into a citywide survey of every faction.",
  1012. "Do not use summary-heavy moralizing paragraphs.",
  1013. ]));
  1014. expect(result.intent.mustAvoid).not.toContain(
  1015. "Keep the chapter centered on a missing record, not a whole-conspiracy overview.",
  1016. );
  1017. expect(result.intent.styleEmphasis).toEqual(expect.arrayContaining([
  1018. "Bring the focus back to Lin Yue's private confrontation with the mentor debt.",
  1019. "Surface one concrete evidence trail the next chapter can pursue.",
  1020. ]));
  1021. expect(result.intent.mustKeep).not.toContain("--");
  1022. });
  1023. it("emits hook agenda into chapter intent and runtime markdown", async () => {
  1024. const stateDir = join(storyDir, "state");
  1025. await mkdir(stateDir, { recursive: true });
  1026. await Promise.all([
  1027. writeFile(
  1028. join(stateDir, "manifest.json"),
  1029. JSON.stringify({
  1030. schemaVersion: 2,
  1031. language: "en",
  1032. lastAppliedChapter: 25,
  1033. projectionVersion: 1,
  1034. migrationWarnings: [],
  1035. }, null, 2),
  1036. "utf-8",
  1037. ),
  1038. writeFile(
  1039. join(stateDir, "current_state.json"),
  1040. JSON.stringify({
  1041. chapter: 25,
  1042. facts: [],
  1043. }, null, 2),
  1044. "utf-8",
  1045. ),
  1046. writeFile(
  1047. join(stateDir, "chapter_summaries.json"),
  1048. JSON.stringify({
  1049. rows: [],
  1050. }, null, 2),
  1051. "utf-8",
  1052. ),
  1053. writeFile(
  1054. join(stateDir, "hooks.json"),
  1055. JSON.stringify({
  1056. hooks: [
  1057. {
  1058. hookId: "recent-route",
  1059. startChapter: 23,
  1060. type: "route",
  1061. status: "open",
  1062. lastAdvancedChapter: 25,
  1063. expectedPayoff: "Recent route payoff",
  1064. notes: "Recent route remains active.",
  1065. },
  1066. {
  1067. hookId: "ready-payoff",
  1068. startChapter: 12,
  1069. type: "mystery",
  1070. status: "progressing",
  1071. lastAdvancedChapter: 24,
  1072. expectedPayoff: "Reveal the hidden room mastermind",
  1073. notes: "The chapter is close to the reveal point.",
  1074. },
  1075. {
  1076. hookId: "stale-debt",
  1077. startChapter: 3,
  1078. type: "relationship",
  1079. status: "open",
  1080. lastAdvancedChapter: 8,
  1081. expectedPayoff: "Mentor debt payoff",
  1082. notes: "Long-stale but still unresolved.",
  1083. },
  1084. ],
  1085. }, null, 2),
  1086. "utf-8",
  1087. ),
  1088. ]);
  1089. book = {
  1090. ...book,
  1091. genre: "other",
  1092. language: "en",
  1093. };
  1094. const planner = new PlannerAgent({
  1095. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  1096. model: "test-model",
  1097. projectRoot: root,
  1098. bookId: book.id,
  1099. });
  1100. const result = await planner.planChapter({
  1101. book,
  1102. bookDir,
  1103. chapterNumber: 26,
  1104. externalContext: "Keep the chapter on the mainline debt conflict.",
  1105. });
  1106. // Lifecycle scheduling: ready-payoff (progressing, mid-arc, overdue) → eligibleResolve;
  1107. // stale-debt (dormant, stale+overdue) → staleDebt; recent-route (remainder) → mustAdvance.
  1108. expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]);
  1109. expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]);
  1110. expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route"]);
  1111. expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship");
  1112. expect(result.intent.hookAgenda.pressureMap).toEqual([]);
  1113. const intentMarkdown = await readFile(result.runtimePath, "utf-8");
  1114. expect(intentMarkdown).toContain("## Hook Agenda");
  1115. expect(intentMarkdown).toContain("ready-payoff");
  1116. expect(intentMarkdown).toContain("stale-debt");
  1117. });
  1118. it("builds stale debt agenda from broader active hooks than the retrieval subset", async () => {
  1119. const stateDir = join(storyDir, "state");
  1120. await mkdir(stateDir, { recursive: true });
  1121. await Promise.all([
  1122. writeFile(
  1123. join(stateDir, "manifest.json"),
  1124. JSON.stringify({
  1125. schemaVersion: 2,
  1126. language: "en",
  1127. lastAppliedChapter: 25,
  1128. projectionVersion: 1,
  1129. migrationWarnings: [],
  1130. }, null, 2),
  1131. "utf-8",
  1132. ),
  1133. writeFile(
  1134. join(stateDir, "current_state.json"),
  1135. JSON.stringify({
  1136. chapter: 25,
  1137. facts: [],
  1138. }, null, 2),
  1139. "utf-8",
  1140. ),
  1141. writeFile(
  1142. join(stateDir, "chapter_summaries.json"),
  1143. JSON.stringify({
  1144. rows: [],
  1145. }, null, 2),
  1146. "utf-8",
  1147. ),
  1148. writeFile(
  1149. join(stateDir, "hooks.json"),
  1150. JSON.stringify({
  1151. hooks: [
  1152. {
  1153. hookId: "recent-route",
  1154. startChapter: 23,
  1155. type: "route",
  1156. status: "open",
  1157. lastAdvancedChapter: 25,
  1158. expectedPayoff: "Recent route payoff",
  1159. notes: "Keep the route central.",
  1160. },
  1161. {
  1162. hookId: "recent-guild",
  1163. startChapter: 22,
  1164. type: "politics",
  1165. status: "progressing",
  1166. lastAdvancedChapter: 24,
  1167. expectedPayoff: "Guild pressure payoff",
  1168. notes: "Guild pressure remains active.",
  1169. },
  1170. {
  1171. hookId: "recent-token",
  1172. startChapter: 21,
  1173. type: "artifact",
  1174. status: "open",
  1175. lastAdvancedChapter: 23,
  1176. expectedPayoff: "Token route payoff",
  1177. notes: "Token route remains active.",
  1178. },
  1179. {
  1180. hookId: "stale-omega",
  1181. startChapter: 3,
  1182. type: "relationship",
  1183. status: "open",
  1184. lastAdvancedChapter: 8,
  1185. expectedPayoff: "Old debt payoff",
  1186. notes: "Dormant unresolved line.",
  1187. },
  1188. {
  1189. hookId: "stale-sable",
  1190. startChapter: 4,
  1191. type: "mystery",
  1192. status: "open",
  1193. lastAdvancedChapter: 9,
  1194. expectedPayoff: "Archive payoff",
  1195. notes: "Another dormant unresolved line.",
  1196. },
  1197. ],
  1198. }, null, 2),
  1199. "utf-8",
  1200. ),
  1201. ]);
  1202. book = {
  1203. ...book,
  1204. genre: "other",
  1205. language: "en",
  1206. };
  1207. const planner = new PlannerAgent({
  1208. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  1209. model: "test-model",
  1210. projectRoot: root,
  1211. bookId: book.id,
  1212. });
  1213. const result = await planner.planChapter({
  1214. book,
  1215. bookDir,
  1216. chapterNumber: 26,
  1217. externalContext: "Keep the chapter on the route pressure.",
  1218. });
  1219. // Lifecycle scheduling: stale-omega and stale-sable are stale+overdue → staleDebt;
  1220. // recent-token and recent-route are not stale/overdue → mustAdvance (by advancePressure).
  1221. expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-omega", "stale-sable"]);
  1222. expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-token", "recent-route"]);
  1223. expect(result.intent.hookAgenda.avoidNewHookFamilies).toEqual(expect.arrayContaining([
  1224. "relationship",
  1225. "mystery",
  1226. ]));
  1227. expect(result.intent.hookAgenda.pressureMap).toEqual([]);
  1228. });
  1229. it("renders hook budget from total active hooks instead of the selected hook snapshot", async () => {
  1230. const stateDir = join(storyDir, "state");
  1231. await mkdir(stateDir, { recursive: true });
  1232. const hooks = Array.from({ length: 12 }, (_, index) => ({
  1233. hookId: `hook-${index + 1}`,
  1234. startChapter: index + 1,
  1235. type: index < 6 ? "route" : "mystery",
  1236. status: "open",
  1237. lastAdvancedChapter: index < 6 ? 25 - index : 12 - index,
  1238. expectedPayoff: index < 6 ? "Route debt payoff" : "Dormant mystery payoff",
  1239. notes: index < 6
  1240. ? `Route pressure thread ${index + 1} stays relevant.`
  1241. : `Dormant thread ${index + 1} should not be selected into the primary context.`,
  1242. }));
  1243. await Promise.all([
  1244. writeFile(
  1245. join(stateDir, "manifest.json"),
  1246. JSON.stringify({
  1247. schemaVersion: 2,
  1248. language: "en",
  1249. lastAppliedChapter: 25,
  1250. projectionVersion: 1,
  1251. migrationWarnings: [],
  1252. }, null, 2),
  1253. "utf-8",
  1254. ),
  1255. writeFile(
  1256. join(stateDir, "current_state.json"),
  1257. JSON.stringify({
  1258. chapter: 25,
  1259. facts: [],
  1260. }, null, 2),
  1261. "utf-8",
  1262. ),
  1263. writeFile(
  1264. join(stateDir, "chapter_summaries.json"),
  1265. JSON.stringify({
  1266. rows: [],
  1267. }, null, 2),
  1268. "utf-8",
  1269. ),
  1270. writeFile(
  1271. join(stateDir, "hooks.json"),
  1272. JSON.stringify({ hooks }, null, 2),
  1273. "utf-8",
  1274. ),
  1275. ]);
  1276. book = {
  1277. ...book,
  1278. genre: "other",
  1279. language: "en",
  1280. targetChapters: 40,
  1281. };
  1282. const planner = new PlannerAgent({
  1283. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  1284. model: "test-model",
  1285. projectRoot: root,
  1286. bookId: book.id,
  1287. });
  1288. const result = await planner.planChapter({
  1289. book,
  1290. bookDir,
  1291. chapterNumber: 26,
  1292. externalContext: "Keep the chapter on the route pressure.",
  1293. });
  1294. const intentMarkdown = await readFile(result.runtimePath, "utf-8");
  1295. expect(intentMarkdown).toContain("12 active hooks");
  1296. expect(intentMarkdown).not.toContain("8 active hooks");
  1297. });
  1298. it("matches multi-line 章节范围 format and distributes beats across chapters", async () => {
  1299. await writeFile(
  1300. join(storyDir, "volume_outline.md"),
  1301. [
  1302. "## 已有章节部分",
  1303. "",
  1304. "### 卷一:开端",
  1305. "- **章节范围**:1-3章",
  1306. "- **核心冲突**:初始设定",
  1307. "- **关键转折**:",
  1308. " 1. 主角登场并发现异常",
  1309. " 2. 遭遇第一次危机",
  1310. " 3. 获得关键线索",
  1311. "",
  1312. "## 后续预测部分",
  1313. "",
  1314. "### 卷二:深入",
  1315. "- **章节范围**:4-8章",
  1316. "- **核心冲突**:深入调查",
  1317. "- **关键转折**:",
  1318. " 1. 抵达新地点,发现物证",
  1319. " 2. 与对手首次正面交锋",
  1320. " 3. 盟友背叛,局势逆转",
  1321. " 4. 主角被迫转入地下",
  1322. " 5. 发现幕后真相的第一条线",
  1323. "",
  1324. ].join("\n"),
  1325. "utf-8",
  1326. );
  1327. const planner = new PlannerAgent({
  1328. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  1329. model: "test-model",
  1330. projectRoot: root,
  1331. bookId: book.id,
  1332. });
  1333. // Chapter 4 should get beat 0 (first beat of 卷二): "抵达新地点"
  1334. const result4 = await planner.planChapter({
  1335. book,
  1336. bookDir,
  1337. chapterNumber: 4,
  1338. });
  1339. expect(result4.intent.outlineNode).toContain("抵达新地点");
  1340. // Chapter 6 should get beat 2 (third beat): "盟友背叛"
  1341. const result6 = await planner.planChapter({
  1342. book,
  1343. bookDir,
  1344. chapterNumber: 6,
  1345. });
  1346. expect(result6.intent.outlineNode).toContain("盟友背叛");
  1347. // Chapter 8 should get beat 4 (fifth beat): "幕后真相"
  1348. const result8 = await planner.planChapter({
  1349. book,
  1350. bookDir,
  1351. chapterNumber: 8,
  1352. });
  1353. expect(result8.intent.outlineNode).toContain("幕后真相");
  1354. // Chapter 5 should get beat 1: "正面交锋"
  1355. const result5 = await planner.planChapter({
  1356. book,
  1357. bookDir,
  1358. chapterNumber: 5,
  1359. });
  1360. expect(result5.intent.outlineNode).toContain("正面交锋");
  1361. });
  1362. it("uses the LLM chapter brief when planner output is valid", async () => {
  1363. const planner = new PlannerAgent({
  1364. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  1365. model: "test-model",
  1366. projectRoot: root,
  1367. bookId: book.id,
  1368. });
  1369. vi.spyOn(planner as unknown as { chat: (...args: unknown[]) => Promise<{ content: string }> }, "chat")
  1370. .mockResolvedValue({
  1371. content: JSON.stringify({
  1372. chapter: 3,
  1373. goal: "Force the warehouse confrontation before the guild escapes.",
  1374. chapterType: "confrontation",
  1375. isGoldenOpening: true,
  1376. beatOutline: [
  1377. { phase: "opening", instruction: "Open inside the warehouse argument." },
  1378. { phase: "development", instruction: "Push the unsigned transfer sheet into the scene." },
  1379. { phase: "hook", instruction: "End with the escape route changing hands." },
  1380. ],
  1381. hookPlan: [
  1382. {
  1383. hookId: "H019",
  1384. movement: "advance",
  1385. targetEffect: "Turn the mentor debt into an immediate cost.",
  1386. },
  1387. ],
  1388. dormantReason: "Hold the canal line until the next chapter.",
  1389. propsAndSetting: ["warehouse transfer sheet", "wet dock", "A Sheng"],
  1390. }),
  1391. });
  1392. const result = await planner.planChapter({
  1393. book,
  1394. bookDir,
  1395. chapterNumber: 3,
  1396. });
  1397. expect(result.brief?.chapterType).toBe("confrontation");
  1398. expect(result.intent.goal).toContain("warehouse confrontation");
  1399. expect(result.intent.arcDirective).toContain("opening: Open inside the warehouse argument.");
  1400. expect(result.intent.sceneDirective).toContain("warehouse transfer sheet");
  1401. await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("## Chapter Brief");
  1402. });
  1403. it("falls back to the legacy planner when the LLM chapter brief is invalid", async () => {
  1404. const planner = new PlannerAgent({
  1405. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  1406. model: "test-model",
  1407. projectRoot: root,
  1408. bookId: book.id,
  1409. });
  1410. vi.spyOn(planner as unknown as { chat: (...args: unknown[]) => Promise<{ content: string }> }, "chat")
  1411. .mockResolvedValue({
  1412. content: "{\"bad\":true}",
  1413. });
  1414. const result = await planner.planChapter({
  1415. book,
  1416. bookDir,
  1417. chapterNumber: 3,
  1418. });
  1419. expect(result.brief).toBeUndefined();
  1420. expect(result.intent.goal).toContain("merchant guild's escape route");
  1421. await expect(readFile(result.runtimePath, "utf-8")).resolves.not.toContain("## Chapter Brief");
  1422. });
  1423. });