| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551 |
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
- import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import type { BookConfig } from "../models/book.js";
- import { PlannerAgent } from "../agents/planner.js";
- describe("PlannerAgent", () => {
- let root: string;
- let bookDir: string;
- let storyDir: string;
- let book: BookConfig;
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "inkos-planner-test-"));
- bookDir = join(root, "books", "planner-book");
- storyDir = join(bookDir, "story");
- await mkdir(join(storyDir, "runtime"), { recursive: true });
- book = {
- id: "planner-book",
- title: "Planner Book",
- platform: "tomato",
- genre: "xuanhuan",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 3000,
- createdAt: "2026-03-22T00:00:00.000Z",
- updatedAt: "2026-03-22T00:00:00.000Z",
- };
- await Promise.all([
- writeFile(
- join(storyDir, "author_intent.md"),
- "# Author Intent\n\nKeep the book emotionally centered on the mentor-student bond.\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "current_focus.md"),
- "# Current Focus\n\nBring the focus back to the mentor conflict before opening new subplots.\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "story_bible.md"),
- "# Story Bible\n\n- The jade seal cannot be destroyed.\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- "# Volume Outline\n\n## Chapter 3\nTrack the merchant guild's escape route.\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "book_rules.md"),
- "---\nprohibitions:\n - Do not reveal the mastermind\n---\n\n# Book Rules\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "current_state.md"),
- "# Current State\n\n- Lin Yue still hides the broken oath token.\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "pending_hooks.md"),
- "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- "# Chapter Summaries\n\n| 2 | Trial fallout | Mentor left without explanation |\n",
- "utf-8",
- ),
- ]);
- });
- afterEach(async () => {
- await rm(root, { recursive: true, force: true });
- });
- it("uses current focus as the chapter goal when no outline node is available", async () => {
- await writeFile(
- join(storyDir, "volume_outline.md"),
- "# Volume Outline\n",
- "utf-8",
- );
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- });
- expect(result.intent.goal).toContain("mentor conflict");
- await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict");
- });
- it("prefers a matched outline node over ordinary current focus text", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "Pull the next chapter back toward the mentor fallout instead of the guild route.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "## Chapter 3",
- "Track the merchant guild's escape route through the western canal.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- });
- expect(result.intent.outlineNode).toContain("merchant guild's escape route");
- expect(result.intent.goal).toContain("merchant guild's escape route");
- expect(result.intent.goal).not.toContain("mentor fallout");
- expect(result.intent.conflicts).toEqual([]);
- });
- it("lets explicit local override focus beat the matched outline node", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "Keep pressure on the guild route in the background.",
- "",
- "## Local Override",
- "",
- "Stay inside the mentor debt confrontation first and delay the canal pursuit by one chapter.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "## Chapter 3",
- "Track the merchant guild's escape route through the western canal.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- });
- expect(result.intent.outlineNode).toContain("merchant guild's escape route");
- expect(result.intent.goal).toContain("mentor debt confrontation");
- expect(result.intent.goal).not.toContain("merchant guild's escape route");
- expect(result.intent.conflicts).toEqual(expect.arrayContaining([
- expect.objectContaining({
- type: "outline_vs_current_focus",
- resolution: "allow explicit current focus override",
- }),
- ]));
- });
- it("keeps external context above both outline anchors and current focus", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "Pull the next chapter back toward the mentor fallout instead of the guild route.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "## Chapter 3",
- "Track the merchant guild's escape route through the western canal.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- externalContext: "Ignore the canal pursuit for now and force the next chapter into the mentor debt confrontation.",
- });
- expect(result.intent.goal).toContain("mentor debt confrontation");
- expect(result.intent.goal).not.toContain("merchant guild's escape route");
- expect(result.intent.conflicts).toEqual(expect.arrayContaining([
- expect.objectContaining({
- type: "outline_vs_request",
- resolution: "allow local outline deferral",
- }),
- ]));
- });
- it("emits structured directives when fallback planning, chapter type repetition, and title collapse stack up", async () => {
- book = {
- ...book,
- genre: "other",
- language: "en",
- };
- await Promise.all([
- writeFile(
- join(storyDir, "author_intent.md"),
- "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "## Chapter 8",
- "Expose the registry clerk's hidden ledger in the floodgate archive.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 1 | Ledger in Rain | Taryn | Taryn checks the first false folio | None | hook advanced | tight | investigation |",
- "| 2 | Ledger at Dusk | Taryn | Taryn questions the dock clerk | None | hook advanced | tight | investigation |",
- "| 3 | Ledger Below | Taryn | Taryn searches the under-archive | None | hook advanced | tight | investigation |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 4,
- });
- expect(result.intent.arcDirective).toContain("fallback");
- expect(result.intent.sceneDirective).toContain("investigation");
- expect(result.intent.titleDirective?.toLowerCase()).toContain("ledger");
- expect(result.intent.moodDirective).toBeUndefined();
- });
- it("emits a mood directive when recent chapters are all high-tension", async () => {
- book = {
- ...book,
- genre: "other",
- language: "zh",
- };
- await Promise.all([
- writeFile(
- join(storyDir, "volume_outline.md"),
- "# Volume Outline\n\n## Chapter 5\n进入新的地点。\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 1 | 暗巷追踪 | 周谨川 | 追踪目标 | None | none | 紧张、压抑 | 悬念验证章 |",
- "| 2 | 旧楼对峙 | 周谨川 | 对峙 | None | none | 冷硬、逼仄 | 冲突章 |",
- "| 3 | 夜色围堵 | 周谨川 | 围堵 | None | none | 肃杀、凝重 | 追击章 |",
- "| 4 | 地下通道 | 周谨川 | 逃脱 | None | none | 压迫、窒息 | 逃亡章 |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 5,
- });
- expect(result.intent.moodDirective).toBeDefined();
- expect(result.intent.moodDirective).toContain("降调");
- expect(result.intent.moodDirective).toContain("日常");
- });
- it("does not emit a mood directive when recent moods are varied", async () => {
- book = {
- ...book,
- genre: "other",
- language: "en",
- };
- await Promise.all([
- writeFile(
- join(storyDir, "volume_outline.md"),
- "# Volume Outline\n\n## Chapter 5\nMove to the harbor.\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 1 | Morning Calm | Taryn | A quiet walk | None | none | warm, gentle | slice-of-life |",
- "| 2 | Sudden Rain | Taryn | Storm arrives | None | none | tense, ominous | tension |",
- "| 3 | Harbor Light | Taryn | Finds shelter | None | none | hopeful, light | transition |",
- "| 4 | The Letter | Taryn | Reads bad news | None | none | melancholy, reflective | introspection |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 5,
- });
- expect(result.intent.moodDirective).toBeUndefined();
- });
- it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- "# Volume Outline\n",
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- });
- expect(result.intent.goal).toContain("mentor-student bond");
- expect(result.intent.goal).not.toContain("Describe what the next 1-3 chapters should prioritize");
- });
- it("uses bullet-style volume outline chapter nodes as the fallback goal when control docs are placeholders", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "author_intent.md"),
- "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "## Volume 1",
- "**Chapter range:** 1-8",
- "",
- "**Key turning points:**",
- "- **Chapter 3:** Track the merchant guild's escape route through the western canal.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- });
- expect(result.intent.outlineNode).toContain("merchant guild's escape route");
- expect(result.intent.goal).toContain("merchant guild's escape route");
- expect(result.intent.goal).not.toContain("Advance chapter 3 with clear narrative focus.");
- });
- it("uses the next paragraph for bold standalone English chapter labels instead of capturing markdown markers", async () => {
- book = {
- ...book,
- genre: "other",
- language: "en",
- };
- await Promise.all([
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "## Volume 1 - The Dead Examiner",
- "**Chapter Range:** 1-12",
- "",
- "**Key Turning Points:**",
- "- Ch1: Renn dies after summoning Taryn to review irregular treaty folios.",
- "",
- "### Golden First Three Chapters Rule",
- "",
- "**Chapter 2:**",
- "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.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 2,
- });
- expect(result.intent.outlineNode).toContain("Show Taryn's edge through action");
- expect(result.intent.outlineNode).not.toBe("**");
- expect(result.intent.goal).toContain("Show Taryn's edge through action");
- expect(result.intent.goal).not.toBe("**");
- });
- it("does not confuse Chapter 1 with Chapter 10 when matching exact English chapter labels", async () => {
- book = {
- ...book,
- genre: "other",
- language: "en",
- };
- await Promise.all([
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "### Chapter 10",
- "This late-volume node should not be selected for chapter one.",
- "",
- "### Chapter 1",
- "Open with the dead examiner and the sealed folio dispute.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 1,
- });
- expect(result.intent.outlineNode).toContain("dead examiner");
- expect(result.intent.outlineNode).not.toContain("late-volume");
- expect(result.intent.goal).toContain("dead examiner");
- });
- it("uses inline Chinese exact chapter labels with a title suffix", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "author_intent.md"),
- "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "第 7 章:在码头接头并截住逃跑账房。",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 7,
- });
- expect(result.intent.outlineNode).toContain("在码头接头");
- expect(result.intent.goal).toContain("在码头接头");
- expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
- });
- it("uses standalone Chinese chapter-range labels when the chapter falls inside the range", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "author_intent.md"),
- "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "第1-6章",
- "Stay with the early city setup and mentor fallout.",
- "",
- "第7-20章",
- "Track the merchant guild's escape route through the western canal.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 7,
- });
- expect(result.intent.outlineNode).toContain("merchant guild's escape route");
- expect(result.intent.goal).toContain("merchant guild's escape route");
- expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
- });
- it("uses standalone English chapter-range labels at the start of the range", async () => {
- book = {
- ...book,
- genre: "other",
- language: "en",
- };
- await Promise.all([
- writeFile(
- join(storyDir, "author_intent.md"),
- "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "Chapter 1-3",
- "Keep the opening pressure on the first examiner.",
- "",
- "Chapter 4-6",
- "Recover the sealed ledger before dawn.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 4,
- });
- expect(result.intent.outlineNode).toContain("sealed ledger");
- expect(result.intent.goal).toContain("sealed ledger");
- expect(result.intent.outlineNode).not.toContain("6");
- expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
- });
- it("uses the next paragraph for bold standalone English chapter-range labels instead of bleeding into the next range", async () => {
- book = {
- ...book,
- genre: "other",
- language: "en",
- };
- await Promise.all([
- writeFile(
- join(storyDir, "author_intent.md"),
- "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "**Chapter 1-3:**",
- "Keep the opening pressure on the first examiner.",
- "",
- "**Chapter 4-6:**",
- "Recover the sealed ledger before dawn.",
- "",
- "**Chapter 7-9:**",
- "Trigger the registry fire and expose the false witness.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 4,
- });
- expect(result.intent.outlineNode).toContain("sealed ledger");
- expect(result.intent.outlineNode).not.toContain("registry fire");
- expect(result.intent.goal).toContain("sealed ledger");
- });
- it("falls back to the first outline directive when no standalone range matches", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "author_intent.md"),
- "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "(Describe what the next 1-3 chapters should prioritize.)",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "第1-6章",
- "Stay with the early city setup and mentor fallout.",
- "",
- "第7-20章",
- "Track the merchant guild's escape route through the western canal.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 25,
- });
- expect(result.intent.outlineNode).toContain("Stay with the early city setup");
- expect(result.intent.goal).toContain("Stay with the early city setup");
- expect(result.intent.outlineNode).not.toBe("第1-6章");
- expect(result.intent.goal).not.toContain("merchant guild's escape route");
- });
- it("preserves hard facts from state and canon in mustKeep", async () => {
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- });
- expect(result.intent.mustKeep).toContain("Lin Yue still hides the broken oath token.");
- expect(result.intent.mustKeep).toContain("The jade seal cannot be destroyed.");
- });
- it("records conflicts when the external request diverges from the outline", async () => {
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- externalContext: "Ignore the guild chase and bring the focus back to mentor conflict.",
- });
- expect(result.intent.conflicts).toHaveLength(1);
- expect(result.intent.conflicts[0]?.type).toBe("outline_vs_request");
- await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("outline_vs_request");
- });
- it("writes compact memory snapshots instead of inlining the full history", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "pending_hooks.md"),
- [
- "# Pending Hooks",
- "",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
- "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
- "| old-seal | 3 | artifact | resolved | 3 | 3 | Jade seal already recovered |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
- "| 2 | City Watch | Patrols sweep the market | Search widens | None | guild-route advanced | urgent | investigation |",
- "| 3 | Seal Vault | Lin Yue finds the seal vault | The jade seal returns | Seal secured | old-seal resolved | solemn | reveal |",
- "| 4 | Empty Road | The group loses the convoy | Doubts grow | Travel fatigue | none | grim | travel |",
- "| 5 | Burned Shrine | Shrine clues point nowhere | Friction rises | Lin Yue distrusts allies | none | bitter | setback |",
- "| 6 | Quiet Ledger | Merchant records stay hidden | No breakthrough | Cash runs thin | none | weary | transition |",
- "| 7 | Broken Letter | A torn letter mentions the mentor | Suspicion returns | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
- "| 8 | River Camp | Lin Yue meets old witnesses | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
- "| 9 | Trial Echo | The trial fallout resurfaces | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
- "| 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 |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 11,
- externalContext: "Bring the focus back to the mentor oath conflict with Lin Yue.",
- });
- const intentMarkdown = await readFile(result.runtimePath, "utf-8");
- expect(intentMarkdown).toContain("mentor-oath");
- expect(intentMarkdown).toContain("| 10 | Locked Gate |");
- expect(intentMarkdown).not.toContain("| 1 | Guild Trail |");
- expect(intentMarkdown).not.toContain("| old-seal | 3 | artifact | resolved |");
- });
- it("renders English memory snapshot headers for English books", async () => {
- book = {
- ...book,
- genre: "other",
- language: "en",
- };
- await Promise.all([
- writeFile(
- join(storyDir, "pending_hooks.md"),
- [
- "# Pending Hooks",
- "",
- "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 10 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 11,
- externalContext: "Bring the focus back to the mentor oath conflict with Lin Yue.",
- });
- const intentMarkdown = await readFile(result.runtimePath, "utf-8");
- expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |");
- expect(intentMarkdown).toContain("| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |");
- expect(intentMarkdown).not.toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |");
- expect(intentMarkdown).not.toContain("| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |");
- });
- it("derives structured current_focus markdown into goal, avoids, and style emphasis", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "current_focus.md"),
- [
- "# Current Focus",
- "",
- "## Active Focus",
- "",
- "- Bring the focus back to Lin Yue's private confrontation with the mentor debt.",
- "- Keep the chapter centered on a missing record, not a whole-conspiracy overview.",
- "- Surface one concrete evidence trail the next chapter can pursue.",
- "",
- "## Avoid",
- "",
- "- Do not turn this chapter into a citywide survey of every faction.",
- "- Do not use summary-heavy moralizing paragraphs.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "story_bible.md"),
- [
- "# Story Bible",
- "",
- "- --",
- "- The jade seal cannot be destroyed.",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "volume_outline.md"),
- "# Volume Outline\n",
- "utf-8",
- ),
- ]);
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 2,
- });
- expect(result.intent.goal).toContain("private confrontation");
- expect(result.intent.goal).toContain("missing record");
- expect(result.intent.mustAvoid).toEqual(expect.arrayContaining([
- "Do not turn this chapter into a citywide survey of every faction.",
- "Do not use summary-heavy moralizing paragraphs.",
- ]));
- expect(result.intent.mustAvoid).not.toContain(
- "Keep the chapter centered on a missing record, not a whole-conspiracy overview.",
- );
- expect(result.intent.styleEmphasis).toEqual(expect.arrayContaining([
- "Bring the focus back to Lin Yue's private confrontation with the mentor debt.",
- "Surface one concrete evidence trail the next chapter can pursue.",
- ]));
- expect(result.intent.mustKeep).not.toContain("--");
- });
- it("emits hook agenda into chapter intent and runtime markdown", async () => {
- const stateDir = join(storyDir, "state");
- await mkdir(stateDir, { recursive: true });
- await Promise.all([
- writeFile(
- join(stateDir, "manifest.json"),
- JSON.stringify({
- schemaVersion: 2,
- language: "en",
- lastAppliedChapter: 25,
- projectionVersion: 1,
- migrationWarnings: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "current_state.json"),
- JSON.stringify({
- chapter: 25,
- facts: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "chapter_summaries.json"),
- JSON.stringify({
- rows: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "hooks.json"),
- JSON.stringify({
- hooks: [
- {
- hookId: "recent-route",
- startChapter: 23,
- type: "route",
- status: "open",
- lastAdvancedChapter: 25,
- expectedPayoff: "Recent route payoff",
- notes: "Recent route remains active.",
- },
- {
- hookId: "ready-payoff",
- startChapter: 12,
- type: "mystery",
- status: "progressing",
- lastAdvancedChapter: 24,
- expectedPayoff: "Reveal the hidden room mastermind",
- notes: "The chapter is close to the reveal point.",
- },
- {
- hookId: "stale-debt",
- startChapter: 3,
- type: "relationship",
- status: "open",
- lastAdvancedChapter: 8,
- expectedPayoff: "Mentor debt payoff",
- notes: "Long-stale but still unresolved.",
- },
- ],
- }, null, 2),
- "utf-8",
- ),
- ]);
- book = {
- ...book,
- genre: "other",
- language: "en",
- };
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 26,
- externalContext: "Keep the chapter on the mainline debt conflict.",
- });
- // Lifecycle scheduling: ready-payoff (progressing, mid-arc, overdue) → eligibleResolve;
- // stale-debt (dormant, stale+overdue) → staleDebt; recent-route (remainder) → mustAdvance.
- expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]);
- expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]);
- expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route"]);
- expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship");
- expect(result.intent.hookAgenda.pressureMap).toEqual([]);
- const intentMarkdown = await readFile(result.runtimePath, "utf-8");
- expect(intentMarkdown).toContain("## Hook Agenda");
- expect(intentMarkdown).toContain("ready-payoff");
- expect(intentMarkdown).toContain("stale-debt");
- });
- it("builds stale debt agenda from broader active hooks than the retrieval subset", async () => {
- const stateDir = join(storyDir, "state");
- await mkdir(stateDir, { recursive: true });
- await Promise.all([
- writeFile(
- join(stateDir, "manifest.json"),
- JSON.stringify({
- schemaVersion: 2,
- language: "en",
- lastAppliedChapter: 25,
- projectionVersion: 1,
- migrationWarnings: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "current_state.json"),
- JSON.stringify({
- chapter: 25,
- facts: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "chapter_summaries.json"),
- JSON.stringify({
- rows: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "hooks.json"),
- JSON.stringify({
- hooks: [
- {
- hookId: "recent-route",
- startChapter: 23,
- type: "route",
- status: "open",
- lastAdvancedChapter: 25,
- expectedPayoff: "Recent route payoff",
- notes: "Keep the route central.",
- },
- {
- hookId: "recent-guild",
- startChapter: 22,
- type: "politics",
- status: "progressing",
- lastAdvancedChapter: 24,
- expectedPayoff: "Guild pressure payoff",
- notes: "Guild pressure remains active.",
- },
- {
- hookId: "recent-token",
- startChapter: 21,
- type: "artifact",
- status: "open",
- lastAdvancedChapter: 23,
- expectedPayoff: "Token route payoff",
- notes: "Token route remains active.",
- },
- {
- hookId: "stale-omega",
- startChapter: 3,
- type: "relationship",
- status: "open",
- lastAdvancedChapter: 8,
- expectedPayoff: "Old debt payoff",
- notes: "Dormant unresolved line.",
- },
- {
- hookId: "stale-sable",
- startChapter: 4,
- type: "mystery",
- status: "open",
- lastAdvancedChapter: 9,
- expectedPayoff: "Archive payoff",
- notes: "Another dormant unresolved line.",
- },
- ],
- }, null, 2),
- "utf-8",
- ),
- ]);
- book = {
- ...book,
- genre: "other",
- language: "en",
- };
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 26,
- externalContext: "Keep the chapter on the route pressure.",
- });
- // Lifecycle scheduling: stale-omega and stale-sable are stale+overdue → staleDebt;
- // recent-token and recent-route are not stale/overdue → mustAdvance (by advancePressure).
- expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-omega", "stale-sable"]);
- expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-token", "recent-route"]);
- expect(result.intent.hookAgenda.avoidNewHookFamilies).toEqual(expect.arrayContaining([
- "relationship",
- "mystery",
- ]));
- expect(result.intent.hookAgenda.pressureMap).toEqual([]);
- });
- it("renders hook budget from total active hooks instead of the selected hook snapshot", async () => {
- const stateDir = join(storyDir, "state");
- await mkdir(stateDir, { recursive: true });
- const hooks = Array.from({ length: 12 }, (_, index) => ({
- hookId: `hook-${index + 1}`,
- startChapter: index + 1,
- type: index < 6 ? "route" : "mystery",
- status: "open",
- lastAdvancedChapter: index < 6 ? 25 - index : 12 - index,
- expectedPayoff: index < 6 ? "Route debt payoff" : "Dormant mystery payoff",
- notes: index < 6
- ? `Route pressure thread ${index + 1} stays relevant.`
- : `Dormant thread ${index + 1} should not be selected into the primary context.`,
- }));
- await Promise.all([
- writeFile(
- join(stateDir, "manifest.json"),
- JSON.stringify({
- schemaVersion: 2,
- language: "en",
- lastAppliedChapter: 25,
- projectionVersion: 1,
- migrationWarnings: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "current_state.json"),
- JSON.stringify({
- chapter: 25,
- facts: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "chapter_summaries.json"),
- JSON.stringify({
- rows: [],
- }, null, 2),
- "utf-8",
- ),
- writeFile(
- join(stateDir, "hooks.json"),
- JSON.stringify({ hooks }, null, 2),
- "utf-8",
- ),
- ]);
- book = {
- ...book,
- genre: "other",
- language: "en",
- targetChapters: 40,
- };
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 26,
- externalContext: "Keep the chapter on the route pressure.",
- });
- const intentMarkdown = await readFile(result.runtimePath, "utf-8");
- expect(intentMarkdown).toContain("12 active hooks");
- expect(intentMarkdown).not.toContain("8 active hooks");
- });
- it("matches multi-line 章节范围 format and distributes beats across chapters", async () => {
- await writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "## 已有章节部分",
- "",
- "### 卷一:开端",
- "- **章节范围**:1-3章",
- "- **核心冲突**:初始设定",
- "- **关键转折**:",
- " 1. 主角登场并发现异常",
- " 2. 遭遇第一次危机",
- " 3. 获得关键线索",
- "",
- "## 后续预测部分",
- "",
- "### 卷二:深入",
- "- **章节范围**:4-8章",
- "- **核心冲突**:深入调查",
- "- **关键转折**:",
- " 1. 抵达新地点,发现物证",
- " 2. 与对手首次正面交锋",
- " 3. 盟友背叛,局势逆转",
- " 4. 主角被迫转入地下",
- " 5. 发现幕后真相的第一条线",
- "",
- ].join("\n"),
- "utf-8",
- );
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- // Chapter 4 should get beat 0 (first beat of 卷二): "抵达新地点"
- const result4 = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 4,
- });
- expect(result4.intent.outlineNode).toContain("抵达新地点");
- // Chapter 6 should get beat 2 (third beat): "盟友背叛"
- const result6 = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 6,
- });
- expect(result6.intent.outlineNode).toContain("盟友背叛");
- // Chapter 8 should get beat 4 (fifth beat): "幕后真相"
- const result8 = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 8,
- });
- expect(result8.intent.outlineNode).toContain("幕后真相");
- // Chapter 5 should get beat 1: "正面交锋"
- const result5 = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 5,
- });
- expect(result5.intent.outlineNode).toContain("正面交锋");
- });
- it("uses the LLM chapter brief when planner output is valid", async () => {
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- vi.spyOn(planner as unknown as { chat: (...args: unknown[]) => Promise<{ content: string }> }, "chat")
- .mockResolvedValue({
- content: JSON.stringify({
- chapter: 3,
- goal: "Force the warehouse confrontation before the guild escapes.",
- chapterType: "confrontation",
- isGoldenOpening: true,
- beatOutline: [
- { phase: "opening", instruction: "Open inside the warehouse argument." },
- { phase: "development", instruction: "Push the unsigned transfer sheet into the scene." },
- { phase: "hook", instruction: "End with the escape route changing hands." },
- ],
- hookPlan: [
- {
- hookId: "H019",
- movement: "advance",
- targetEffect: "Turn the mentor debt into an immediate cost.",
- },
- ],
- dormantReason: "Hold the canal line until the next chapter.",
- propsAndSetting: ["warehouse transfer sheet", "wet dock", "A Sheng"],
- }),
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- });
- expect(result.brief?.chapterType).toBe("confrontation");
- expect(result.intent.goal).toContain("warehouse confrontation");
- expect(result.intent.arcDirective).toContain("opening: Open inside the warehouse argument.");
- expect(result.intent.sceneDirective).toContain("warehouse transfer sheet");
- await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("## Chapter Brief");
- });
- it("falls back to the legacy planner when the LLM chapter brief is invalid", async () => {
- const planner = new PlannerAgent({
- client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- vi.spyOn(planner as unknown as { chat: (...args: unknown[]) => Promise<{ content: string }> }, "chat")
- .mockResolvedValue({
- content: "{\"bad\":true}",
- });
- const result = await planner.planChapter({
- book,
- bookDir,
- chapterNumber: 3,
- });
- expect(result.brief).toBeUndefined();
- expect(result.intent.goal).toContain("merchant guild's escape route");
- await expect(readFile(result.runtimePath, "utf-8")).resolves.not.toContain("## Chapter Brief");
- });
- });
|