|
|
@@ -1,1619 +0,0 @@
|
|
|
-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",
|
|
|
- ),
|
|
|
- ]);
|
|
|
- });
|
|
|
-
|
|
|
- /**
|
|
|
- * Default chat mock installed on the prototype: echoes the derived goal
|
|
|
- * from the planner user prompt as the brief's goal, so legacy
|
|
|
- * goal-derivation tests keep working. Individual tests can override
|
|
|
- * with their own vi.spyOn on a specific instance.
|
|
|
- */
|
|
|
- beforeEach(() => {
|
|
|
- vi.spyOn(
|
|
|
- PlannerAgent.prototype as unknown as { chat: (...args: unknown[]) => Promise<{ content: string }> },
|
|
|
- "chat",
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
- ).mockImplementation(async (...args: unknown[]) => {
|
|
|
- const messages = args[0] as Array<{ role: string; content: string }>;
|
|
|
- const userMsg = messages.find((m) => m.role === "user");
|
|
|
- const content = userMsg?.content ?? "";
|
|
|
-
|
|
|
- function isPlaceholder(line: string): boolean {
|
|
|
- return /^\(.*\)$/.test(line) || /^\(文件尚未创建\)/.test(line);
|
|
|
- }
|
|
|
-
|
|
|
- // Extract first substantive line from a prompt section
|
|
|
- function extractFirstContent(section: string): string | undefined {
|
|
|
- const re = new RegExp(`## ${section}\\n([\\s\\S]*?)(?=\\n## |$)`);
|
|
|
- const block = content.match(re)?.[1];
|
|
|
- if (!block) return undefined;
|
|
|
- const lines = block.split("\n").map((l) => l.trim()).filter(
|
|
|
- (l) => l.length > 0 && !l.startsWith("#") && !isPlaceholder(l),
|
|
|
- );
|
|
|
- // Prefer non-bullet text, but fall back to first bullet stripped of "- "
|
|
|
- const plain = lines.find((l) => !l.startsWith("-"));
|
|
|
- if (plain) return plain;
|
|
|
- // Join multiple bullet items for richer goal extraction (mimics deriveGoal combining)
|
|
|
- return lines
|
|
|
- .filter((l) => l.startsWith("- "))
|
|
|
- .map((l) => l.slice(2))
|
|
|
- .slice(0, 3)
|
|
|
- .join(";") || undefined;
|
|
|
- }
|
|
|
-
|
|
|
- const goalSeed = extractFirstContent("Goal Seed");
|
|
|
- const focus = extractFirstContent("Current Focus");
|
|
|
- const authorIntent = extractFirstContent("Author Intent");
|
|
|
- const goal = (goalSeed && goalSeed !== "(no matched outline slice)" ? goalSeed : undefined)
|
|
|
- ?? focus
|
|
|
- ?? authorIntent
|
|
|
- ?? "advance the story";
|
|
|
- const chapterMatch = content.match(/## Chapter\n(\d+)/);
|
|
|
- const chapter = chapterMatch ? Number(chapterMatch[1]) : 3;
|
|
|
-
|
|
|
- return {
|
|
|
- content: JSON.stringify({
|
|
|
- chapter,
|
|
|
- goal,
|
|
|
- chapterType: "推进",
|
|
|
- isGoldenOpening: chapter <= 3,
|
|
|
- beatOutline: [
|
|
|
- { phase: "opening", instruction: "open scene" },
|
|
|
- { phase: "development", instruction: "develop conflict" },
|
|
|
- { phase: "hook", instruction: "end with hook" },
|
|
|
- ],
|
|
|
- hookPlan: [],
|
|
|
- propsAndSetting: [],
|
|
|
- }),
|
|
|
- };
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- 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");
|
|
|
- // goal is now LLM-derived from brief; verify conflict detection still works
|
|
|
- expect(result.intent.conflicts).toEqual(expect.arrayContaining([
|
|
|
- expect.objectContaining({
|
|
|
- type: "outline_vs_current_focus",
|
|
|
- resolution: "allow explicit current focus override",
|
|
|
- }),
|
|
|
- ]));
|
|
|
- expect(result.brief).toBeDefined();
|
|
|
- });
|
|
|
-
|
|
|
- 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.",
|
|
|
- });
|
|
|
-
|
|
|
- // goal is now LLM-derived; verify conflict detection for external override
|
|
|
- expect(result.intent.conflicts).toEqual(expect.arrayContaining([
|
|
|
- expect.objectContaining({
|
|
|
- type: "outline_vs_request",
|
|
|
- resolution: "allow local outline deferral",
|
|
|
- }),
|
|
|
- ]));
|
|
|
- expect(result.brief).toBeDefined();
|
|
|
- });
|
|
|
-
|
|
|
- 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,
|
|
|
- });
|
|
|
-
|
|
|
- // With LLM brief path, arc/scene directives are derived from the brief
|
|
|
- expect(result.intent.arcDirective).toBeDefined();
|
|
|
- expect(result.intent.sceneDirective).toBeDefined();
|
|
|
- expect(result.brief).toBeDefined();
|
|
|
- expect(result.brief.beatOutline.length).toBeGreaterThanOrEqual(1);
|
|
|
- });
|
|
|
-
|
|
|
- it("emits a cycle 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,
|
|
|
- });
|
|
|
-
|
|
|
- // With objective cycle analysis, the moodDirective now contains
|
|
|
- // cycle-phase guidance instead of the old mood-based cooldown
|
|
|
- expect(result.intent.moodDirective).toBeDefined();
|
|
|
- expect(result.intent.moodDirective).toContain("本章");
|
|
|
- });
|
|
|
-
|
|
|
- it("emits a cycle directive for varied moods based on chapter type patterns", 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,
|
|
|
- });
|
|
|
-
|
|
|
- // With the new cycle system, a cycle directive is always emitted
|
|
|
- // based on chapter type patterns rather than mood analysis
|
|
|
- expect(result.intent.moodDirective).toBeDefined();
|
|
|
- expect(result.intent.moodDirective).toContain("chapter");
|
|
|
- });
|
|
|
-
|
|
|
- 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,
|
|
|
- });
|
|
|
-
|
|
|
- // goal is now LLM-derived from brief; verify other code-derived fields
|
|
|
- expect(result.brief).toBeDefined();
|
|
|
- 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("throws 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}",
|
|
|
- });
|
|
|
-
|
|
|
- await expect(planner.planChapter({
|
|
|
- book,
|
|
|
- bookDir,
|
|
|
- chapterNumber: 3,
|
|
|
- })).rejects.toThrow("Planner LLM returned invalid ChapterBrief JSON");
|
|
|
- });
|
|
|
-});
|