| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412 |
- import { afterEach, beforeEach, describe, expect, it } 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");
- });
- });
|