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

fix: require branch choice before continuing interactive writing

Ma 7 месяцев назад
Родитель
Сommit
12ddf28777

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

@@ -180,4 +180,114 @@ describe("agent pipeline tools", () => {
 
 
     expect(result.error).toContain("章节进度");
     expect(result.error).toContain("章节进度");
   });
   });
+
+  it("blocks write_draft when an interactive branch is waiting for a reader choice", async () => {
+    const baseBook = await state.loadBookConfig(bookId);
+    await state.saveBookConfig(bookId, {
+      ...baseBook,
+      narrativeMode: "interactive-tree",
+    });
+    const tree = await state.ensureInteractiveTree(bookId);
+    await state.saveBranchTree(bookId, {
+      ...tree,
+      nodes: [
+        {
+          ...tree.nodes[0]!,
+          status: "awaiting-choice",
+          snapshotRef: { chapterNumber: 1 },
+          chapterIds: ["ch-0001"],
+        },
+      ],
+      choices: [
+        {
+          choiceId: "choice-root-1-1",
+          fromNodeId: "root",
+          toNodeId: "root-1-a",
+          label: "接受交易",
+          intent: "接受交易,先进入档案室。",
+          immediateGoal: "今晚进入档案室。",
+          expectedCost: "欠下一笔人情。",
+          expectedRisk: "后续会被监视。",
+          hookPressure: "看守线推进。",
+          characterPressure: "同伴信任下降。",
+          tone: "紧张",
+          selected: false,
+        },
+      ],
+    });
+
+    const writeDraft = vi.spyOn(pipeline, "writeDraft").mockResolvedValue({
+      chapterNumber: 2,
+      title: "Should Not Run",
+      wordCount: 100,
+      filePath: "books/agent-book/chapters/0002_Should_Not_Run.md",
+    });
+
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "write_draft",
+      { bookId },
+    ));
+
+    expect(result.error).toMatch(/分支|选项|choice/i);
+    expect(writeDraft).not.toHaveBeenCalled();
+  });
+
+  it("blocks write_full_pipeline when an interactive branch is waiting for a reader choice", async () => {
+    const baseBook = await state.loadBookConfig(bookId);
+    await state.saveBookConfig(bookId, {
+      ...baseBook,
+      narrativeMode: "interactive-tree",
+    });
+    const tree = await state.ensureInteractiveTree(bookId);
+    await state.saveBranchTree(bookId, {
+      ...tree,
+      nodes: [
+        {
+          ...tree.nodes[0]!,
+          status: "awaiting-choice",
+          snapshotRef: { chapterNumber: 1 },
+          chapterIds: ["ch-0001"],
+        },
+      ],
+      choices: [
+        {
+          choiceId: "choice-root-1-1",
+          fromNodeId: "root",
+          toNodeId: "root-1-a",
+          label: "接受交易",
+          intent: "接受交易,先进入档案室。",
+          immediateGoal: "今晚进入档案室。",
+          expectedCost: "欠下一笔人情。",
+          expectedRisk: "后续会被监视。",
+          hookPressure: "看守线推进。",
+          characterPressure: "同伴信任下降。",
+          tone: "紧张",
+          selected: false,
+        },
+      ],
+    });
+
+    const writeNextChapter = vi.spyOn(pipeline, "writeNextChapter").mockResolvedValue({
+      chapterNumber: 2,
+      title: "Should Not Run",
+      wordCount: 100,
+      auditResult: { passed: true, issues: [], summary: "ok" },
+      revised: false,
+      status: "ready-for-review",
+    });
+
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "write_full_pipeline",
+      { bookId, count: 1 },
+    ));
+
+    expect(result.error).toMatch(/分支|选项|choice/i);
+    expect(writeNextChapter).not.toHaveBeenCalled();
+  });
 });
 });

+ 58 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -524,6 +524,64 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("refuses to continue interactive writing while the active branch awaits a choice", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const baseBook = await state.loadBookConfig(bookId);
+    await state.saveBookConfig(bookId, {
+      ...baseBook,
+      narrativeMode: "interactive-tree",
+    });
+    const tree = await state.ensureInteractiveTree(bookId);
+    await state.saveBranchTree(bookId, {
+      ...tree,
+      nodes: [
+        {
+          ...tree.nodes[0]!,
+          status: "awaiting-choice",
+          snapshotRef: { chapterNumber: 1 },
+          chapterIds: ["ch-0001"],
+        },
+        {
+          nodeId: "root-1-a",
+          parentNodeId: "root",
+          sourceChapterId: "ch-0001",
+          sourceChapterNumber: 1,
+          branchDepth: 1,
+          branchLabel: "接受交易",
+          status: "dormant",
+          snapshotRef: { chapterNumber: 1 },
+          selectedChoiceId: null,
+          chapterIds: [],
+          displayPath: "main.a",
+        },
+      ],
+      choices: [
+        {
+          choiceId: "choice-root-1-1",
+          fromNodeId: "root",
+          toNodeId: "root-1-a",
+          label: "接受交易",
+          intent: "接受交易,先进入档案室。",
+          immediateGoal: "今晚进入档案室。",
+          expectedCost: "欠下一笔人情。",
+          expectedRisk: "后续会被监视。",
+          hookPressure: "看守线推进。",
+          characterPressure: "同伴信任下降。",
+          tone: "紧张",
+          selected: false,
+        },
+      ],
+    });
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(createWriterOutput());
+
+    try {
+      await expect(runner.writeNextChapter(bookId)).rejects.toThrow(/choice|分支|选项/i);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("bootstraps missing control documents for legacy books before writing", async () => {
   it("bootstraps missing control documents for legacy books before writing", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
     const { root, runner, bookId } = await createRunnerFixture();
 
 

+ 4 - 0
packages/core/src/pipeline/agent.ts

@@ -615,6 +615,10 @@ async function getSequentialWriteGuardError(
   bookId: string,
   bookId: string,
   toolName: "write_draft" | "write_full_pipeline",
   toolName: "write_draft" | "write_full_pipeline",
 ): Promise<string | null> {
 ): Promise<string | null> {
+  const pendingInteractiveChoice = await state.getPendingInteractiveChoice(bookId);
+  if (pendingInteractiveChoice) {
+    return `${toolName} 不能继续写作:互动分支 ${pendingInteractiveChoice.activeNodeId} 还在等待读者选择。请先完成分支选择,再继续写下一章。`;
+  }
   const nextNum = await state.getNextChapterNumber(bookId);
   const nextNum = await state.getNextChapterNumber(bookId);
   const index = await state.loadChapterIndex(bookId);
   const index = await state.loadChapterIndex(bookId);
   if (index.length === 0) return null;
   if (index.length === 0) return null;

+ 18 - 0
packages/core/src/pipeline/runner.ts

@@ -408,6 +408,7 @@ export class PipelineRunner {
     try {
     try {
       await this.state.ensureControlDocuments(bookId);
       await this.state.ensureControlDocuments(bookId);
       const book = await this.state.loadBookConfig(bookId);
       const book = await this.state.loadBookConfig(bookId);
+      await this.assertInteractiveChoiceSelected(bookId, book);
       const bookDir = this.state.bookDir(bookId);
       const bookDir = this.state.bookDir(bookId);
       const chapterNumber = await this.state.getNextChapterNumber(bookId);
       const chapterNumber = await this.state.getNextChapterNumber(bookId);
       const stageLanguage = await this.resolveBookLanguage(book);
       const stageLanguage = await this.resolveBookLanguage(book);
@@ -950,6 +951,7 @@ export class PipelineRunner {
   private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
   private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
     await this.state.ensureControlDocuments(bookId);
     await this.state.ensureControlDocuments(bookId);
     const book = await this.state.loadBookConfig(bookId);
     const book = await this.state.loadBookConfig(bookId);
+    await this.assertInteractiveChoiceSelected(bookId, book);
     const bookDir = this.state.bookDir(bookId);
     const bookDir = this.state.bookDir(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);
     const stageLanguage = await this.resolveBookLanguage(book);
     const stageLanguage = await this.resolveBookLanguage(book);
@@ -1864,6 +1866,22 @@ ${matrix}`,
     return `ch-${String(chapterNumber).padStart(4, "0")}`;
     return `ch-${String(chapterNumber).padStart(4, "0")}`;
   }
   }
 
 
+  private async assertInteractiveChoiceSelected(bookId: string, book: Pick<BookConfig, "narrativeMode">): Promise<void> {
+    if (book.narrativeMode !== "interactive-tree") {
+      return;
+    }
+
+    const pending = await this.state.getPendingInteractiveChoice(bookId);
+    if (!pending) {
+      return;
+    }
+
+    throw new Error(
+      `Interactive branch ${pending.activeNodeId} is waiting for a choice. ` +
+      `Choose a branch before continuing writing.`,
+    );
+  }
+
   private async buildPersistenceOutput(
   private async buildPersistenceOutput(
     bookId: string,
     bookId: string,
     book: BookConfig,
     book: BookConfig,

+ 27 - 0
packages/core/src/state/manager.ts

@@ -312,6 +312,33 @@ export class StateManager {
     return this.ensureInteractiveTreeAt(this.bookDir(bookId));
     return this.ensureInteractiveTreeAt(this.bookDir(bookId));
   }
   }
 
 
+  async getPendingInteractiveChoice(bookId: string): Promise<{
+    activeNodeId: string;
+    choiceIds: ReadonlyArray<string>;
+  } | null> {
+    const book = await this.loadBookConfig(bookId).catch(() => null);
+    if (!book || book.narrativeMode !== "interactive-tree") {
+      return null;
+    }
+
+    const tree = await this.loadBranchTree(bookId);
+    if (!tree) {
+      return null;
+    }
+
+    const activeNode = tree.nodes.find((node) => node.nodeId === tree.activeNodeId);
+    if (!activeNode || activeNode.status !== "awaiting-choice") {
+      return null;
+    }
+
+    return {
+      activeNodeId: activeNode.nodeId,
+      choiceIds: tree.choices
+        .filter((choice) => choice.fromNodeId === activeNode.nodeId && !choice.selected)
+        .map((choice) => choice.choiceId),
+    };
+  }
+
   async ensureInteractiveTreeAt(bookDir: string): Promise<InteractiveBranchTree> {
   async ensureInteractiveTreeAt(bookDir: string): Promise<InteractiveBranchTree> {
     const existing = await this.loadBranchTreeAt(bookDir);
     const existing = await this.loadBranchTreeAt(bookDir);
     if (existing) {
     if (existing) {