Просмотр исходного кода

test: update all tests for required ChapterBrief and single-channel writer

- pipeline-runner: add default planChapter mock with valid brief in
  beforeEach; fix persisted-plan test to include Chapter Brief section;
  fix specific assertion tests with proper mock implementations
- writer: add chapterBrief to governed-path test inputs; update
  sanitization test to verify brief-path sanitization
- pipeline-agent: add planChapter mock returning valid brief
- cli-integration: pre-seed intent file with Chapter Brief section;
  switch plan tests to compose-from-persisted-plan pattern

All 659 tests pass.
Ma 7 месяцев назад
Родитель
Сommit
d1d20672cd

+ 41 - 4
packages/cli/src/__tests__/cli-integration.test.ts

@@ -763,6 +763,43 @@ describe("CLI integration", () => {
         await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
       });
 
+      const plannedGoal = "Ignore the guild chase and focus on the mentor conflict.";
+      const intentMarkdown = [
+        "# Chapter Intent",
+        "",
+        "## Goal",
+        plannedGoal,
+        "",
+        "## Outline Node",
+        "(not found)",
+        "",
+        "## Must Keep",
+        "- none",
+        "",
+        "## Must Avoid",
+        "- none",
+        "",
+        "## Style Emphasis",
+        "- none",
+        "",
+        "## Conflicts",
+        "- none",
+        "",
+        "## Chapter Brief",
+        "- chapterType: 推进",
+        "- isGoldenOpening: false",
+        "",
+        "### Beat Outline",
+        "- opening: Track the merchant guild trail",
+        "",
+        "### Hook Plan",
+        "- none",
+        "",
+        "### Props And Setting",
+        "- none",
+        "",
+      ].join("\n");
+
       await Promise.all([
         writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the story centered on the mentor conflict.\n", "utf-8"),
         writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
@@ -771,11 +808,12 @@ describe("CLI integration", () => {
         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, "runtime", "chapter-0001.intent.md"), intentMarkdown, "utf-8"),
       ]);
     });
 
-    it("runs plan chapter and returns the generated intent path in JSON mode", async () => {
-      const output = run(["plan", "chapter", "cli-book", "--json", "--context", "Ignore the guild chase and focus on the mentor conflict."]);
+    it("loads a pre-planned intent and returns the generated intent path in JSON mode", async () => {
+      const output = run(["compose", "chapter", "cli-book", "--json"]);
       const data = JSON.parse(output);
 
       expect(data.bookId).toBe("cli-book");
@@ -799,9 +837,8 @@ describe("CLI integration", () => {
       await expect(stat(join(projectDir, "books", "cli-book", data.tracePath))).resolves.toBeTruthy();
     });
 
-    it("reuses the planned intent when compose runs without a new context", async () => {
+    it("reuses the pre-planned intent when compose runs without a new context", async () => {
       const plannedGoal = "Ignore the guild chase and focus on the mentor conflict.";
-      run(["plan", "chapter", "cli-book", "--context", plannedGoal]);
 
       const output = run(["compose", "chapter", "cli-book", "--json"]);
       const data = JSON.parse(output);

+ 57 - 0
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { AGENT_TOOLS, executeAgentTool } from "../pipeline/agent.js";
 import { PipelineRunner, StateManager, type PipelineConfig } from "../index.js";
+import { PlannerAgent } from "../agents/planner.js";
 
 describe("agent pipeline tools", () => {
   let root: string;
@@ -53,6 +54,61 @@ describe("agent pipeline tools", () => {
     await mkdir(join(state.bookDir(bookId), "chapters"), { recursive: true });
     await writeFile(join(state.bookDir(bookId), "chapters", "index.json"), "[]", "utf-8");
 
+    vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async (input) => {
+      const chapterNumber = input.chapterNumber;
+      // Try to read the local override from current_focus.md, mirroring the real planner logic
+      let goal = input.externalContext ?? "test goal";
+      try {
+        const { readFile: readFs } = await import("node:fs/promises");
+        const focusContent = await readFs(join(input.bookDir, "story", "current_focus.md"), "utf-8");
+        const overrideMatch = focusContent.match(/## Local Override\s*\n+([^\n#]+)/);
+        if (overrideMatch?.[1]?.trim()) {
+          goal = overrideMatch[1].trim();
+        }
+      } catch { /* ignore missing file */ }
+      const brief = {
+        chapter: chapterNumber,
+        goal,
+        chapterType: "推进" as const,
+        isGoldenOpening: false,
+        beatOutline: [{ phase: "opening" as const, instruction: "test" }],
+        hookPlan: [] as Array<{ hookId: string; movement: "quiet-hold" | "refresh" | "advance" | "partial-payoff" | "full-payoff"; targetEffect: string }>,
+        propsAndSetting: [] as string[],
+      };
+      const intentMarkdown = [
+        "# Chapter Intent",
+        "",
+        "## Goal",
+        goal,
+      ].join("\n");
+      const { mkdir: mkdirFs, writeFile: writeFileFs } = await import("node:fs/promises");
+      const runtimeDir = join(input.bookDir, "story", "runtime");
+      await mkdirFs(runtimeDir, { recursive: true });
+      const runtimePath = join(runtimeDir, `chapter-${String(chapterNumber).padStart(4, "0")}.intent.md`);
+      await writeFileFs(runtimePath, intentMarkdown, "utf-8");
+      return {
+        intent: {
+          chapter: chapterNumber,
+          goal,
+          mustKeep: [],
+          mustAvoid: [],
+          styleEmphasis: [],
+          conflicts: [],
+          hookAgenda: {
+            pressureMap: [],
+            mustAdvance: [],
+            eligibleResolve: [],
+            staleDebt: [],
+            avoidNewHookFamilies: [],
+          },
+        },
+        brief,
+        intentMarkdown,
+        plannerInputs: [runtimePath],
+        runtimePath,
+      };
+    });
+
     await Promise.all([
       writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the story centered on the mentor conflict.\n", "utf-8"),
       writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
@@ -65,6 +121,7 @@ describe("agent pipeline tools", () => {
   });
 
   afterEach(async () => {
+    vi.restoreAllMocks();
     await rm(root, { recursive: true, force: true });
   });
 

+ 221 - 32
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -201,6 +201,80 @@ async function createRunnerFixture(
 
 describe("PipelineRunner", () => {
   beforeEach(() => {
+    vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async (input) => {
+      const chapterNumber = input.chapterNumber;
+      const goal = input.externalContext ?? "test goal";
+      const brief = {
+        chapter: chapterNumber,
+        goal,
+        chapterType: "推进" as const,
+        isGoldenOpening: false,
+        beatOutline: [{ phase: "opening" as const, instruction: "test" }],
+        hookPlan: [] as Array<{ hookId: string; movement: "quiet-hold" | "refresh" | "advance" | "partial-payoff" | "full-payoff"; targetEffect: string }>,
+        propsAndSetting: [] as string[],
+      };
+      const intentMarkdown = [
+        "# Chapter Intent",
+        "",
+        "## Goal",
+        goal,
+        "",
+        "## Outline Node",
+        "(not found)",
+        "",
+        "## Must Keep",
+        "- none",
+        "",
+        "## Must Avoid",
+        "- none",
+        "",
+        "## Style Emphasis",
+        "- none",
+        "",
+        "## Conflicts",
+        "- none",
+        "",
+        "## Chapter Brief",
+        `- chapterType: ${brief.chapterType}`,
+        `- isGoldenOpening: ${brief.isGoldenOpening}`,
+        "",
+        "### Beat Outline",
+        "- opening: test",
+        "",
+        "### Hook Plan",
+        "- none",
+        "",
+        "### Props And Setting",
+        "- none",
+        "",
+      ].join("\n");
+      const runtimeDir = join(input.bookDir, "story", "runtime");
+      const { mkdir: mkdirFs, writeFile: writeFileFs } = await import("node:fs/promises");
+      await mkdirFs(runtimeDir, { recursive: true });
+      const runtimePath = join(runtimeDir, `chapter-${String(chapterNumber).padStart(4, "0")}.intent.md`);
+      await writeFileFs(runtimePath, intentMarkdown, "utf-8");
+      return {
+        intent: {
+          chapter: chapterNumber,
+          goal,
+          mustKeep: [],
+          mustAvoid: [],
+          styleEmphasis: [],
+          conflicts: [],
+          hookAgenda: {
+            pressureMap: [],
+            mustAdvance: [],
+            eligibleResolve: [],
+            staleDebt: [],
+            avoidNewHookFamilies: [],
+          },
+        },
+        brief,
+        intentMarkdown,
+        plannerInputs: [runtimePath],
+        runtimePath,
+      };
+    });
     vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
       passed: true,
       totalScore: 85,
@@ -525,22 +599,77 @@ describe("PipelineRunner", () => {
       writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
     ]);
 
-    const originalPlanChapter = PlannerAgent.prototype.planChapter;
-    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async function (this: PlannerAgent, input) {
-      const result = await originalPlanChapter.call(this, input);
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async (input) => {
+      const runtimeDir = join(input.bookDir, "story", "runtime");
+      await mkdir(runtimeDir, { recursive: true });
+      const goal = "Ignore the guild chase and bring focus back to mentor conflict.";
+      const brief = {
+        chapter: input.chapterNumber,
+        goal,
+        chapterType: "confrontation" as const,
+        isGoldenOpening: true,
+        beatOutline: [{ phase: "opening" as const, instruction: "Open on the conflict." }],
+        hookPlan: [] as Array<{ hookId: string; movement: "quiet-hold" | "refresh" | "advance" | "partial-payoff" | "full-payoff"; targetEffect: string }>,
+        propsAndSetting: ["broken oath token"],
+      };
+      const intentMarkdown = [
+        "# Chapter Intent",
+        "",
+        "## Goal",
+        goal,
+        "",
+        "## Outline Node",
+        "Track the merchant guild trail.",
+        "",
+        "## Must Keep",
+        "- Lin Yue still hides the broken oath token.",
+        "",
+        "## Must Avoid",
+        "- none",
+        "",
+        "## Style Emphasis",
+        "- none",
+        "",
+        "## Conflicts",
+        "- outline_vs_request: allow local outline deferral",
+        "",
+        "## Chapter Brief",
+        "- chapterType: confrontation",
+        "- isGoldenOpening: true",
+        "",
+        "### Beat Outline",
+        "- opening: Open on the conflict.",
+        "",
+        "### Hook Plan",
+        "- none",
+        "",
+        "### Props And Setting",
+        "- broken oath token",
+        "",
+      ].join("\n");
+      const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
+      await writeFile(runtimePath, intentMarkdown, "utf-8");
       return {
-        ...result,
-        brief: {
+        intent: {
           chapter: input.chapterNumber,
-          goal: result.intent.goal,
-          chapterType: "confrontation",
-          isGoldenOpening: true,
-          beatOutline: [
-            { phase: "opening", instruction: "Open on the conflict." },
-          ],
-          hookPlan: [],
-          propsAndSetting: ["broken oath token"],
+          goal,
+          outlineNode: "Track the merchant guild trail.",
+          mustKeep: ["Lin Yue still hides the broken oath token."],
+          mustAvoid: [],
+          styleEmphasis: [],
+          conflicts: [{ type: "outline_vs_request", resolution: "allow local outline deferral" }],
+          hookAgenda: {
+            pressureMap: [],
+            mustAdvance: [],
+            eligibleResolve: [],
+            staleDebt: [],
+            avoidNewHookFamilies: [],
+          },
         },
+        brief,
+        intentMarkdown,
+        plannerInputs: [runtimePath],
+        runtimePath,
       };
     });
     const composeChapter = vi.spyOn(ComposerModule, "composeGovernedChapter");
@@ -612,6 +741,19 @@ describe("PipelineRunner", () => {
           "## Conflicts",
           "- outline_vs_request: allow local outline deferral",
           "",
+          "## Chapter Brief",
+          "- chapterType: confrontation",
+          "- isGoldenOpening: true",
+          "",
+          "### Beat Outline",
+          "- opening: Open on the conflict.",
+          "",
+          "### Hook Plan",
+          "- none",
+          "",
+          "### Props And Setting",
+          "- broken oath token",
+          "",
           "## Pending Hooks Snapshot",
           "- none",
           "",
@@ -623,24 +765,7 @@ describe("PipelineRunner", () => {
       ),
     ]);
 
-    const originalPlanChapter = PlannerAgent.prototype.planChapter;
-    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async function (this: PlannerAgent, input) {
-      const result = await originalPlanChapter.call(this, input);
-      return {
-        ...result,
-        brief: {
-          chapter: input.chapterNumber,
-          goal: result.intent.goal,
-          chapterType: "confrontation",
-          isGoldenOpening: true,
-          beatOutline: [
-            { phase: "opening", instruction: "Open on the conflict." },
-          ],
-          hookPlan: [],
-          propsAndSetting: ["broken oath token"],
-        },
-      };
-    });
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
     const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
         chapterNumber: 1,
@@ -1262,7 +1387,71 @@ describe("PipelineRunner", () => {
       ),
     ]);
 
-    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async (input) => {
+      const rDir = join(input.bookDir, "story", "runtime");
+      await mkdir(rDir, { recursive: true });
+      const goal = "Track the merchant guild trail.";
+      const intentMd = [
+        "# Chapter Intent",
+        "",
+        "## Goal",
+        goal,
+        "",
+        "## Outline Node",
+        "(not found)",
+        "",
+        "## Must Keep",
+        "- none",
+        "",
+        "## Must Avoid",
+        "- none",
+        "",
+        "## Style Emphasis",
+        "- none",
+        "",
+        "## Conflicts",
+        "- none",
+        "",
+        "## Chapter Brief",
+        "- chapterType: 推进",
+        "- isGoldenOpening: false",
+        "",
+        "### Beat Outline",
+        "- opening: test",
+        "",
+        "### Hook Plan",
+        "- none",
+        "",
+        "### Props And Setting",
+        "- none",
+        "",
+      ].join("\n");
+      const runtimePath = join(rDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
+      await writeFile(runtimePath, intentMd, "utf-8");
+      return {
+        intent: {
+          chapter: input.chapterNumber,
+          goal,
+          mustKeep: [],
+          mustAvoid: [],
+          styleEmphasis: [],
+          conflicts: [],
+          hookAgenda: { pressureMap: [], mustAdvance: [], eligibleResolve: [], staleDebt: [], avoidNewHookFamilies: [] },
+        },
+        brief: {
+          chapter: input.chapterNumber,
+          goal,
+          chapterType: "推进",
+          isGoldenOpening: false,
+          beatOutline: [{ phase: "opening" as const, instruction: "test" }],
+          hookPlan: [],
+          propsAndSetting: [],
+        },
+        intentMarkdown: intentMd,
+        plannerInputs: [runtimePath],
+        runtimePath,
+      };
+    });
     const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
         chapterNumber: 1,

+ 32 - 28
packages/core/src/__tests__/writer.test.ts

@@ -901,7 +901,15 @@ describe("WriterAgent", () => {
         },
         bookDir,
         chapterNumber: 4,
-        chapterIntent: "# Chapter Intent\n\n## Goal\nForce Mara back toward the ledger trail.\n",
+        chapterBrief: {
+          chapter: 4,
+          goal: "Force Mara back toward the ledger trail.",
+          chapterType: "confrontation",
+          isGoldenOpening: false,
+          beatOutline: [{ phase: "opening" as const, instruction: "Open with tension at the archive window." }],
+          hookPlan: [{ hookId: "ledger-fragment", movement: "advance" as const, targetEffect: "Mara is forced to confront the ledger." }],
+          propsAndSetting: ["archive window"],
+        },
         contextPackage: {
           chapter: 4,
           selectedContext: [
@@ -1025,7 +1033,15 @@ describe("WriterAgent", () => {
         },
         bookDir,
         chapterNumber: 4,
-        chapterIntent: "# Chapter Intent\n\n## Goal\nPush Mara back toward the archive ledger.\n",
+        chapterBrief: {
+          chapter: 4,
+          goal: "Push Mara back toward the archive ledger.",
+          chapterType: "confrontation",
+          isGoldenOpening: false,
+          beatOutline: [{ phase: "opening" as const, instruction: "Open with tension at the archive." }],
+          hookPlan: [{ hookId: "ledger-fragment", movement: "advance" as const, targetEffect: "Mara is forced to confront the ledger." }],
+          propsAndSetting: ["archive"],
+        },
         contextPackage: {
           chapter: 4,
           selectedContext: [
@@ -1168,29 +1184,18 @@ describe("WriterAgent", () => {
         },
         bookDir,
         chapterNumber: 4,
-        chapterIntent: [
-          "# Chapter Intent",
-          "",
-          "## Goal",
-          "Push Mara back toward the archive ledger.",
-          "",
-          "## Must Avoid",
-          "- 不要写成前几章回顾",
-          "- 本章要做的是推进 ledger-fragment,但不要把 H001/H002 写进正文",
-          "",
-          "## Hook Agenda",
-          "### Must Advance",
-          "- mentor-oath",
-          "",
-          "### Eligible Resolve",
-          "- ledger-fragment",
-          "",
-          "### Stale Debt",
-          "- stale-ledger",
-          "",
-          "### Avoid New Hook Families",
-          "- relationship",
-        ].join("\n"),
+        chapterBrief: {
+          chapter: 4,
+          goal: "Push Mara back toward the archive ledger.",
+          chapterType: "confrontation",
+          isGoldenOpening: false,
+          beatOutline: [{ phase: "opening" as const, instruction: "本章要做的是推进 ledger-fragment tension at the archive." }],
+          hookPlan: [
+            { hookId: "mentor-oath", movement: "advance" as const, targetEffect: "Advance the oath debt with H001 ledger-fragment callback." },
+            { hookId: "ledger-fragment", movement: "partial-payoff" as const, targetEffect: "Partially resolve stale-ledger." },
+          ],
+          propsAndSetting: ["archive"],
+        },
         contextPackage: {
           chapter: 4,
           selectedContext: [
@@ -1219,15 +1224,14 @@ describe("WriterAgent", () => {
 
       expect(systemPrompt).not.toContain("Hook-A / Hook-B");
       expect(systemPrompt).toContain("真实 hook_id");
-      expect(creativePrompt).not.toContain("## Explicit Hook Agenda");
+      // Hook IDs from the brief should be sanitized by renderBriefAsNarrativeBlock
       expect(creativePrompt).not.toContain("## Hook Agenda");
       expect(creativePrompt).not.toContain("mentor-oath");
       expect(creativePrompt).not.toContain("ledger-fragment");
       expect(creativePrompt).not.toContain("stale-ledger");
       expect(creativePrompt).not.toContain("H001");
-      expect(creativePrompt).not.toContain("H002");
-      expect(creativePrompt).not.toContain("前几章");
       expect(creativePrompt).not.toContain("本章要做的");
+      // The goal text should survive sanitization
       expect(creativePrompt).toContain("Push Mara back toward the archive ledger.");
     } finally {
       await rm(root, { recursive: true, force: true });