| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616 |
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
- import { createRequire } from "node:module";
- import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import type { BookConfig } from "../models/book.js";
- import type { PlanChapterOutput } from "../agents/planner.js";
- import { ComposerAgent } from "../agents/composer.js";
- const require = createRequire(import.meta.url);
- const hasNodeSqlite = (() => {
- try {
- require("node:sqlite");
- return true;
- } catch {
- return false;
- }
- })();
- describe("ComposerAgent", () => {
- let root: string;
- let bookDir: string;
- let storyDir: string;
- let book: BookConfig;
- let plan: PlanChapterOutput;
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "inkos-composer-test-"));
- bookDir = join(root, "books", "composer-book");
- storyDir = join(bookDir, "story");
- await mkdir(join(storyDir, "runtime"), { recursive: true });
- book = {
- id: "composer-book",
- title: "Composer 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 pressure on the mentor conflict.\n", "utf-8"),
- writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring the focus back to the mentor conflict.\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 4\nTrack the merchant guild trail.\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, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
- ]);
- const runtimePath = join(storyDir, "runtime", "chapter-0004.intent.md");
- await writeFile(runtimePath, "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor conflict.\n", "utf-8");
- plan = {
- intent: {
- chapter: 4,
- goal: "Bring the focus back to the mentor conflict.",
- outlineNode: "Track the merchant guild trail.",
- mustKeep: [
- "Lin Yue still hides the broken oath token.",
- "The jade seal cannot be destroyed.",
- ],
- mustAvoid: ["Do not reveal the mastermind."],
- styleEmphasis: ["character conflict", "tight POV"],
- },
- memo: {
- chapter: 4,
- goal: "Bring the focus back to the mentor conflict.",
- isGoldenOpening: false,
- body: "",
- threadRefs: [],
- },
- intentMarkdown: "# Chapter Intent\n",
- plannerInputs: [
- join(storyDir, "author_intent.md"),
- join(storyDir, "current_focus.md"),
- join(storyDir, "story_bible.md"),
- join(storyDir, "volume_outline.md"),
- join(storyDir, "current_state.md"),
- ],
- runtimePath,
- };
- });
- afterEach(async () => {
- await rm(root, { recursive: true, force: true });
- });
- it("selects only the relevant context and writes a context package", async () => {
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 4,
- plan,
- });
- const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
- expect(selectedSources.slice(0, 5)).toEqual([
- "runtime/chapter_memo",
- "story/current_focus.md",
- "story/current_state.md",
- "story/story_bible.md",
- "story/volume_outline.md",
- ]);
- expect(selectedSources.some((source) => source.startsWith("story/pending_hooks.md"))).toBe(true);
- expect(selectedSources).not.toContain("story/style_guide.md");
- await expect(readFile(result.contextPath, "utf-8")).resolves.toContain("current_focus.md");
- });
- it("emits a rule stack with hard, soft, and diagnostic sections", async () => {
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 4,
- plan,
- });
- expect(result.ruleStack.sections.hard).toContain("story_bible");
- expect(result.ruleStack.sections.soft).toContain("author_intent");
- expect(result.ruleStack.sections.diagnostic).toContain("anti_ai_checks");
- // activeOverrides dropped with ChapterConflict removal (Phase 1 transitional)
- expect(result.ruleStack.activeOverrides).toEqual([]);
- });
- it("writes trace output describing planner inputs and selected sources", async () => {
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 4,
- plan,
- });
- expect(result.trace.plannerInputs).toEqual(plan.plannerInputs);
- expect(result.trace.selectedSources).toContain("story/current_focus.md");
- // trace.notes dropped with ChapterConflict removal (Phase 1 transitional)
- expect(result.trace.notes).toEqual([]);
- await expect(readFile(result.tracePath, "utf-8")).resolves.toContain("story/current_focus.md");
- });
- it("retrieves summary and hook evidence chunks instead of whole long memory files", 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 |",
- "| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
- "| 8 | River Camp | Lin Yue, Mentor Witness | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
- "| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const longRangePlan: PlanChapterOutput = {
- ...plan,
- intent: {
- ...plan.intent,
- chapter: 10,
- goal: "Bring the focus back to the mentor oath conflict.",
- outlineNode: "Track the merchant guild trail.",
- },
- };
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 10,
- plan: longRangePlan,
- });
- const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
- expect(selectedSources).toContain("story/pending_hooks.md#mentor-oath");
- expect(selectedSources).toContain("story/chapter_summaries.md#9");
- expect(selectedSources).not.toContain("story/pending_hooks.md");
- if (hasNodeSqlite) {
- await expect(stat(join(storyDir, "memory.db"))).resolves.toBeTruthy();
- }
- });
- it("surfaces stale unresolved hook evidence in governed context selection", 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: 22,
- type: "route",
- status: "open",
- lastAdvancedChapter: 24,
- expectedPayoff: "Recent route payoff",
- notes: "Recent but not critical.",
- },
- {
- 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"),
- ]);
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 26,
- plan: {
- ...plan,
- intent: {
- ...plan.intent,
- chapter: 26,
- goal: "Keep the chapter on the mainline debt conflict.",
- },
- },
- });
- const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
- expect(selectedSources).toContain("story/pending_hooks.md#recent-route");
- expect(selectedSources).toContain("story/pending_hooks.md#stale-debt");
- });
- it("adds current-state fact evidence retrieved from sqlite-backed memory", async () => {
- await writeFile(
- join(storyDir, "current_state.md"),
- [
- "# Current State",
- "",
- "| Field | Value |",
- "| --- | --- |",
- "| Current Chapter | 9 |",
- "| Current Location | Ashen ferry crossing |",
- "| Protagonist State | Lin Yue hides the broken oath token and the old wound has reopened. |",
- "| Current Goal | Find the vanished mentor before the guild covers its tracks. |",
- "| Current Conflict | Mentor debt with the vanished teacher blocks every choice. |",
- "",
- ].join("\n"),
- "utf-8",
- );
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 10,
- plan: {
- ...plan,
- intent: {
- ...plan.intent,
- chapter: 10,
- goal: "Bring the focus back to the vanished mentor conflict.",
- },
- },
- });
- const factEntry = result.contextPackage.selectedContext.find((entry) =>
- entry.source === "story/current_state.md#current-conflict",
- );
- expect(factEntry).toBeDefined();
- expect(factEntry?.excerpt).toContain("Current Conflict");
- expect(factEntry?.excerpt).toContain("Mentor debt with the vanished teacher");
- });
- it("adds relevant volume-summary evidence for long-span retrieval after consolidation", async () => {
- await writeFile(
- join(storyDir, "volume_summaries.md"),
- [
- "# Volume Summaries",
- "",
- "## Volume 1 (Ch.1-40)",
- "",
- "Lin Yue's mentor oath becomes the core unresolved debt, while the guild route keeps trying to pull him away from the mainline.",
- "",
- "## Volume 2 (Ch.41-80)",
- "",
- "The guild route dominates logistics noise, but the mentor debt recedes into the background.",
- "",
- ].join("\n"),
- "utf-8",
- );
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 81,
- plan: {
- ...plan,
- intent: {
- ...plan.intent,
- chapter: 81,
- goal: "Bring the focus back to the mentor oath conflict.",
- },
- },
- });
- const volumeEntry = result.contextPackage.selectedContext.find((entry) =>
- entry.source.startsWith("story/volume_summaries.md#"),
- );
- expect(volumeEntry).toBeDefined();
- expect(volumeEntry?.excerpt).toContain("mentor oath");
- });
- it("adds explicit title history, mood trail, and canon evidence for governed writing", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 1 | Ledger in Rain | Lin Yue | First ledger clue appears | None | none | tight | investigation |",
- "| 2 | Ledger at Dusk | Lin Yue | Second ledger clue appears | None | none | tight | investigation |",
- "| 3 | Harbor Ledger | Lin Yue | Third ledger clue appears | None | none | tight | investigation |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "parent_canon.md"),
- "# Parent Canon\n\nThe mentor does not learn about the archive fire until volume two.\n",
- "utf-8",
- ),
- writeFile(
- join(storyDir, "fanfic_canon.md"),
- "# Fanfic Canon\n\nMara may diverge from the archive route, but the oath debt logic must stay intact.\n",
- "utf-8",
- ),
- ]);
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 4,
- plan,
- });
- const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
- expect(selectedSources).toContain("story/chapter_summaries.md#recent_titles");
- expect(selectedSources).toContain("story/chapter_summaries.md#recent_mood_type_trail");
- expect(selectedSources).toContain("story/parent_canon.md");
- expect(selectedSources).toContain("story/fanfic_canon.md");
- const titleEntry = result.contextPackage.selectedContext.find((entry) =>
- entry.source === "story/chapter_summaries.md#recent_titles",
- );
- const moodEntry = result.contextPackage.selectedContext.find((entry) =>
- entry.source === "story/chapter_summaries.md#recent_mood_type_trail",
- );
- const parentCanonEntry = result.contextPackage.selectedContext.find((entry) =>
- entry.source === "story/parent_canon.md",
- );
- const fanficCanonEntry = result.contextPackage.selectedContext.find((entry) =>
- entry.source === "story/fanfic_canon.md",
- );
- expect(titleEntry?.excerpt).toContain("Ledger in Rain");
- expect(moodEntry?.excerpt).toContain("tight / investigation");
- expect(parentCanonEntry?.excerpt).toContain("archive fire");
- expect(fanficCanonEntry?.excerpt).toContain("oath debt logic");
- });
- it("includes dedicated audit drift guidance instead of relying on current_state pollution", async () => {
- await writeFile(
- join(storyDir, "audit_drift.md"),
- [
- "# Audit Drift",
- "",
- "## 审计纠偏(自动生成,下一章写作前参照)",
- "",
- "> - [warning] 节奏单调: 最近4章章节类型持续停留在“调查章”。",
- ].join("\n"),
- "utf-8",
- );
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 4,
- plan,
- });
- const driftEntry = result.contextPackage.selectedContext.find((entry) =>
- entry.source === "story/audit_drift.md",
- );
- expect(driftEntry).toBeDefined();
- expect(driftEntry?.excerpt).toContain("节奏单调");
- });
- it("emits hook debt briefs for agenda-targeted hooks", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "pending_hooks.md"),
- [
- "# Pending Hooks",
- "",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| mentor-oath | 8 | relationship | progressing | 9 | 揭开师债为何断裂 | 慢烧 | 师债需要跨更大弧线回收 |",
- "| guild-route | 1 | mystery | open | 2 | 查清商会路线背后的买家 | 近期 | 商会路线仍在旁支干扰 |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
- "| 8 | River Camp | Lin Yue | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
- "| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 10,
- plan: {
- ...plan,
- intent: {
- ...plan.intent,
- chapter: 10,
- goal: "Bring the focus back to the mentor oath conflict.",
- },
- memo: {
- ...plan.memo,
- chapter: 10,
- goal: "Bring the focus back to the mentor oath conflict.",
- threadRefs: ["mentor-oath"],
- },
- },
- });
- const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath");
- expect(hookDebtEntry).toBeDefined();
- expect(hookDebtEntry?.excerpt).toContain("mentor-oath");
- expect(hookDebtEntry?.excerpt).toContain("备忘引用旧债");
- expect(hookDebtEntry?.excerpt).toContain("读者承诺");
- expect(hookDebtEntry?.excerpt).toContain("River Camp");
- expect(hookDebtEntry?.excerpt).toContain("Trial Echo");
- });
- it("includes memo-referenced hook ids in hook debt retrieval", async () => {
- await Promise.all([
- writeFile(
- join(storyDir, "pending_hooks.md"),
- [
- "# Pending Hooks",
- "",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| black-ring | 6 | mystery | open | 7 | 揭开黑戒来源 | 中程 | 这一根还没进旧 hookAgenda |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| 6 | Black Ring | Lin Yue | Black ring first surfaces | Pressure rises | black-ring seeded | uneasy | mystery |",
- "| 7 | Wet Dock | Lin Yue | Ring clue points to the dock | Stakes rise | black-ring advanced | tense | pursuit |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const composer = new ComposerAgent({
- client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
- model: "test-model",
- projectRoot: root,
- bookId: book.id,
- });
- const result = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber: 8,
- plan: {
- ...plan,
- intent: {
- ...plan.intent,
- chapter: 8,
- goal: "Follow the black ring pressure.",
- mustKeep: [],
- },
- memo: {
- ...plan.memo,
- chapter: 8,
- goal: "Follow the black ring pressure.",
- threadRefs: ["black-ring"],
- },
- },
- });
- expect(result.contextPackage.selectedContext.map((entry) => entry.source)).toContain("runtime/hook_debt#black-ring");
- });
- });
|