composer.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. import { afterEach, beforeEach, describe, expect, it } from "vitest";
  2. import { createRequire } from "node:module";
  3. import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
  4. import { tmpdir } from "node:os";
  5. import { join } from "node:path";
  6. import type { BookConfig } from "../models/book.js";
  7. import type { PlanChapterOutput } from "../agents/planner.js";
  8. import { ComposerAgent } from "../agents/composer.js";
  9. const require = createRequire(import.meta.url);
  10. const hasNodeSqlite = (() => {
  11. try {
  12. require("node:sqlite");
  13. return true;
  14. } catch {
  15. return false;
  16. }
  17. })();
  18. describe("ComposerAgent", () => {
  19. let root: string;
  20. let bookDir: string;
  21. let storyDir: string;
  22. let book: BookConfig;
  23. let plan: PlanChapterOutput;
  24. beforeEach(async () => {
  25. root = await mkdtemp(join(tmpdir(), "inkos-composer-test-"));
  26. bookDir = join(root, "books", "composer-book");
  27. storyDir = join(bookDir, "story");
  28. await mkdir(join(storyDir, "runtime"), { recursive: true });
  29. book = {
  30. id: "composer-book",
  31. title: "Composer Book",
  32. platform: "tomato",
  33. genre: "xuanhuan",
  34. status: "active",
  35. targetChapters: 20,
  36. chapterWordCount: 3000,
  37. createdAt: "2026-03-22T00:00:00.000Z",
  38. updatedAt: "2026-03-22T00:00:00.000Z",
  39. };
  40. await Promise.all([
  41. writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the pressure on the mentor conflict.\n", "utf-8"),
  42. writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring the focus back to the mentor conflict.\n", "utf-8"),
  43. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  44. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nTrack the merchant guild trail.\n", "utf-8"),
  45. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  46. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  47. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  48. ]);
  49. const runtimePath = join(storyDir, "runtime", "chapter-0004.intent.md");
  50. await writeFile(runtimePath, "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor conflict.\n", "utf-8");
  51. plan = {
  52. intent: {
  53. chapter: 4,
  54. goal: "Bring the focus back to the mentor conflict.",
  55. outlineNode: "Track the merchant guild trail.",
  56. mustKeep: [
  57. "Lin Yue still hides the broken oath token.",
  58. "The jade seal cannot be destroyed.",
  59. ],
  60. mustAvoid: ["Do not reveal the mastermind."],
  61. styleEmphasis: ["character conflict", "tight POV"],
  62. },
  63. memo: {
  64. chapter: 4,
  65. goal: "Bring the focus back to the mentor conflict.",
  66. isGoldenOpening: false,
  67. body: "",
  68. threadRefs: [],
  69. },
  70. intentMarkdown: "# Chapter Intent\n",
  71. plannerInputs: [
  72. join(storyDir, "author_intent.md"),
  73. join(storyDir, "current_focus.md"),
  74. join(storyDir, "story_bible.md"),
  75. join(storyDir, "volume_outline.md"),
  76. join(storyDir, "current_state.md"),
  77. ],
  78. runtimePath,
  79. };
  80. });
  81. afterEach(async () => {
  82. await rm(root, { recursive: true, force: true });
  83. });
  84. it("selects only the relevant context and writes a context package", async () => {
  85. const composer = new ComposerAgent({
  86. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  87. model: "test-model",
  88. projectRoot: root,
  89. bookId: book.id,
  90. });
  91. const result = await composer.composeChapter({
  92. book,
  93. bookDir,
  94. chapterNumber: 4,
  95. plan,
  96. });
  97. const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
  98. expect(selectedSources.slice(0, 5)).toEqual([
  99. "runtime/chapter_memo",
  100. "story/current_focus.md",
  101. "story/current_state.md",
  102. "story/story_bible.md",
  103. "story/volume_outline.md",
  104. ]);
  105. expect(selectedSources.some((source) => source.startsWith("story/pending_hooks.md"))).toBe(true);
  106. expect(selectedSources).not.toContain("story/style_guide.md");
  107. await expect(readFile(result.contextPath, "utf-8")).resolves.toContain("current_focus.md");
  108. });
  109. it("emits a rule stack with hard, soft, and diagnostic sections", async () => {
  110. const composer = new ComposerAgent({
  111. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  112. model: "test-model",
  113. projectRoot: root,
  114. bookId: book.id,
  115. });
  116. const result = await composer.composeChapter({
  117. book,
  118. bookDir,
  119. chapterNumber: 4,
  120. plan,
  121. });
  122. expect(result.ruleStack.sections.hard).toContain("story_bible");
  123. expect(result.ruleStack.sections.soft).toContain("author_intent");
  124. expect(result.ruleStack.sections.diagnostic).toContain("anti_ai_checks");
  125. // activeOverrides dropped with ChapterConflict removal (Phase 1 transitional)
  126. expect(result.ruleStack.activeOverrides).toEqual([]);
  127. });
  128. it("writes trace output describing planner inputs and selected sources", async () => {
  129. const composer = new ComposerAgent({
  130. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  131. model: "test-model",
  132. projectRoot: root,
  133. bookId: book.id,
  134. });
  135. const result = await composer.composeChapter({
  136. book,
  137. bookDir,
  138. chapterNumber: 4,
  139. plan,
  140. });
  141. expect(result.trace.plannerInputs).toEqual(plan.plannerInputs);
  142. expect(result.trace.selectedSources).toContain("story/current_focus.md");
  143. // trace.notes dropped with ChapterConflict removal (Phase 1 transitional)
  144. expect(result.trace.notes).toEqual([]);
  145. await expect(readFile(result.tracePath, "utf-8")).resolves.toContain("story/current_focus.md");
  146. });
  147. it("retrieves summary and hook evidence chunks instead of whole long memory files", async () => {
  148. await Promise.all([
  149. writeFile(
  150. join(storyDir, "pending_hooks.md"),
  151. [
  152. "# Pending Hooks",
  153. "",
  154. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  155. "| --- | --- | --- | --- | --- | --- | --- |",
  156. "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
  157. "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
  158. "| old-seal | 3 | artifact | resolved | 3 | 3 | Jade seal already recovered |",
  159. "",
  160. ].join("\n"),
  161. "utf-8",
  162. ),
  163. writeFile(
  164. join(storyDir, "chapter_summaries.md"),
  165. [
  166. "# Chapter Summaries",
  167. "",
  168. "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
  169. "| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
  170. "| 8 | River Camp | Lin Yue, Mentor Witness | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
  171. "| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  172. "",
  173. ].join("\n"),
  174. "utf-8",
  175. ),
  176. ]);
  177. const longRangePlan: PlanChapterOutput = {
  178. ...plan,
  179. intent: {
  180. ...plan.intent,
  181. chapter: 10,
  182. goal: "Bring the focus back to the mentor oath conflict.",
  183. outlineNode: "Track the merchant guild trail.",
  184. },
  185. };
  186. const composer = new ComposerAgent({
  187. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  188. model: "test-model",
  189. projectRoot: root,
  190. bookId: book.id,
  191. });
  192. const result = await composer.composeChapter({
  193. book,
  194. bookDir,
  195. chapterNumber: 10,
  196. plan: longRangePlan,
  197. });
  198. const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
  199. expect(selectedSources).toContain("story/pending_hooks.md#mentor-oath");
  200. expect(selectedSources).toContain("story/chapter_summaries.md#9");
  201. expect(selectedSources).not.toContain("story/pending_hooks.md");
  202. if (hasNodeSqlite) {
  203. await expect(stat(join(storyDir, "memory.db"))).resolves.toBeTruthy();
  204. }
  205. });
  206. it("surfaces stale unresolved hook evidence in governed context selection", async () => {
  207. const stateDir = join(storyDir, "state");
  208. await mkdir(stateDir, { recursive: true });
  209. await Promise.all([
  210. writeFile(join(stateDir, "manifest.json"), JSON.stringify({
  211. schemaVersion: 2,
  212. language: "en",
  213. lastAppliedChapter: 25,
  214. projectionVersion: 1,
  215. migrationWarnings: [],
  216. }, null, 2), "utf-8"),
  217. writeFile(join(stateDir, "current_state.json"), JSON.stringify({
  218. chapter: 25,
  219. facts: [],
  220. }, null, 2), "utf-8"),
  221. writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
  222. rows: [],
  223. }, null, 2), "utf-8"),
  224. writeFile(join(stateDir, "hooks.json"), JSON.stringify({
  225. hooks: [
  226. {
  227. hookId: "recent-route",
  228. startChapter: 22,
  229. type: "route",
  230. status: "open",
  231. lastAdvancedChapter: 24,
  232. expectedPayoff: "Recent route payoff",
  233. notes: "Recent but not critical.",
  234. },
  235. {
  236. hookId: "stale-debt",
  237. startChapter: 3,
  238. type: "relationship",
  239. status: "open",
  240. lastAdvancedChapter: 8,
  241. expectedPayoff: "Mentor debt payoff",
  242. notes: "Long-stale but still unresolved.",
  243. },
  244. ],
  245. }, null, 2), "utf-8"),
  246. ]);
  247. const composer = new ComposerAgent({
  248. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  249. model: "test-model",
  250. projectRoot: root,
  251. bookId: book.id,
  252. });
  253. const result = await composer.composeChapter({
  254. book,
  255. bookDir,
  256. chapterNumber: 26,
  257. plan: {
  258. ...plan,
  259. intent: {
  260. ...plan.intent,
  261. chapter: 26,
  262. goal: "Keep the chapter on the mainline debt conflict.",
  263. },
  264. },
  265. });
  266. const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
  267. expect(selectedSources).toContain("story/pending_hooks.md#recent-route");
  268. expect(selectedSources).toContain("story/pending_hooks.md#stale-debt");
  269. });
  270. it("adds current-state fact evidence retrieved from sqlite-backed memory", async () => {
  271. await writeFile(
  272. join(storyDir, "current_state.md"),
  273. [
  274. "# Current State",
  275. "",
  276. "| Field | Value |",
  277. "| --- | --- |",
  278. "| Current Chapter | 9 |",
  279. "| Current Location | Ashen ferry crossing |",
  280. "| Protagonist State | Lin Yue hides the broken oath token and the old wound has reopened. |",
  281. "| Current Goal | Find the vanished mentor before the guild covers its tracks. |",
  282. "| Current Conflict | Mentor debt with the vanished teacher blocks every choice. |",
  283. "",
  284. ].join("\n"),
  285. "utf-8",
  286. );
  287. const composer = new ComposerAgent({
  288. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  289. model: "test-model",
  290. projectRoot: root,
  291. bookId: book.id,
  292. });
  293. const result = await composer.composeChapter({
  294. book,
  295. bookDir,
  296. chapterNumber: 10,
  297. plan: {
  298. ...plan,
  299. intent: {
  300. ...plan.intent,
  301. chapter: 10,
  302. goal: "Bring the focus back to the vanished mentor conflict.",
  303. },
  304. },
  305. });
  306. const factEntry = result.contextPackage.selectedContext.find((entry) =>
  307. entry.source === "story/current_state.md#current-conflict",
  308. );
  309. expect(factEntry).toBeDefined();
  310. expect(factEntry?.excerpt).toContain("Current Conflict");
  311. expect(factEntry?.excerpt).toContain("Mentor debt with the vanished teacher");
  312. });
  313. it("adds relevant volume-summary evidence for long-span retrieval after consolidation", async () => {
  314. await writeFile(
  315. join(storyDir, "volume_summaries.md"),
  316. [
  317. "# Volume Summaries",
  318. "",
  319. "## Volume 1 (Ch.1-40)",
  320. "",
  321. "Lin Yue's mentor oath becomes the core unresolved debt, while the guild route keeps trying to pull him away from the mainline.",
  322. "",
  323. "## Volume 2 (Ch.41-80)",
  324. "",
  325. "The guild route dominates logistics noise, but the mentor debt recedes into the background.",
  326. "",
  327. ].join("\n"),
  328. "utf-8",
  329. );
  330. const composer = new ComposerAgent({
  331. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  332. model: "test-model",
  333. projectRoot: root,
  334. bookId: book.id,
  335. });
  336. const result = await composer.composeChapter({
  337. book,
  338. bookDir,
  339. chapterNumber: 81,
  340. plan: {
  341. ...plan,
  342. intent: {
  343. ...plan.intent,
  344. chapter: 81,
  345. goal: "Bring the focus back to the mentor oath conflict.",
  346. },
  347. },
  348. });
  349. const volumeEntry = result.contextPackage.selectedContext.find((entry) =>
  350. entry.source.startsWith("story/volume_summaries.md#"),
  351. );
  352. expect(volumeEntry).toBeDefined();
  353. expect(volumeEntry?.excerpt).toContain("mentor oath");
  354. });
  355. it("adds explicit title history, mood trail, and canon evidence for governed writing", async () => {
  356. await Promise.all([
  357. writeFile(
  358. join(storyDir, "chapter_summaries.md"),
  359. [
  360. "# Chapter Summaries",
  361. "",
  362. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  363. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  364. "| 1 | Ledger in Rain | Lin Yue | First ledger clue appears | None | none | tight | investigation |",
  365. "| 2 | Ledger at Dusk | Lin Yue | Second ledger clue appears | None | none | tight | investigation |",
  366. "| 3 | Harbor Ledger | Lin Yue | Third ledger clue appears | None | none | tight | investigation |",
  367. "",
  368. ].join("\n"),
  369. "utf-8",
  370. ),
  371. writeFile(
  372. join(storyDir, "parent_canon.md"),
  373. "# Parent Canon\n\nThe mentor does not learn about the archive fire until volume two.\n",
  374. "utf-8",
  375. ),
  376. writeFile(
  377. join(storyDir, "fanfic_canon.md"),
  378. "# Fanfic Canon\n\nMara may diverge from the archive route, but the oath debt logic must stay intact.\n",
  379. "utf-8",
  380. ),
  381. ]);
  382. const composer = new ComposerAgent({
  383. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  384. model: "test-model",
  385. projectRoot: root,
  386. bookId: book.id,
  387. });
  388. const result = await composer.composeChapter({
  389. book,
  390. bookDir,
  391. chapterNumber: 4,
  392. plan,
  393. });
  394. const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
  395. expect(selectedSources).toContain("story/chapter_summaries.md#recent_titles");
  396. expect(selectedSources).toContain("story/chapter_summaries.md#recent_mood_type_trail");
  397. expect(selectedSources).toContain("story/parent_canon.md");
  398. expect(selectedSources).toContain("story/fanfic_canon.md");
  399. const titleEntry = result.contextPackage.selectedContext.find((entry) =>
  400. entry.source === "story/chapter_summaries.md#recent_titles",
  401. );
  402. const moodEntry = result.contextPackage.selectedContext.find((entry) =>
  403. entry.source === "story/chapter_summaries.md#recent_mood_type_trail",
  404. );
  405. const parentCanonEntry = result.contextPackage.selectedContext.find((entry) =>
  406. entry.source === "story/parent_canon.md",
  407. );
  408. const fanficCanonEntry = result.contextPackage.selectedContext.find((entry) =>
  409. entry.source === "story/fanfic_canon.md",
  410. );
  411. expect(titleEntry?.excerpt).toContain("Ledger in Rain");
  412. expect(moodEntry?.excerpt).toContain("tight / investigation");
  413. expect(parentCanonEntry?.excerpt).toContain("archive fire");
  414. expect(fanficCanonEntry?.excerpt).toContain("oath debt logic");
  415. });
  416. it("includes dedicated audit drift guidance instead of relying on current_state pollution", async () => {
  417. await writeFile(
  418. join(storyDir, "audit_drift.md"),
  419. [
  420. "# Audit Drift",
  421. "",
  422. "## 审计纠偏(自动生成,下一章写作前参照)",
  423. "",
  424. "> - [warning] 节奏单调: 最近4章章节类型持续停留在“调查章”。",
  425. ].join("\n"),
  426. "utf-8",
  427. );
  428. const composer = new ComposerAgent({
  429. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  430. model: "test-model",
  431. projectRoot: root,
  432. bookId: book.id,
  433. });
  434. const result = await composer.composeChapter({
  435. book,
  436. bookDir,
  437. chapterNumber: 4,
  438. plan,
  439. });
  440. const driftEntry = result.contextPackage.selectedContext.find((entry) =>
  441. entry.source === "story/audit_drift.md",
  442. );
  443. expect(driftEntry).toBeDefined();
  444. expect(driftEntry?.excerpt).toContain("节奏单调");
  445. });
  446. it("emits hook debt briefs for agenda-targeted hooks", async () => {
  447. await Promise.all([
  448. writeFile(
  449. join(storyDir, "pending_hooks.md"),
  450. [
  451. "# Pending Hooks",
  452. "",
  453. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
  454. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  455. "| mentor-oath | 8 | relationship | progressing | 9 | 揭开师债为何断裂 | 慢烧 | 师债需要跨更大弧线回收 |",
  456. "| guild-route | 1 | mystery | open | 2 | 查清商会路线背后的买家 | 近期 | 商会路线仍在旁支干扰 |",
  457. "",
  458. ].join("\n"),
  459. "utf-8",
  460. ),
  461. writeFile(
  462. join(storyDir, "chapter_summaries.md"),
  463. [
  464. "# Chapter Summaries",
  465. "",
  466. "| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
  467. "| 8 | River Camp | Lin Yue | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
  468. "| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  469. "",
  470. ].join("\n"),
  471. "utf-8",
  472. ),
  473. ]);
  474. const composer = new ComposerAgent({
  475. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  476. model: "test-model",
  477. projectRoot: root,
  478. bookId: book.id,
  479. });
  480. const result = await composer.composeChapter({
  481. book,
  482. bookDir,
  483. chapterNumber: 10,
  484. plan: {
  485. ...plan,
  486. intent: {
  487. ...plan.intent,
  488. chapter: 10,
  489. goal: "Bring the focus back to the mentor oath conflict.",
  490. },
  491. memo: {
  492. ...plan.memo,
  493. chapter: 10,
  494. goal: "Bring the focus back to the mentor oath conflict.",
  495. threadRefs: ["mentor-oath"],
  496. },
  497. },
  498. });
  499. const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath");
  500. expect(hookDebtEntry).toBeDefined();
  501. expect(hookDebtEntry?.excerpt).toContain("mentor-oath");
  502. expect(hookDebtEntry?.excerpt).toContain("备忘引用旧债");
  503. expect(hookDebtEntry?.excerpt).toContain("读者承诺");
  504. expect(hookDebtEntry?.excerpt).toContain("River Camp");
  505. expect(hookDebtEntry?.excerpt).toContain("Trial Echo");
  506. });
  507. it("includes memo-referenced hook ids in hook debt retrieval", async () => {
  508. await Promise.all([
  509. writeFile(
  510. join(storyDir, "pending_hooks.md"),
  511. [
  512. "# Pending Hooks",
  513. "",
  514. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
  515. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  516. "| black-ring | 6 | mystery | open | 7 | 揭开黑戒来源 | 中程 | 这一根还没进旧 hookAgenda |",
  517. "",
  518. ].join("\n"),
  519. "utf-8",
  520. ),
  521. writeFile(
  522. join(storyDir, "chapter_summaries.md"),
  523. [
  524. "# Chapter Summaries",
  525. "",
  526. "| 6 | Black Ring | Lin Yue | Black ring first surfaces | Pressure rises | black-ring seeded | uneasy | mystery |",
  527. "| 7 | Wet Dock | Lin Yue | Ring clue points to the dock | Stakes rise | black-ring advanced | tense | pursuit |",
  528. "",
  529. ].join("\n"),
  530. "utf-8",
  531. ),
  532. ]);
  533. const composer = new ComposerAgent({
  534. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  535. model: "test-model",
  536. projectRoot: root,
  537. bookId: book.id,
  538. });
  539. const result = await composer.composeChapter({
  540. book,
  541. bookDir,
  542. chapterNumber: 8,
  543. plan: {
  544. ...plan,
  545. intent: {
  546. ...plan.intent,
  547. chapter: 8,
  548. goal: "Follow the black ring pressure.",
  549. mustKeep: [],
  550. },
  551. memo: {
  552. ...plan.memo,
  553. chapter: 8,
  554. goal: "Follow the black ring pressure.",
  555. threadRefs: ["black-ring"],
  556. },
  557. },
  558. });
  559. expect(result.contextPackage.selectedContext.map((entry) => entry.source)).toContain("runtime/hook_debt#black-ring");
  560. });
  561. });