| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273 |
- import { afterEach, beforeEach, describe, expect, it, vi } 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 { PipelineRunner } from "../pipeline/runner.js";
- import { StateManager } from "../state/manager.js";
- import { ArchitectAgent } from "../agents/architect.js";
- import { PlannerAgent } from "../agents/planner.js";
- import { ComposerAgent } from "../agents/composer.js";
- import { WriterAgent, type WriteChapterOutput } from "../agents/writer.js";
- import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
- import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/continuity.js";
- import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
- import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
- import type { BookConfig } from "../models/book.js";
- import type { ChapterMeta } from "../models/chapter.js";
- import { MemoryDB } from "../state/memory-db.js";
- import * as memoryDbModule from "../state/memory-db.js";
- import { countChapterLength } from "../utils/length-metrics.js";
- const require = createRequire(import.meta.url);
- const hasNodeSqlite = (() => {
- try {
- require("node:sqlite");
- return true;
- } catch {
- return false;
- }
- })();
- const sqliteIt = hasNodeSqlite ? it : it.skip;
- const ZERO_USAGE = {
- promptTokens: 0,
- completionTokens: 0,
- totalTokens: 0,
- } as const;
- const CRITICAL_ISSUE: AuditIssue = {
- severity: "critical",
- category: "continuity",
- description: "Fix the chapter state",
- suggestion: "Repair the contradiction",
- };
- function createAuditResult(overrides: Partial<AuditResult>): AuditResult {
- return {
- passed: true,
- issues: [],
- summary: "ok",
- tokenUsage: ZERO_USAGE,
- ...overrides,
- };
- }
- function createWriterOutput(overrides: Partial<WriteChapterOutput> = {}): WriteChapterOutput {
- return {
- chapterNumber: 1,
- title: "Test Chapter",
- content: "Original chapter body.",
- wordCount: "Original chapter body.".length,
- preWriteCheck: "check",
- postSettlement: "settled",
- updatedState: "writer state",
- updatedLedger: "writer ledger",
- updatedHooks: "writer hooks",
- chapterSummary: "| 1 | Original summary |",
- updatedSubplots: "writer subplots",
- updatedEmotionalArcs: "writer emotions",
- updatedCharacterMatrix: "writer matrix",
- postWriteErrors: [],
- postWriteWarnings: [],
- tokenUsage: ZERO_USAGE,
- ...overrides,
- };
- }
- function createReviseOutput(overrides: Partial<ReviseOutput> = {}): ReviseOutput {
- return {
- revisedContent: "Revised chapter body.",
- wordCount: "Revised chapter body.".length,
- fixedIssues: ["fixed"],
- updatedState: "revised state",
- updatedLedger: "revised ledger",
- updatedHooks: "revised hooks",
- tokenUsage: ZERO_USAGE,
- ...overrides,
- };
- }
- function createAnalyzedOutput(overrides: Partial<WriteChapterOutput> = {}): WriteChapterOutput {
- return createWriterOutput({
- content: "Analyzed final chapter body.",
- wordCount: "Analyzed final chapter body.".length,
- updatedState: "analyzed state",
- updatedLedger: "analyzed ledger",
- updatedHooks: "analyzed hooks",
- chapterSummary: "| 1 | Revised summary |",
- updatedSubplots: "analyzed subplots",
- updatedEmotionalArcs: "analyzed emotions",
- updatedCharacterMatrix: "analyzed matrix",
- ...overrides,
- });
- }
- function createStateCard(params: {
- readonly chapter: number;
- readonly location: string;
- readonly protagonistState: string;
- readonly goal: string;
- readonly conflict: string;
- }): string {
- return [
- "# Current State",
- "",
- "| Field | Value |",
- "| --- | --- |",
- `| Current Chapter | ${params.chapter} |`,
- `| Current Location | ${params.location} |`,
- `| Protagonist State | ${params.protagonistState} |`,
- `| Current Goal | ${params.goal} |`,
- "| Current Constraint | The city gates are watched. |",
- "| Current Alliances | Mentor allies are scattered. |",
- `| Current Conflict | ${params.conflict} |`,
- "",
- ].join("\n");
- }
- function createCaptureLogger() {
- const infos: string[] = [];
- const warnings: string[] = [];
- const logger = {
- debug() {},
- info(message: string) {
- infos.push(message);
- },
- warn(message: string) {
- warnings.push(message);
- },
- error() {},
- child() {
- return logger;
- },
- };
- return { logger, infos, warnings };
- }
- async function createRunnerFixture(
- configOverrides: Partial<ConstructorParameters<typeof PipelineRunner>[0]> = {},
- ): Promise<{
- root: string;
- runner: PipelineRunner;
- state: StateManager;
- bookId: string;
- }> {
- const root = await mkdtemp(join(tmpdir(), "inkos-runner-test-"));
- const state = new StateManager(root);
- const bookId = "test-book";
- const now = "2026-03-19T00:00:00.000Z";
- const book: BookConfig = {
- id: bookId,
- title: "Test Book",
- platform: "tomato",
- genre: "xuanhuan",
- status: "active",
- targetChapters: 10,
- chapterWordCount: 3000,
- createdAt: now,
- updatedAt: now,
- };
- await state.saveBookConfig(bookId, book);
- await mkdir(join(state.bookDir(bookId), "story"), { recursive: true });
- await mkdir(join(state.bookDir(bookId), "chapters"), { recursive: true });
- const runner = new PipelineRunner({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- },
- } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
- model: "test-model",
- projectRoot: root,
- ...configOverrides,
- });
- return { root, runner, state, bookId };
- }
- describe("PipelineRunner", () => {
- beforeEach(() => {
- vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(
- async ({ chapterContent, lengthSpec }) => ({
- normalizedContent: chapterContent,
- finalCount: countChapterLength(chapterContent, lengthSpec.countingMode),
- applied: false,
- mode: "none",
- tokenUsage: ZERO_USAGE,
- }),
- );
- });
- afterEach(() => {
- vi.restoreAllMocks();
- });
- it("does not reuse override clients when credential sources differ", () => {
- const previousKeyA = process.env.TEST_KEY_A;
- const previousKeyB = process.env.TEST_KEY_B;
- process.env.TEST_KEY_A = "key-a";
- process.env.TEST_KEY_B = "key-b";
- try {
- const runner = new PipelineRunner({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- },
- } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
- model: "base-model",
- projectRoot: process.cwd(),
- defaultLLMConfig: {
- provider: "custom",
- baseUrl: "https://base.example/v1",
- apiKey: "base-key",
- model: "base-model",
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0,
- apiFormat: "chat",
- stream: false,
- },
- modelOverrides: {
- writer: {
- model: "writer-model",
- provider: "custom",
- baseUrl: "https://shared.example/v1",
- apiKeyEnv: "TEST_KEY_A",
- },
- auditor: {
- model: "auditor-model",
- provider: "custom",
- baseUrl: "https://shared.example/v1",
- apiKeyEnv: "TEST_KEY_B",
- },
- },
- });
- const resolveOverride = (
- runner as unknown as {
- resolveOverride: (agent: string) => { model: string; client: unknown };
- }
- ).resolveOverride.bind(runner);
- const writerOverride = resolveOverride("writer");
- const auditorOverride = resolveOverride("auditor");
- expect(writerOverride.client).not.toBe(auditorOverride.client);
- } finally {
- if (previousKeyA === undefined) delete process.env.TEST_KEY_A;
- else process.env.TEST_KEY_A = previousKeyA;
- if (previousKeyB === undefined) delete process.env.TEST_KEY_B;
- else process.env.TEST_KEY_B = previousKeyB;
- }
- });
- it("initializes control documents during book creation", async () => {
- const root = await mkdtemp(join(tmpdir(), "inkos-init-book-test-"));
- const bookId = "bootstrap-book";
- const brief = "# Author Intent\n\nKeep the narrative centered on mentor conflict.\n";
- const now = "2026-03-22T00:00:00.000Z";
- const book: BookConfig = {
- id: bookId,
- title: "Bootstrap Book",
- platform: "tomato",
- genre: "xuanhuan",
- status: "outlining",
- targetChapters: 10,
- chapterWordCount: 3000,
- createdAt: now,
- updatedAt: now,
- };
- const runner = new PipelineRunner({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- },
- } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
- model: "test-model",
- projectRoot: root,
- externalContext: brief,
- });
- vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
- storyBible: "# Story Bible\n",
- volumeOutline: "# Volume Outline\n",
- bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
- currentState: "# Current State\n",
- pendingHooks: "# Pending Hooks\n",
- });
- try {
- await runner.initBook(book);
- const storyDir = join(root, "books", bookId, "story");
- const authorIntent = await readFile(join(storyDir, "author_intent.md"), "utf-8");
- const currentFocus = await readFile(join(storyDir, "current_focus.md"), "utf-8");
- const runtimeDir = await stat(join(storyDir, "runtime"));
- expect(authorIntent).toContain("mentor conflict");
- expect(currentFocus).toContain("Current Focus");
- expect(runtimeDir.isDirectory()).toBe(true);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("bootstraps missing control documents for legacy books before writing", async () => {
- const { root, runner, bookId } = await createRunnerFixture();
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Legacy chapter body.",
- wordCount: "Legacy chapter body.".length,
- }),
- );
- try {
- await runner.writeDraft(bookId);
- const storyDir = join(root, "books", bookId, "story");
- const authorIntent = await readFile(join(storyDir, "author_intent.md"), "utf-8");
- const currentFocus = await readFile(join(storyDir, "current_focus.md"), "utf-8");
- const runtimeDir = await stat(join(storyDir, "runtime"));
- expect(authorIntent).toContain("Author Intent");
- expect(currentFocus).toContain("Current Focus");
- expect(runtimeDir.isDirectory()).toBe(true);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("routes writeDraft through planner and composer in v2 mode", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "v2",
- });
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
- ]);
- const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
- const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
- const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Governed draft body.",
- wordCount: "Governed draft body.".length,
- }),
- );
- try {
- await runner.writeDraft(bookId, "Ignore the guild chase and bring focus back to mentor conflict.");
- expect(planChapter).toHaveBeenCalledTimes(1);
- expect(composeChapter).toHaveBeenCalledTimes(1);
- const writeInput = writeChapter.mock.calls[0]?.[0];
- expect(writeInput?.externalContext).toBeUndefined();
- expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
- expect(writeInput?.contextPackage?.selectedContext.length).toBeGreaterThan(0);
- expect(writeInput?.ruleStack?.activeOverrides).toHaveLength(1);
- const runtimeDir = join(state.bookDir(bookId), "story", "runtime");
- await expect(stat(join(runtimeDir, "chapter-0001.intent.md"))).resolves.toBeTruthy();
- await expect(stat(join(runtimeDir, "chapter-0001.context.json"))).resolves.toBeTruthy();
- await expect(stat(join(runtimeDir, "chapter-0001.rule-stack.yaml"))).resolves.toBeTruthy();
- await expect(stat(join(runtimeDir, "chapter-0001.trace.json"))).resolves.toBeTruthy();
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("reuses an existing planned intent for draft when no new context is provided in v2 mode", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "v2",
- });
- await Promise.all([
- mkdir(join(state.bookDir(bookId), "story", "runtime"), { recursive: true }),
- writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nTrack the merchant guild trail.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
- writeFile(
- join(state.bookDir(bookId), "story", "runtime", "chapter-0001.intent.md"),
- [
- "# Chapter Intent",
- "",
- "## Goal",
- "Bring the focus back to the mentor conflict.",
- "",
- "## Outline Node",
- "Track the merchant guild trail.",
- "",
- "## Must Keep",
- "- Lin Yue still hides the broken oath token.",
- "",
- "## Must Avoid",
- "- Do not reveal the mastermind",
- "",
- "## Style Emphasis",
- "- Keep the narrative emotionally close to the mentor conflict.",
- "",
- "## Conflicts",
- "- outline_vs_request: allow local outline deferral",
- "",
- "## Pending Hooks Snapshot",
- "- none",
- "",
- "## Chapter Summaries Snapshot",
- "- none",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
- const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Governed draft body.",
- wordCount: "Governed draft body.".length,
- }),
- );
- try {
- await runner.writeDraft(bookId);
- expect(planChapter).not.toHaveBeenCalled();
- const writeInput = writeChapter.mock.calls[0]?.[0];
- expect(writeInput?.chapterIntent).toContain("Bring the focus back to the mentor conflict.");
- expect(writeInput?.ruleStack?.activeOverrides).toHaveLength(1);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- sqliteIt("syncs current-state facts into memory.db after drafting a chapter", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const chapterOneState = createStateCard({
- chapter: 1,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue hides the broken oath token.",
- goal: "Find the vanished mentor before dawn.",
- conflict: "Mentor debt blocks every choice.",
- });
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- writeFile(
- join(state.bookDir(bookId), "story", "current_state.md"),
- createStateCard({
- chapter: 0,
- location: "Shrine outskirts",
- protagonistState: "Lin Yue begins with the oath token hidden.",
- goal: "Reach the trial city.",
- conflict: "The trial deadline is closing in.",
- }),
- "utf-8",
- ),
- ]);
- await state.snapshotState(bookId, 0);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Draft body.",
- wordCount: "Draft body.".length,
- updatedState: chapterOneState,
- updatedHooks: [
- "# Pending Hooks",
- "",
- "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| mentor-debt | 1 | relationship | open | 1 | 6 | The mentor debt remains unresolved |",
- "",
- ].join("\n"),
- chapterSummary: [
- "| 1 | Ferry Debt | Lin Yue | Lin Yue crosses the ferry and recommits to the mentor trail | The debt hardens into the core conflict | mentor-debt advanced | tense | mainline |",
- ].join("\n"),
- }),
- );
- try {
- await runner.writeDraft(bookId);
- const memoryDb = new MemoryDB(state.bookDir(bookId));
- try {
- expect(memoryDb.getCurrentFacts()).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- predicate: "Current Conflict",
- object: "Mentor debt blocks every choice.",
- validFromChapter: 1,
- sourceChapter: 1,
- }),
- ]),
- );
- expect(memoryDb.getChapterCount()).toBe(1);
- expect(memoryDb.getActiveHooks()).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- hookId: "mentor-debt",
- status: "open",
- }),
- ]),
- );
- } finally {
- memoryDb.close();
- }
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- sqliteIt("syncs narrative memory from structured runtime state instead of stale markdown projections", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const stateDir = join(storyDir, "state");
- await mkdir(stateDir, { recursive: true });
- await Promise.all([
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 1 | Markdown Summary | Lin Yue | Old markdown event | Old markdown state | markdown-hook advanced | tense | fallback |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "pending_hooks.md"),
- [
- "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| markdown-hook | 1 | mystery | open | 1 | 4 | Old markdown hook |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(join(stateDir, "manifest.json"), JSON.stringify({
- schemaVersion: 2,
- language: "en",
- lastAppliedChapter: 3,
- projectionVersion: 1,
- migrationWarnings: [],
- }, null, 2), "utf-8"),
- writeFile(join(stateDir, "current_state.json"), JSON.stringify({
- chapter: 3,
- facts: [],
- }, null, 2), "utf-8"),
- writeFile(join(stateDir, "hooks.json"), JSON.stringify({
- hooks: [
- {
- hookId: "structured-hook",
- startChapter: 2,
- type: "relationship",
- status: "progressing",
- lastAdvancedChapter: 3,
- expectedPayoff: "Reveal the mentor ledger.",
- notes: "Structured hook should win.",
- },
- ],
- }, null, 2), "utf-8"),
- writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
- rows: [
- {
- chapter: 3,
- title: "Structured Summary",
- characters: "Lin Yue",
- events: "Structured runtime state event.",
- stateChanges: "Structured runtime state shift.",
- hookActivity: "structured-hook advanced",
- mood: "grim",
- chapterType: "mainline",
- },
- ],
- }, null, 2), "utf-8"),
- ]);
- try {
- await (runner as unknown as {
- syncNarrativeMemoryIndex: (targetBookId: string) => Promise<void>;
- }).syncNarrativeMemoryIndex(bookId);
- const memoryDb = new MemoryDB(state.bookDir(bookId));
- try {
- expect(memoryDb.getSummaries(1, 10)).toEqual([
- expect.objectContaining({
- chapter: 3,
- title: "Structured Summary",
- events: "Structured runtime state event.",
- }),
- ]);
- expect(memoryDb.getActiveHooks()).toEqual([
- expect.objectContaining({
- hookId: "structured-hook",
- status: "progressing",
- }),
- ]);
- } finally {
- memoryDb.close();
- }
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("uses a friendly fallback warning when sqlite memory indexing is unavailable", async () => {
- const { logger, warnings } = createCaptureLogger();
- const { root, runner, state, bookId } = await createRunnerFixture({
- logger,
- });
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- writeFile(
- join(state.bookDir(bookId), "story", "current_state.md"),
- createStateCard({
- chapter: 0,
- location: "Shrine outskirts",
- protagonistState: "Lin Yue begins with the oath token hidden.",
- goal: "Reach the trial city.",
- conflict: "The trial deadline is closing in.",
- }),
- "utf-8",
- ),
- ]);
- vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation(() => {
- const error = new Error("No such built-in module: node:sqlite");
- (error as Error & { code?: string }).code = "ERR_UNKNOWN_BUILTIN_MODULE";
- throw error;
- });
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Draft body.",
- wordCount: "Draft body.".length,
- }),
- );
- try {
- const result = await runner.writeDraft(bookId);
- expect(result.chapterNumber).toBe(1);
- expect(warnings).toContain(
- "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
- );
- expect(warnings.join("\n")).not.toContain("node:sqlite");
- expect(warnings.join("\n")).not.toContain("ERR_UNKNOWN_BUILTIN_MODULE");
- expect(warnings.join("\n")).not.toContain("状态事实同步已跳过:");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("does not misclassify generic runtime errors as sqlite-unavailable fallback", async () => {
- const { logger, warnings } = createCaptureLogger();
- const { root, runner, state, bookId } = await createRunnerFixture({
- logger,
- });
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- writeFile(
- join(state.bookDir(bookId), "story", "current_state.md"),
- createStateCard({
- chapter: 0,
- location: "Shrine outskirts",
- protagonistState: "Lin Yue begins with the oath token hidden.",
- goal: "Reach the trial city.",
- conflict: "The trial deadline is closing in.",
- }),
- "utf-8",
- ),
- ]);
- vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation(() => {
- throw new Error("sync failed while handling cached node:sqlite telemetry text");
- });
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Draft body.",
- wordCount: "Draft body.".length,
- }),
- );
- try {
- const result = await runner.writeDraft(bookId);
- expect(result.chapterNumber).toBe(1);
- expect(warnings.join("\n")).toContain("叙事记忆同步已跳过:");
- expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- sqliteIt("recovers when sqlite-unavailable signature is transient and probe succeeds", async () => {
- const { logger, warnings } = createCaptureLogger();
- const { root, runner, state, bookId } = await createRunnerFixture({
- logger,
- inputGovernanceMode: "legacy",
- });
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- writeFile(
- join(state.bookDir(bookId), "story", "current_state.md"),
- createStateCard({
- chapter: 0,
- location: "Shrine outskirts",
- protagonistState: "Lin Yue begins with the oath token hidden.",
- goal: "Reach the trial city.",
- conflict: "The trial deadline is closing in.",
- }),
- "utf-8",
- ),
- ]);
- const RealMemoryDB = memoryDbModule.MemoryDB;
- let constructorCalls = 0;
- vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation((...args: ConstructorParameters<typeof memoryDbModule.MemoryDB>) => {
- if (constructorCalls === 0) {
- constructorCalls += 1;
- const error = new Error("No such built-in module: node:sqlite");
- (error as Error & { code?: string }).code = "ERR_UNKNOWN_BUILTIN_MODULE";
- throw error;
- }
- constructorCalls += 1;
- return new RealMemoryDB(...args);
- });
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Draft body.",
- wordCount: "Draft body.".length,
- chapterSummary: "| 1 | Draft summary | Lin Yue | Draft event | Draft shift | hook advanced | tense | transition |",
- updatedHooks: [
- "# Pending Hooks",
- "",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| mentor-debt | 1 | relationship | open | 1 | 3 | Draft hook |",
- ].join("\n"),
- }),
- );
- try {
- const result = await runner.writeDraft(bookId);
- expect(result.chapterNumber).toBe(1);
- expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引");
- expect(warnings.join("\n")).not.toContain("叙事记忆同步已跳过");
- const memoryDb = new MemoryDB(state.bookDir(bookId));
- try {
- expect(memoryDb.getChapterCount()).toBe(1);
- expect(memoryDb.getActiveHooks()).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- hookId: "mentor-debt",
- status: "open",
- }),
- ]),
- );
- } finally {
- memoryDb.close();
- }
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- sqliteIt("retries transient sqlite busy errors during narrative memory sync", async () => {
- const { logger, warnings } = createCaptureLogger();
- const { root, runner, state, bookId } = await createRunnerFixture({
- logger,
- inputGovernanceMode: "legacy",
- });
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- writeFile(
- join(state.bookDir(bookId), "story", "current_state.md"),
- createStateCard({
- chapter: 0,
- location: "Shrine outskirts",
- protagonistState: "Lin Yue begins with the oath token hidden.",
- goal: "Reach the trial city.",
- conflict: "The trial deadline is closing in.",
- }),
- "utf-8",
- ),
- ]);
- const RealMemoryDB = memoryDbModule.MemoryDB;
- let constructorCalls = 0;
- vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation((...args: ConstructorParameters<typeof memoryDbModule.MemoryDB>) => {
- if (constructorCalls === 0) {
- constructorCalls += 1;
- const error = new Error("database is locked");
- (error as Error & { code?: string }).code = "SQLITE_BUSY";
- throw error;
- }
- constructorCalls += 1;
- return new RealMemoryDB(...args);
- });
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Draft body.",
- wordCount: "Draft body.".length,
- chapterSummary: "| 1 | Draft summary | Lin Yue | Draft event | Draft shift | hook advanced | tense | transition |",
- updatedHooks: [
- "# Pending Hooks",
- "",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| mentor-debt | 1 | relationship | open | 1 | 3 | Draft hook |",
- ].join("\n"),
- }),
- );
- try {
- const result = await runner.writeDraft(bookId);
- expect(result.chapterNumber).toBe(1);
- expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引");
- expect(warnings.join("\n")).not.toContain("叙事记忆同步已跳过");
- const memoryDb = new MemoryDB(state.bookDir(bookId));
- try {
- expect(memoryDb.getChapterCount()).toBe(1);
- expect(memoryDb.getActiveHooks()).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- hookId: "mentor-debt",
- status: "open",
- }),
- ]),
- );
- } finally {
- memoryDb.close();
- }
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("logs explicit stage messages during book initialization", async () => {
- const { logger, infos } = createCaptureLogger();
- const { root, runner, state, bookId } = await createRunnerFixture({ logger });
- const book = await state.loadBookConfig(bookId);
- vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
- storyBible: "# Story Bible\n",
- volumeOutline: "# Volume Outline\n",
- bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
- currentState: createStateCard({
- chapter: 0,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- }),
- pendingHooks: "# Pending Hooks\n",
- });
- try {
- await runner.initBook(book);
- expect(infos).toEqual(expect.arrayContaining([
- "阶段:保存书籍配置",
- "阶段:生成基础设定",
- "阶段:写入基础设定文件",
- "阶段:初始化控制文档",
- "阶段:创建初始快照",
- ]));
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("marks an outlining book as active after drafting the first chapter", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const book = await state.loadBookConfig(bookId);
- await state.saveBookConfig(bookId, { ...book, status: "outlining" });
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Draft body.",
- wordCount: "Draft body.".length,
- }),
- );
- try {
- await runner.writeDraft(bookId);
- const book = await state.loadBookConfig(bookId);
- expect(book.status).toBe("active");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("routes writeNextChapter through planner and composer in v2 mode", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "v2",
- });
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
- ]);
- const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
- const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
- const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Governed pipeline draft.",
- wordCount: "Governed pipeline draft.".length,
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- try {
- await runner.writeNextChapter(bookId, 220);
- expect(planChapter).toHaveBeenCalledTimes(1);
- expect(composeChapter).toHaveBeenCalledTimes(1);
- const writeInput = writeChapter.mock.calls[0]?.[0];
- expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
- expect(writeInput?.contextPackage?.selectedContext.length).toBeGreaterThan(0);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("re-plans instead of reusing a persisted invalid intent artifact in v2 mode", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "v2",
- });
- const storyDir = join(state.bookDir(bookId), "story");
- const runtimeDir = join(storyDir, "runtime");
- await mkdir(runtimeDir, { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
- writeFile(
- join(storyDir, "volume_outline.md"),
- [
- "# Volume Outline",
- "",
- "### Golden First Three Chapters Rule",
- "",
- "**Chapter 1:**",
- "Track the merchant guild trail.",
- "",
- ].join("\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, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\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(runtimeDir, "chapter-0001.intent.md"),
- [
- "# Chapter Intent",
- "",
- "## Goal",
- "**",
- "",
- "## Outline Node",
- "**",
- "",
- "## Must Keep",
- "- none",
- "",
- "## Must Avoid",
- "- none",
- "",
- "## Style Emphasis",
- "- none",
- "",
- "## Conflicts",
- "- none",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
- const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Governed pipeline draft.",
- wordCount: "Governed pipeline draft.".length,
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- try {
- await runner.writeNextChapter(bookId, 220);
- expect(planChapter).toHaveBeenCalledTimes(1);
- const writeInput = writeChapter.mock.calls[0]?.[0];
- expect(writeInput?.chapterIntent).toContain("Track the merchant guild trail.");
- expect(writeInput?.chapterIntent).not.toContain("\n**\n");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("logs explicit stage messages during writeNextChapter", async () => {
- const { logger, infos } = createCaptureLogger();
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "v2",
- logger,
- });
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
- ]);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Governed pipeline draft.",
- wordCount: "Governed pipeline draft.".length,
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- try {
- await runner.writeNextChapter(bookId, 220);
- expect(infos).toEqual(expect.arrayContaining([
- "阶段:准备章节输入",
- "阶段:撰写章节草稿",
- "阶段:审计草稿",
- "阶段:落盘最终章节",
- "阶段:生成最终真相文件",
- "阶段:校验真相文件变更",
- "阶段:同步记忆索引",
- "阶段:更新章节索引与快照",
- ]));
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("logs English stage messages during writeNextChapter for English books", async () => {
- const { logger, infos } = createCaptureLogger();
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "v2",
- logger,
- });
- const englishBook = {
- ...(await state.loadBookConfig(bookId)),
- genre: "other",
- language: "en" as const,
- chapterWordCount: 220,
- };
- await state.saveBookConfig(bookId, englishBook);
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
- ]);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Governed pipeline draft.",
- wordCount: countChapterLength("Governed pipeline draft.", "en_words"),
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- try {
- await runner.writeNextChapter(bookId, 220);
- expect(infos).toEqual(expect.arrayContaining([
- "Stage: preparing chapter inputs",
- "Stage: writing chapter draft",
- "Stage: auditing draft",
- "Stage: persisting final chapter",
- "Stage: rebuilding final truth files",
- "Stage: validating truth file updates",
- "Stage: syncing memory indexes",
- "Stage: updating chapter index and snapshots",
- ]));
- expect(infos.join("\n")).not.toContain("阶段:");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("writes English audit drift correction blocks for English books", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const englishBook = {
- ...(await state.loadBookConfig(bookId)),
- genre: "other",
- language: "en" as const,
- chapterWordCount: 220,
- };
- await state.saveBookConfig(bookId, englishBook);
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nKeep the pressure on the harbor debt.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "current_state.md"), createStateCard({
- chapter: 0,
- location: "Harbor gate",
- protagonistState: "Lin Yue is tracking the vanished mentor.",
- goal: "Reach the sealed berth.",
- conflict: "The harbor debt keeps pulling him sideways.",
- }), "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The harbor seal cannot be forged.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- The vanished mentor still owes a debt.\n", "utf-8"),
- ]);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Lin Yue reached the sealed berth before dawn.",
- wordCount: countChapterLength("Lin Yue reached the sealed berth before dawn.", "en_words"),
- updatedState: createStateCard({
- chapter: 1,
- location: "Sealed berth",
- protagonistState: "Lin Yue is winded but focused.",
- goal: "Inspect the berth before the guild arrives.",
- conflict: "The harbor debt is still active.",
- }),
- updatedHooks: "# Pending Hooks\n\n- The vanished mentor still owes a debt.\n",
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [{
- severity: "warning",
- category: "continuity",
- description: "Keep the berth timing precise in the next chapter.",
- suggestion: "Avoid skipping the dawn transition.",
- }],
- summary: "warning only",
- }),
- );
- try {
- await runner.writeNextChapter(bookId, 220);
- const currentState = await readFile(join(state.bookDir(bookId), "story", "current_state.md"), "utf-8");
- expect(currentState).toContain("## Audit Drift Correction");
- expect(currentState).toContain("> Chapter 1 audit found the following issues");
- expect(currentState).not.toContain("## 审计纠偏");
- expect(currentState).not.toContain("下一章写作前参照");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("passes reduced control inputs into auditor and reviser in v2 mode", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "v2",
- });
- await Promise.all([
- writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
- writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
- ]);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Needs governed revision.",
- wordCount: "Needs governed revision.".length,
- }),
- );
- const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: false,
- issues: [CRITICAL_ISSUE],
- summary: "needs revision",
- }),
- );
- const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: "Governed revised content.",
- wordCount: "Governed revised content.".length,
- }),
- );
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- content: "Governed revised content.",
- wordCount: "Governed revised content.".length,
- }),
- );
- try {
- await runner.writeNextChapter(bookId, 220);
- expect(auditChapter.mock.calls[0]?.[4]).toMatchObject({
- chapterIntent: expect.stringContaining("# Chapter Intent"),
- contextPackage: expect.objectContaining({
- selectedContext: expect.any(Array),
- }),
- ruleStack: expect.objectContaining({
- activeOverrides: expect.any(Array),
- }),
- });
- expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
- chapterIntent: expect.stringContaining("# Chapter Intent"),
- contextPackage: expect.objectContaining({
- selectedContext: expect.any(Array),
- }),
- ruleStack: expect.objectContaining({
- activeOverrides: expect.any(Array),
- }),
- lengthSpec: expect.objectContaining({
- target: 220,
- softMin: 190,
- softMax: 250,
- }),
- });
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("normalizes revised output once before re-audit when it leaves the target band", async () => {
- const { root, runner, bookId } = await createRunnerFixture();
- const writerDraft = "中段正文。".repeat(40);
- const overlongRevision = "修订后正文。".repeat(60);
- const normalizedRevision = "归一正文。".repeat(40);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- content: writerDraft,
- wordCount: writerDraft.length,
- }),
- );
- const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [CRITICAL_ISSUE],
- summary: "needs revision",
- }),
- )
- .mockResolvedValueOnce(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: overlongRevision,
- wordCount: overlongRevision.length,
- }),
- );
- const normalizeChapter = vi.mocked(
- LengthNormalizerAgent.prototype.normalizeChapter,
- ).mockResolvedValue({
- normalizedContent: normalizedRevision,
- finalCount: normalizedRevision.length,
- applied: true,
- mode: "compress",
- tokenUsage: ZERO_USAGE,
- });
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- content: normalizedRevision,
- wordCount: normalizedRevision.length,
- }),
- );
- try {
- await runner.writeNextChapter(bookId, 220);
- expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
- lengthSpec: expect.objectContaining({
- target: 220,
- softMin: 190,
- softMax: 250,
- }),
- });
- expect(normalizeChapter).toHaveBeenCalledTimes(1);
- expect(normalizeChapter.mock.calls[0]?.[0]).toMatchObject({
- chapterContent: overlongRevision,
- lengthSpec: expect.objectContaining({
- target: 220,
- }),
- });
- expect(auditChapter.mock.calls[1]?.[1]).toBe(normalizedRevision);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("normalizes overlong writer output once before audit", async () => {
- const { root, runner, bookId } = await createRunnerFixture();
- const overlongDraft = "冗余句子。".repeat(60);
- const normalizedDraft = "压缩后的正文。".repeat(12);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: overlongDraft,
- wordCount: overlongDraft.length,
- }),
- );
- const normalizeChapter = vi.mocked(
- LengthNormalizerAgent.prototype.normalizeChapter,
- ).mockResolvedValue({
- normalizedContent: normalizedDraft,
- finalCount: normalizedDraft.length,
- applied: true,
- mode: "compress",
- tokenUsage: ZERO_USAGE,
- });
- const auditChapter = vi.spyOn(
- ContinuityAuditor.prototype,
- "auditChapter",
- ).mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- content: normalizedDraft,
- wordCount: normalizedDraft.length,
- }),
- );
- try {
- await runner.writeNextChapter(bookId, 220);
- expect(normalizeChapter).toHaveBeenCalledTimes(1);
- expect(auditChapter.mock.calls[0]?.[1]).toBe(normalizedDraft);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("normalizes short writer output once before audit", async () => {
- const { root, runner, bookId } = await createRunnerFixture();
- const shortDraft = "短句。".repeat(20);
- const normalizedDraft = "补足后的正文。".repeat(15);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: shortDraft,
- wordCount: shortDraft.length,
- }),
- );
- const normalizeChapter = vi.mocked(
- LengthNormalizerAgent.prototype.normalizeChapter,
- ).mockResolvedValue({
- normalizedContent: normalizedDraft,
- finalCount: normalizedDraft.length,
- applied: true,
- mode: "expand",
- tokenUsage: ZERO_USAGE,
- });
- const auditChapter = vi.spyOn(
- ContinuityAuditor.prototype,
- "auditChapter",
- ).mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- content: normalizedDraft,
- wordCount: normalizedDraft.length,
- }),
- );
- try {
- await runner.writeNextChapter(bookId, 220);
- expect(normalizeChapter).toHaveBeenCalledTimes(1);
- expect(normalizeChapter.mock.calls[0]?.[0]).toMatchObject({
- chapterContent: shortDraft,
- lengthSpec: expect.objectContaining({
- target: 220,
- softMin: 190,
- softMax: 250,
- }),
- });
- expect(auditChapter.mock.calls[0]?.[1]).toBe(normalizedDraft);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("records a length warning when a single normalize pass still misses the hard range", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const overlongDraft = "冗余句子。".repeat(60);
- const stillOverHard = "仍然过长。".repeat(70);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: overlongDraft,
- wordCount: overlongDraft.length,
- }),
- );
- const normalizeChapter = vi.mocked(
- LengthNormalizerAgent.prototype.normalizeChapter,
- ).mockResolvedValue({
- normalizedContent: stillOverHard,
- finalCount: stillOverHard.length,
- applied: true,
- mode: "compress",
- tokenUsage: ZERO_USAGE,
- });
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- content: stillOverHard,
- wordCount: stillOverHard.length,
- }),
- );
- try {
- const result = await runner.writeNextChapter(bookId, 220);
- const chapterIndex = await state.loadChapterIndex(bookId);
- const chapterMeta = chapterIndex.find((entry) => entry.number === 1);
- expect(normalizeChapter).toHaveBeenCalledTimes(1);
- expect((result as { lengthWarnings?: ReadonlyArray<string> }).lengthWarnings?.[0]).toContain(
- "超出硬区间",
- );
- expect((result as { lengthTelemetry?: { finalCount: number } }).lengthTelemetry?.finalCount).toBe(
- stillOverHard.length,
- );
- expect(chapterMeta?.lengthWarnings?.[0]).toContain("超出硬区间");
- expect(chapterMeta?.lengthTelemetry?.lengthWarning).toBe(true);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("keeps the last actionable audit issues when re-audit returns failed with no issues", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "legacy",
- });
- const storyDir = join(state.bookDir(bookId), "story");
- const draftBody = "甲".repeat(210);
- const revisedBody = "乙".repeat(215);
- await Promise.all([
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 0,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- }), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- ]);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- content: draftBody,
- wordCount: draftBody.length,
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [CRITICAL_ISSUE],
- summary: "needs revision",
- }),
- )
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [],
- summary: "",
- }),
- );
- vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: revisedBody,
- wordCount: revisedBody.length,
- fixedIssues: ["- tightened continuity."],
- }),
- );
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- content: revisedBody,
- wordCount: revisedBody.length,
- }),
- );
- try {
- const result = await runner.writeNextChapter(bookId, 220);
- const savedIndex = await state.loadChapterIndex(bookId);
- expect(result.status).toBe("audit-failed");
- expect(result.auditResult.summary).toBe("needs revision");
- expect(result.auditResult.issues).toEqual([CRITICAL_ISSUE]);
- expect(savedIndex[0]?.auditIssues).toEqual([
- `[critical] ${CRITICAL_ISSUE.description}`,
- ]);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("preserves the legacy fallback when input governance mode is legacy", async () => {
- const { root, runner, bookId } = await createRunnerFixture({
- inputGovernanceMode: "legacy",
- externalContext: "Legacy focus only.",
- });
- const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
- const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
- const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 1,
- content: "Legacy draft body.",
- wordCount: "Legacy draft body.".length,
- }),
- );
- try {
- await runner.writeDraft(bookId);
- expect(planChapter).not.toHaveBeenCalled();
- expect(composeChapter).not.toHaveBeenCalled();
- const writeInput = writeChapter.mock.calls[0]?.[0];
- expect(writeInput?.externalContext).toBe("Legacy focus only.");
- expect(writeInput?.chapterIntent).toBeUndefined();
- expect(writeInput?.contextPackage).toBeUndefined();
- expect(writeInput?.ruleStack).toBeUndefined();
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("uses the latest revised content as the input for follow-up spot-fix revisions", async () => {
- const { root, runner, bookId } = await createRunnerFixture();
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- content: "Original draft body.",
- wordCount: "Original draft body.".length,
- postWriteErrors: [
- {
- severity: "error",
- rule: "post-write",
- description: "Needs a deterministic fix",
- suggestion: "Repair the line",
- },
- ],
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
- .mockResolvedValueOnce(createAuditResult({
- passed: false,
- issues: [CRITICAL_ISSUE],
- summary: "needs another revision",
- }))
- .mockResolvedValueOnce(createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }));
- const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter")
- .mockResolvedValueOnce(createReviseOutput({
- revisedContent: "After first fix.",
- wordCount: "After first fix.".length,
- }))
- .mockResolvedValueOnce(createReviseOutput({
- revisedContent: "After second fix.",
- wordCount: "After second fix.".length,
- }));
- vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
- vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- content: "After second fix.",
- wordCount: "After second fix.".length,
- }),
- );
- await runner.writeNextChapter(bookId);
- expect(reviseChapter).toHaveBeenCalledTimes(2);
- expect(reviseChapter.mock.calls[1]?.[1]).toBe("After first fix.");
- await rm(root, { recursive: true, force: true });
- });
- it("persists truth files derived from the final revised chapter", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- content: "Original draft body.",
- wordCount: "Original draft body.".length,
- updatedState: "original state",
- updatedLedger: "original ledger",
- updatedHooks: "original hooks",
- chapterSummary: "| 1 | Original summary |",
- updatedSubplots: "original subplots",
- updatedEmotionalArcs: "original emotions",
- updatedCharacterMatrix: "original matrix",
- postWriteErrors: [
- {
- severity: "error",
- rule: "post-write",
- description: "Needs a deterministic fix",
- suggestion: "Repair the line",
- },
- ],
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: "Final revised body.",
- wordCount: "Final revised body.".length,
- }),
- );
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- content: "Final revised body.",
- wordCount: "Final revised body.".length,
- updatedState: "final analyzed state",
- updatedLedger: "final analyzed ledger",
- updatedHooks: "final analyzed hooks",
- chapterSummary: "| 1 | Final analyzed summary |",
- updatedSubplots: "final analyzed subplots",
- updatedEmotionalArcs: "final analyzed emotions",
- updatedCharacterMatrix: "final analyzed matrix",
- }),
- );
- await runner.writeNextChapter(bookId);
- const storyDir = join(state.bookDir(bookId), "story");
- await expect(readFile(join(storyDir, "current_state.md"), "utf-8"))
- .resolves.toContain("final analyzed state");
- await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8"))
- .resolves.toContain("final analyzed hooks");
- await expect(readFile(join(storyDir, "particle_ledger.md"), "utf-8"))
- .resolves.toContain("final analyzed ledger");
- await expect(readFile(join(storyDir, "chapter_summaries.md"), "utf-8"))
- .resolves.toContain("Final analyzed summary");
- await expect(readFile(join(storyDir, "subplot_board.md"), "utf-8"))
- .resolves.toContain("final analyzed subplots");
- await expect(readFile(join(storyDir, "emotional_arcs.md"), "utf-8"))
- .resolves.toContain("final analyzed emotions");
- await expect(readFile(join(storyDir, "character_matrix.md"), "utf-8"))
- .resolves.toContain("final analyzed matrix");
- await rm(root, { recursive: true, force: true });
- });
- it("persists structured runtime state and rendered projections from writer delta output", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "legacy",
- });
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- content: "Lin Yue follows the debt into the river-port ledger.",
- wordCount: countChapterLength("Lin Yue follows the debt into the river-port ledger.", "en_words"),
- postWriteErrors: [],
- postWriteWarnings: [],
- runtimeStateDelta: {
- chapter: 1,
- currentStatePatch: {
- currentGoal: "Follow the debt through the river-port ledger.",
- currentConflict: "Guild pressure keeps pulling against the debt trail.",
- },
- hookOps: {
- upsert: [
- {
- hookId: "mentor-debt",
- startChapter: 1,
- type: "relationship",
- status: "open",
- lastAdvancedChapter: 1,
- expectedPayoff: "Reveal why the mentor vanished.",
- notes: "The river-port ledger sharpens the debt line.",
- },
- ],
- resolve: [],
- defer: [],
- },
- chapterSummary: {
- chapter: 1,
- title: "River Ledger",
- characters: "Lin Yue",
- events: "Lin Yue follows the debt into the river-port ledger.",
- stateChanges: "The debt line sharpens.",
- hookActivity: "mentor-debt advanced",
- mood: "tense",
- chapterType: "investigation",
- },
- subplotOps: [],
- emotionalArcOps: [],
- characterMatrixOps: [],
- notes: [],
- },
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- await runner.writeNextChapter(bookId);
- const storyDir = join(state.bookDir(bookId), "story");
- const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
- const hooks = await readFile(join(storyDir, "pending_hooks.md"), "utf-8");
- const summaries = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8");
- const manifest = JSON.parse(await readFile(join(storyDir, "state", "manifest.json"), "utf-8"));
- const stateCurrent = JSON.parse(await readFile(join(storyDir, "state", "current_state.json"), "utf-8"));
- const stateHooks = JSON.parse(await readFile(join(storyDir, "state", "hooks.json"), "utf-8"));
- const stateSummaries = JSON.parse(await readFile(join(storyDir, "state", "chapter_summaries.json"), "utf-8"));
- expect(currentState).toContain("Follow the debt through the river-port ledger.");
- expect(hooks).toContain("mentor-debt");
- expect(summaries).toContain("River Ledger");
- expect(manifest.lastAppliedChapter).toBe(1);
- expect(stateCurrent.chapter).toBe(1);
- expect(stateHooks.hooks[0]?.hookId).toBe("mentor-debt");
- expect(stateSummaries.rows[0]?.title).toBe("River Ledger");
- await rm(root, { recursive: true, force: true });
- });
- it("does not corrupt persisted runtime state when writer delta is invalid", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "legacy",
- });
- const storyDir = join(state.bookDir(bookId), "story");
- await mkdir(join(storyDir, "state"), { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 0,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- }), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
- writeFile(join(storyDir, "state", "manifest.json"), JSON.stringify({
- schemaVersion: 2,
- language: "en",
- lastAppliedChapter: 0,
- projectionVersion: 1,
- migrationWarnings: [],
- }, null, 2), "utf-8"),
- writeFile(join(storyDir, "state", "current_state.json"), JSON.stringify({
- chapter: 0,
- facts: [],
- }, null, 2), "utf-8"),
- writeFile(join(storyDir, "state", "hooks.json"), JSON.stringify({
- hooks: [],
- }, null, 2), "utf-8"),
- writeFile(join(storyDir, "state", "chapter_summaries.json"), JSON.stringify({
- rows: [],
- }, null, 2), "utf-8"),
- ]);
- const beforeState = await readFile(join(storyDir, "current_state.md"), "utf-8");
- const beforeManifest = await readFile(join(storyDir, "state", "manifest.json"), "utf-8");
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- content: "Broken chapter body.",
- wordCount: countChapterLength("Broken chapter body.", "en_words"),
- postWriteErrors: [],
- postWriteWarnings: [],
- runtimeStateDelta: {
- chapter: 0,
- hookOps: {
- upsert: [],
- resolve: [],
- defer: [],
- },
- notes: [],
- } as unknown as NonNullable<ReturnType<typeof createWriterOutput>["runtimeStateDelta"]>,
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- await expect(runner.writeNextChapter(bookId)).rejects.toThrow();
- await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe(beforeState);
- await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8")).resolves.toBe(beforeManifest);
- await rm(root, { recursive: true, force: true });
- });
- it("rolls back persisted runtime state when writer delta contains natural-language numeric drift", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "legacy",
- });
- const storyDir = join(state.bookDir(bookId), "story");
- await mkdir(join(storyDir, "state"), { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 0,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- }), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
- writeFile(join(storyDir, "state", "manifest.json"), JSON.stringify({
- schemaVersion: 2,
- language: "en",
- lastAppliedChapter: 0,
- projectionVersion: 1,
- migrationWarnings: [],
- }, null, 2), "utf-8"),
- writeFile(join(storyDir, "state", "current_state.json"), JSON.stringify({
- chapter: 0,
- facts: [],
- }, null, 2), "utf-8"),
- writeFile(join(storyDir, "state", "hooks.json"), JSON.stringify({
- hooks: [],
- }, null, 2), "utf-8"),
- writeFile(join(storyDir, "state", "chapter_summaries.json"), JSON.stringify({
- rows: [],
- }, null, 2), "utf-8"),
- ]);
- const beforeState = await readFile(join(storyDir, "current_state.md"), "utf-8");
- const beforeManifest = await readFile(join(storyDir, "state", "manifest.json"), "utf-8");
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- content: "Broken chapter body.",
- wordCount: countChapterLength("Broken chapter body.", "en_words"),
- postWriteErrors: [],
- postWriteWarnings: [],
- runtimeStateDelta: {
- chapter: 1,
- hookOps: {
- upsert: [
- {
- hookId: "mentor-debt",
- startChapter: 1,
- type: "relationship",
- status: "open",
- lastAdvancedChapter: "chapter one",
- expectedPayoff: "Reveal the debt.",
- notes: "Bad numeric drift.",
- },
- ],
- resolve: [],
- defer: [],
- },
- notes: [],
- } as unknown as NonNullable<ReturnType<typeof createWriterOutput>["runtimeStateDelta"]>,
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- await expect(runner.writeNextChapter(bookId)).rejects.toThrow();
- await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe(beforeState);
- await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8")).resolves.toBe(beforeManifest);
- await rm(root, { recursive: true, force: true });
- });
- it("reports only resumed chapters in import results", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const now = "2026-03-19T00:00:00.000Z";
- const existingIndex: ChapterMeta[] = [
- {
- number: 1,
- title: "One",
- status: "imported",
- wordCount: 10,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- lengthWarnings: [],
- },
- {
- number: 2,
- title: "Two",
- status: "imported",
- wordCount: 20,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- lengthWarnings: [],
- },
- ];
- await state.saveChapterIndex(bookId, existingIndex);
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockImplementation(async (input) =>
- createAnalyzedOutput({
- chapterNumber: input.chapterNumber,
- title: input.chapterTitle ?? `Chapter ${input.chapterNumber}`,
- content: input.chapterContent,
- wordCount: input.chapterContent.length,
- }),
- );
- vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
- vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
- const result = await runner.importChapters({
- bookId,
- resumeFrom: 3,
- chapters: [
- { title: "One", content: "1111111111" },
- { title: "Two", content: "22222222222222222222" },
- { title: "Three", content: "333333333333333" },
- { title: "Four", content: "4444444444444444444444444" },
- ],
- });
- expect(result.importedCount).toBe(2);
- expect(result.totalWords).toBe("333333333333333".length + "4444444444444444444444444".length);
- expect(result.nextChapter).toBe(5);
- await rm(root, { recursive: true, force: true });
- });
- sqliteIt("rebuilds fact history from imported chapter snapshots", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
- storyBible: "# Story Bible\n",
- volumeOutline: "# Volume Outline\n",
- bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
- currentState: createStateCard({
- chapter: 0,
- location: "Shrine outskirts",
- protagonistState: "Lin Yue begins with the oath token hidden.",
- goal: "Reach the trial city.",
- conflict: "The trial deadline is closing in.",
- }),
- pendingHooks: "# Pending Hooks\n",
- });
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter")
- .mockResolvedValueOnce(createAnalyzedOutput({
- chapterNumber: 1,
- title: "One",
- content: "One body.",
- wordCount: "One body.".length,
- updatedState: createStateCard({
- chapter: 1,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- }),
- updatedHooks: "# Pending Hooks\n",
- }))
- .mockResolvedValueOnce(createAnalyzedOutput({
- chapterNumber: 2,
- title: "Two",
- content: "Two body.",
- wordCount: "Two body.".length,
- updatedState: createStateCard({
- chapter: 2,
- location: "North watchtower",
- protagonistState: "Lin Yue finally shows the oath token.",
- goal: "Reach the watchtower before the guild.",
- conflict: "The merchant guild now contests the mentor trail.",
- }),
- updatedHooks: "# Pending Hooks\n",
- }));
- try {
- await runner.importChapters({
- bookId,
- chapters: [
- { title: "One", content: "One body." },
- { title: "Two", content: "Two body." },
- ],
- });
- const memoryDb = new MemoryDB(state.bookDir(bookId));
- try {
- const chapterOneFacts = memoryDb.getFactsAt("protagonist", 1);
- const currentFacts = memoryDb.getCurrentFacts();
- expect(chapterOneFacts).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- predicate: "Current Conflict",
- object: "The mentor debt is still personal.",
- }),
- ]),
- );
- expect(currentFacts).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- predicate: "Current Conflict",
- object: "The merchant guild now contests the mentor trail.",
- validFromChapter: 2,
- sourceChapter: 2,
- }),
- ]),
- );
- } finally {
- memoryDb.close();
- }
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- sqliteIt("rebuilds fact history from structured snapshot state instead of stale markdown snapshots", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const snapshotOneDir = join(storyDir, "snapshots", "1");
- const snapshotOneStateDir = join(snapshotOneDir, "state");
- await mkdir(snapshotOneStateDir, { recursive: true });
- await Promise.all([
- writeFile(
- join(snapshotOneDir, "current_state.md"),
- createStateCard({
- chapter: 1,
- location: "Old markdown ferry crossing",
- protagonistState: "Markdown state still hides the oath token.",
- goal: "Follow the markdown trail.",
- conflict: "Old markdown conflict.",
- }),
- "utf-8",
- ),
- writeFile(join(snapshotOneStateDir, "current_state.json"), JSON.stringify({
- chapter: 1,
- facts: [
- {
- subject: "current",
- predicate: "Current Location",
- object: "Structured watchtower",
- validFromChapter: 1,
- validUntilChapter: null,
- sourceChapter: 1,
- },
- {
- subject: "protagonist",
- predicate: "Current Conflict",
- object: "Structured conflict replaces markdown drift.",
- validFromChapter: 1,
- validUntilChapter: null,
- sourceChapter: 1,
- },
- ],
- }, null, 2), "utf-8"),
- ]);
- try {
- await (runner as unknown as {
- syncCurrentStateFactHistory: (targetBookId: string, uptoChapter: number) => Promise<void>;
- }).syncCurrentStateFactHistory(bookId, 1);
- const memoryDb = new MemoryDB(state.bookDir(bookId));
- try {
- expect(memoryDb.getCurrentFacts()).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- predicate: "Current Location",
- object: "Structured watchtower",
- validFromChapter: 1,
- }),
- expect.objectContaining({
- predicate: "Current Conflict",
- object: "Structured conflict replaces markdown drift.",
- validFromChapter: 1,
- }),
- ]),
- );
- expect(memoryDb.getCurrentFacts()).not.toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- object: "Old markdown ferry crossing",
- }),
- expect.objectContaining({
- object: "Old markdown conflict.",
- }),
- ]),
- );
- } finally {
- memoryDb.close();
- }
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("tracks imported English chapters using word counts instead of characters", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const englishBook = {
- ...(await state.loadBookConfig(bookId)),
- genre: "other",
- language: "en" as const,
- };
- const now = "2026-03-19T00:00:00.000Z";
- await state.saveBookConfig(bookId, englishBook);
- await state.saveChapterIndex(bookId, [
- {
- number: 1,
- title: "Prelude",
- status: "imported",
- wordCount: 3,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- lengthWarnings: [],
- },
- {
- number: 2,
- title: "Crossroads",
- status: "imported",
- wordCount: 2,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- lengthWarnings: [],
- },
- ]);
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockImplementation(async (input) =>
- createAnalyzedOutput({
- chapterNumber: input.chapterNumber,
- title: input.chapterTitle ?? `Chapter ${input.chapterNumber}`,
- content: input.chapterContent,
- wordCount: countChapterLength(input.chapterContent, "en_words"),
- }),
- );
- vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
- vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
- const result = await runner.importChapters({
- bookId,
- resumeFrom: 3,
- chapters: [
- { title: "Prelude", content: "One two three" },
- { title: "Crossroads", content: "Four five" },
- { title: "The Watchtower", content: "The storm kept rolling west" },
- { title: "Aftermath", content: "Lanterns dimmed before dawn broke" },
- ],
- });
- const chapterIndex = await state.loadChapterIndex(bookId);
- const chapterThree = chapterIndex.find((entry) => entry.number === 3);
- const chapterFour = chapterIndex.find((entry) => entry.number === 4);
- expect(result.importedCount).toBe(2);
- expect(result.totalWords).toBe(10);
- expect(chapterThree?.wordCount).toBe(5);
- expect(chapterFour?.wordCount).toBe(5);
- await rm(root, { recursive: true, force: true });
- });
- it("imports English chapters with English foundation seeds and persistence files", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const englishBook = {
- ...(await state.loadBookConfig(bookId)),
- genre: "other",
- language: "en" as const,
- chapterWordCount: 2200,
- };
- await state.saveBookConfig(bookId, englishBook);
- const foundation = vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
- storyBible: "# Story Bible\n",
- volumeOutline: "# Volume Outline\n",
- bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
- currentState: createStateCard({
- chapter: 0,
- location: "Harbor gate",
- protagonistState: "Mara arrives with a sealed letter.",
- goal: "Find the missing captain before sunrise.",
- conflict: "The harbor watch is searching every ship.",
- }),
- pendingHooks: "# Pending Hooks\n\n| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |\n| --- | --- | --- | --- | --- | --- | --- |\n",
- });
- const saveChapter = vi.spyOn(WriterAgent.prototype, "saveChapter");
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- chapterNumber: 1,
- title: "Prelude",
- content: "A cold wind crossed the harbor.",
- wordCount: countChapterLength("A cold wind crossed the harbor.", "en_words"),
- updatedState: createStateCard({
- chapter: 1,
- location: "Harbor gate",
- protagonistState: "Mara hides the sealed letter under her coat.",
- goal: "Slip past the harbor watch.",
- conflict: "The watch now searches for the missing captain's courier.",
- }),
- updatedHooks: "# Pending Hooks\n\n| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |\n| --- | --- | --- | --- | --- | --- | --- |\n| captain-letter | 1 | mystery | open | 1 | The captain's disappearance is explained. | The sealed letter points to the vanished captain. |\n",
- chapterSummary: "| 1 | Prelude | Mara | Mara reaches the harbor with a sealed letter. | Mara hides the letter and studies the watch patrol. | The captain-letter mystery opens. | tense | setup |",
- updatedSubplots: [
- "# Subplot Board",
- "",
- "| Subplot | Status | Note |",
- "| --- | --- | --- |",
- "| Harbor search | Active | Mara begins the search for the missing captain. |",
- "",
- ].join("\n"),
- updatedEmotionalArcs: "",
- updatedCharacterMatrix: "",
- }),
- );
- try {
- await runner.importChapters({
- bookId,
- chapters: [
- { title: "Prelude", content: "A cold wind crossed the harbor." },
- ],
- });
- const storyDir = join(state.bookDir(bookId), "story");
- const chapterPath = join(state.bookDir(bookId), "chapters", "0001_Prelude.md");
- const chapterFile = await readFile(chapterPath, "utf-8");
- const chapterSummaries = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8");
- const subplotBoard = await readFile(join(storyDir, "subplot_board.md"), "utf-8");
- expect(foundation.mock.calls[0]?.[1]).toContain("Chapter 1: Prelude");
- expect(foundation.mock.calls[0]?.[1]).not.toContain("第1章");
- expect(saveChapter.mock.calls[0]?.[3]).toBe("en");
- expect(chapterFile).toContain("# Chapter 1: Prelude");
- expect(chapterSummaries).toContain("# Chapter Summaries");
- expect(chapterSummaries).not.toContain("# 章节摘要");
- expect(subplotBoard).toContain("# Subplot Board");
- expect(subplotBoard).not.toContain("# 支线进度板");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("logs localized replay progress during chapter import", async () => {
- const { logger, infos } = createCaptureLogger();
- const { root, runner, bookId } = await createRunnerFixture({ logger });
- vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
- storyBible: "# Story Bible\n",
- volumeOutline: "# Volume Outline\n",
- bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
- currentState: createStateCard({
- chapter: 0,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- }),
- pendingHooks: "# Pending Hooks\n",
- });
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
- createAnalyzedOutput({
- chapterNumber: 1,
- title: "Prelude",
- content: "章节正文。",
- wordCount: "章节正文。".length,
- }),
- );
- vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
- vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
- try {
- await runner.importChapters({
- bookId,
- chapters: [
- { title: "第一章", content: "章节正文。" },
- ],
- });
- expect(infos).toEqual(expect.arrayContaining([
- "步骤 1:从 1 章生成基础设定...",
- "基础设定已生成。",
- "步骤 2:从第 1 章开始顺序回放...",
- "分析章节 1/1:第一章...",
- "完成。已导入 1 章,共 5字。下一章:2",
- ]));
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("does not leak imported future state into early replay chapters", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const englishBook = {
- ...(await state.loadBookConfig(bookId)),
- genre: "other",
- language: "en" as const,
- chapterWordCount: 2200,
- };
- await state.saveBookConfig(bookId, englishBook);
- await mkdir(join(storyDir, "snapshots", "0"), { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "subplot_board.md"), "# Subplot Board\n\nFUTURE LEAK subplot\n", "utf-8"),
- writeFile(join(storyDir, "emotional_arcs.md"), "# Emotional Arcs\n\nFUTURE LEAK emotion\n", "utf-8"),
- writeFile(join(storyDir, "character_matrix.md"), "# Character Matrix\n\nFUTURE LEAK matrix\n", "utf-8"),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# Chapter Summaries",
- "",
- "| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 99 | Future | Future Cast | FUTURE LEAK event | FUTURE LEAK state | FUTURE LEAK hook | grim | finale |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "snapshots", "0", "current_state.md"),
- createStateCard({
- chapter: 60,
- location: "Chengdu court",
- protagonistState: "FUTURE LEAK snapshot",
- goal: "Secure the western kingdom.",
- conflict: "Late-book imperial rivalry is now fully active.",
- }),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "snapshots", "0", "pending_hooks.md"),
- [
- "# Pending Hooks",
- "",
- "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| future-hook | 60 | mystery | open | 60 | Future payoff | FUTURE LEAK |",
- "",
- ].join("\n"),
- "utf-8",
- ),
- ]);
- vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
- storyBible: "# Story Bible\n",
- volumeOutline: "# Volume Outline\n",
- bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
- currentState: createStateCard({
- chapter: 60,
- location: "Chengdu court",
- protagonistState: "FUTURE LEAK: Liu Bei already holds Yizhou.",
- goal: "Secure the western kingdom.",
- conflict: "Late-book imperial rivalry is now fully active.",
- }),
- pendingHooks: [
- "# Pending Hooks",
- "",
- "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| future-hook | 60 | mystery | open | 60 | Future payoff | FUTURE LEAK |",
- "",
- ].join("\n"),
- });
- let stateSeenByFirstReplay = "";
- let hooksSeenByFirstReplay = "";
- let subplotSeenByFirstReplay = "";
- let emotionalSeenByFirstReplay = "";
- let matrixSeenByFirstReplay = "";
- let summariesSeenByFirstReplay = "";
- vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockImplementationOnce(async (input) => {
- stateSeenByFirstReplay = await readFile(join(input.bookDir, "story", "current_state.md"), "utf-8");
- hooksSeenByFirstReplay = await readFile(join(input.bookDir, "story", "pending_hooks.md"), "utf-8");
- subplotSeenByFirstReplay = await readFile(join(input.bookDir, "story", "subplot_board.md"), "utf-8").catch(() => "");
- emotionalSeenByFirstReplay = await readFile(join(input.bookDir, "story", "emotional_arcs.md"), "utf-8").catch(() => "");
- matrixSeenByFirstReplay = await readFile(join(input.bookDir, "story", "character_matrix.md"), "utf-8").catch(() => "");
- summariesSeenByFirstReplay = await readFile(join(input.bookDir, "story", "chapter_summaries.md"), "utf-8").catch(() => "");
- return createAnalyzedOutput({
- chapterNumber: 1,
- title: "Prelude",
- content: "A cold wind crossed the harbor.",
- wordCount: countChapterLength("A cold wind crossed the harbor.", "en_words"),
- updatedState: createStateCard({
- chapter: 1,
- location: "Harbor gate",
- protagonistState: "Mara hides the sealed letter under her coat.",
- goal: "Slip past the harbor watch.",
- conflict: "The watch now searches for the missing captain's courier.",
- }),
- updatedHooks: [
- "# Pending Hooks",
- "",
- "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| captain-letter | 1 | mystery | open | 1 | The captain's disappearance is explained. | The sealed letter points to the vanished captain. |",
- "",
- ].join("\n"),
- });
- });
- try {
- await runner.importChapters({
- bookId,
- chapters: [
- { title: "Prelude", content: "A cold wind crossed the harbor." },
- ],
- });
- expect(stateSeenByFirstReplay).toContain("| Current Chapter | 0 |");
- expect(stateSeenByFirstReplay).not.toContain("FUTURE LEAK");
- expect(hooksSeenByFirstReplay).toContain("# Pending Hooks");
- expect(hooksSeenByFirstReplay).not.toContain("future-hook");
- expect(hooksSeenByFirstReplay).not.toContain("FUTURE LEAK");
- expect(subplotSeenByFirstReplay).not.toContain("FUTURE LEAK");
- expect(emotionalSeenByFirstReplay).not.toContain("FUTURE LEAK");
- expect(matrixSeenByFirstReplay).not.toContain("FUTURE LEAK");
- expect(summariesSeenByFirstReplay).not.toContain("FUTURE LEAK");
- const snapshotZeroState = await readFile(join(storyDir, "snapshots", "0", "current_state.md"), "utf-8");
- const snapshotZeroHooks = await readFile(join(storyDir, "snapshots", "0", "pending_hooks.md"), "utf-8");
- expect(snapshotZeroState).toContain("| Current Chapter | 0 |");
- expect(snapshotZeroState).not.toContain("FUTURE LEAK");
- expect(snapshotZeroHooks).toContain("# Pending Hooks");
- expect(snapshotZeroHooks).not.toContain("future-hook");
- expect(snapshotZeroHooks).not.toContain("FUTURE LEAK");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- sqliteIt("rebuilds current facts from the revised chapter snapshot", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const chaptersDir = join(state.bookDir(bookId), "chapters");
- const oldState = createStateCard({
- chapter: 1,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- });
- const revisedState = createStateCard({
- chapter: 1,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue no longer hides the oath token.",
- goal: "Confront the vanished mentor.",
- conflict: "The oath token is public now, forcing the confrontation.",
- });
- await Promise.all([
- writeFile(join(chaptersDir, "0001_Test_Chapter.md"), "# 第1章 Test Chapter\n\nOriginal body.", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), oldState, "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- ]);
- await state.saveChapterIndex(bookId, [{
- number: 1,
- title: "Test Chapter",
- status: "audit-failed",
- wordCount: "Original body.".length,
- createdAt: "2026-03-19T00:00:00.000Z",
- updatedAt: "2026-03-19T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- }]);
- await state.snapshotState(bookId, 1);
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [CRITICAL_ISSUE],
- summary: "needs revision",
- }),
- )
- .mockResolvedValueOnce(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: "Revised body.",
- wordCount: "Revised body.".length,
- updatedState: revisedState,
- updatedHooks: "# Pending Hooks\n",
- }),
- );
- try {
- await runner.reviseDraft(bookId, 1);
- const memoryDb = new MemoryDB(state.bookDir(bookId));
- try {
- expect(memoryDb.getCurrentFacts()).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- predicate: "Current Conflict",
- object: "The oath token is public now, forcing the confrontation.",
- validFromChapter: 1,
- sourceChapter: 1,
- }),
- ]),
- );
- } finally {
- memoryDb.close();
- }
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("feeds long-span fatigue warnings back into pipeline audit and drift correction", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const now = "2026-03-19T00:00:00.000Z";
- await Promise.all([
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 2,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The debt trail keeps narrowing.",
- }), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- writeFile(
- join(storyDir, "chapter_summaries.md"),
- [
- "# 章节摘要",
- "",
- "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
- "|------|------|----------|----------|----------|----------|----------|----------|",
- "| 1 | 旧路 | 林越 | 进城 | 潜伏开始 | 债印未解 | 克制 | 布局 |",
- "| 2 | 暗巷 | 林越 | 试探 | 目标未变 | 债印未解 | 克制 | 布局 |",
- ].join("\n"),
- "utf-8",
- ),
- writeFile(join(state.bookDir(bookId), "chapters", "0001_旧路.md"), "# 第1章 旧路\n\n城门在晨雾里半开。林越顺着石阶慢慢往里走。巷口那盏灯一直没有灭。", "utf-8"),
- writeFile(join(state.bookDir(bookId), "chapters", "0002_暗巷.md"), "# 第2章 暗巷\n\n午后的风掠过墙头。林越没有回头,只是沿着阴影继续向前。墙后的铃声很轻。", "utf-8"),
- ]);
- await state.saveChapterIndex(bookId, [
- {
- number: 1,
- title: "旧路",
- status: "ready-for-review",
- wordCount: 36,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- lengthWarnings: [],
- },
- {
- number: 2,
- title: "暗巷",
- status: "ready-for-review",
- wordCount: 36,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- lengthWarnings: [],
- },
- ]);
- vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
- createWriterOutput({
- chapterNumber: 3,
- title: "回声",
- content: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。风从更深的巷子里吹了出来。",
- wordCount: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。风从更深的巷子里吹了出来。".length,
- updatedState: createStateCard({
- chapter: 3,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The debt trail keeps narrowing.",
- }),
- updatedLedger: "",
- updatedHooks: "# Pending Hooks\n",
- chapterSummary: "| 3 | 回声 | 林越 | 继续潜伏 | 目标未变 | 债印未解 | 克制 | 布局 |",
- }),
- );
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "ok",
- }),
- );
- try {
- const result = await runner.writeNextChapter(bookId);
- const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
- expect(result.auditResult.issues.some((issue) => issue.category === "节奏单调")).toBe(true);
- expect(currentState).toContain("节奏单调");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const chaptersDir = join(state.bookDir(bookId), "chapters");
- await Promise.all([
- writeFile(join(chaptersDir, "0001_Test_Chapter.md"), "# 第1章 Test Chapter\n\nOriginal body.", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 1,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- }), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- ]);
- await state.saveChapterIndex(bookId, [{
- number: 1,
- title: "Test Chapter",
- status: "audit-failed",
- wordCount: "Original body.".length,
- createdAt: "2026-03-19T00:00:00.000Z",
- updatedAt: "2026-03-19T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- }]);
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
- createAuditResult({
- passed: false,
- issues: [CRITICAL_ISSUE],
- summary: "needs revision",
- }),
- );
- const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: "Spot-fixed body.",
- wordCount: "Spot-fixed body.".length,
- updatedState: createStateCard({
- chapter: 1,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is repaired.",
- }),
- updatedHooks: "# Pending Hooks\n",
- }),
- );
- try {
- await runner.reviseDraft(bookId, 1);
- expect(reviseChapter).toHaveBeenCalledTimes(1);
- expect(reviseChapter.mock.calls[0]?.[4]).toBe("spot-fix");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("passes governed control inputs into manual revise in v2 mode", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture({
- inputGovernanceMode: "v2",
- });
- const storyDir = join(state.bookDir(bookId), "story");
- const chaptersDir = join(state.bookDir(bookId), "chapters");
- const originalBody = "林越推门进去,先看见柜台后那盏没关的灯。";
- await Promise.all([
- writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n把注意力收回师债主线。\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n\n## 第1章\n先处理商会路线噪音。\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 1,
- location: "旧港便利店",
- protagonistState: "林越仍在追查师债。",
- goal: "把注意力拉回师债线索。",
- conflict: "商会路线仍在分散注意力。",
- }), "utf-8"),
- writeFile(join(storyDir, "story_bible.md"), "# 世界观设定\n\n- 誓令碎片不可伪造。\n", "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n\n- 师债线索仍未回收。\n", "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), [
- "# 章节摘要",
- "",
- "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(chaptersDir, "0001_夜灯.md"), `# 第1章 夜灯\n\n${originalBody}`, "utf-8"),
- ]);
- await state.saveChapterIndex(bookId, [{
- number: 1,
- title: "夜灯",
- status: "audit-failed",
- wordCount: originalBody.length,
- createdAt: "2026-03-19T00:00:00.000Z",
- updatedAt: "2026-03-19T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- }]);
- const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [CRITICAL_ISSUE],
- summary: "needs revision",
- }),
- )
- .mockResolvedValueOnce(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
- wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
- fixedIssues: ["- 收紧了主线焦点。"],
- updatedState: createStateCard({
- chapter: 1,
- location: "旧港便利店",
- protagonistState: "林越把注意力重新拉回师债。",
- goal: "继续追查师债。",
- conflict: "商会路线暂时退居背景。",
- }),
- updatedHooks: "# 伏笔池\n\n- 师债线索仍未回收。\n",
- }),
- );
- try {
- await runner.reviseDraft(bookId, 1);
- expect(auditChapter.mock.calls[0]?.[4]).toMatchObject({
- chapterIntent: expect.stringContaining("# Chapter Intent"),
- contextPackage: expect.objectContaining({
- selectedContext: expect.any(Array),
- }),
- ruleStack: expect.objectContaining({
- activeOverrides: expect.any(Array),
- }),
- });
- expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
- chapterIntent: expect.stringContaining("# Chapter Intent"),
- contextPackage: expect.objectContaining({
- selectedContext: expect.any(Array),
- }),
- ruleStack: expect.objectContaining({
- activeOverrides: expect.any(Array),
- }),
- lengthSpec: expect.objectContaining({
- target: 3000,
- }),
- });
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("passes merged AI-tell issues into manual revise and rejects no-improvement revisions", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const chaptersDir = join(state.bookDir(bookId), "chapters");
- const originalBody = "林越抬手。林越停步。林越转身。林越侧耳。";
- await Promise.all([
- writeFile(join(chaptersDir, "0001_Test_Chapter.md"), `# 第1章 Test Chapter\n\n${originalBody}`, "utf-8"),
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 1,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- }), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- ]);
- await state.saveChapterIndex(bookId, [{
- number: 1,
- title: "Test Chapter",
- status: "audit-failed",
- wordCount: originalBody.length,
- createdAt: "2026-03-19T00:00:00.000Z",
- updatedAt: "2026-03-19T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- }]);
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [{
- severity: "warning",
- category: "节奏",
- description: "结尾解释略多。",
- suggestion: "压缩一行解释。",
- }],
- summary: "needs revision",
- }),
- )
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [{
- severity: "warning",
- category: "节奏",
- description: "结尾解释略多。",
- suggestion: "压缩一行解释。",
- }],
- summary: "still weak",
- }),
- );
- const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: `${originalBody}\n\n修订后收束更利落。`,
- wordCount: `${originalBody}\n\n修订后收束更利落。`.length,
- fixedIssues: ["- 压缩了结尾解释。"],
- }),
- );
- try {
- const result = await runner.reviseDraft(bookId, 1);
- const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
- const savedIndex = await state.loadChapterIndex(bookId);
- expect(reviseChapter).toHaveBeenCalledTimes(1);
- expect(reviseChapter.mock.calls[0]?.[3]).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ category: "节奏" }),
- expect.objectContaining({ category: "列表式结构" }),
- ]),
- );
- expect(result.applied).toBe(false);
- expect(result.status).toBe("unchanged");
- expect(result.skippedReason).toContain("did not improve");
- expect(savedChapter).toContain(originalBody);
- expect(savedChapter).not.toContain("修订后收束更利落");
- expect(savedIndex[0]?.status).toBe("audit-failed");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("persists manual revisions only when merged audit improves", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const chaptersDir = join(state.bookDir(bookId), "chapters");
- const originalBody = "林越抬手。林越停步。林越转身。林越侧耳。";
- const revisedBody = "门被风顶开,林越先停在门槛前。\n\n他侧过身,听见墙后那道更轻的呼吸。";
- await Promise.all([
- writeFile(join(chaptersDir, "0001_Test_Chapter.md"), `# 第1章 Test Chapter\n\n${originalBody}`, "utf-8"),
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 1,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt is still personal.",
- }), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- ]);
- await state.saveChapterIndex(bookId, [{
- number: 1,
- title: "Test Chapter",
- status: "audit-failed",
- wordCount: originalBody.length,
- createdAt: "2026-03-19T00:00:00.000Z",
- updatedAt: "2026-03-19T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- }]);
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [{
- severity: "warning",
- category: "节奏",
- description: "结尾解释略多。",
- suggestion: "压缩一行解释。",
- }],
- summary: "needs revision",
- }),
- )
- .mockResolvedValueOnce(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: revisedBody,
- wordCount: revisedBody.length,
- fixedIssues: ["- 收紧了结尾节奏。"],
- updatedState: createStateCard({
- chapter: 1,
- location: "Ashen ferry crossing",
- protagonistState: "Lin Yue still hides the oath token.",
- goal: "Find the vanished mentor.",
- conflict: "The mentor debt sharpens into a direct threat.",
- }),
- updatedHooks: "# Pending Hooks\n",
- }),
- );
- try {
- const result = await runner.reviseDraft(bookId, 1);
- const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
- const savedIndex = await state.loadChapterIndex(bookId);
- expect(result.applied).toBe(true);
- expect(result.status).toBe("ready-for-review");
- expect(result.fixedIssues).toEqual(["- 收紧了结尾节奏。"]);
- expect(savedChapter).toContain(revisedBody);
- expect(savedIndex[0]?.status).toBe("ready-for-review");
- expect(savedIndex[0]?.auditIssues).toEqual([]);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("re-audits revisions against updated state overrides instead of stale on-disk truth files", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const chaptersDir = join(state.bookDir(bookId), "chapters");
- const originalBody = "Taryn kept one hand on the annexe key and listened at the door.";
- const revisedBody = `${originalBody}\n\nHe checked the seal again before he moved.`;
- await state.saveBookConfig(bookId, {
- ...(await state.loadBookConfig(bookId)),
- platform: "other",
- genre: "progression",
- language: "en",
- chapterWordCount: 1800,
- });
- await Promise.all([
- writeFile(join(chaptersDir, "0001_First.md"), `# Chapter 1: First\n\nOpening chapter.`, "utf-8"),
- writeFile(join(chaptersDir, "0002_Test_Chapter.md"), `# Chapter 2: Test Chapter\n\n${originalBody}`, "utf-8"),
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 1,
- location: "Orsden archive lower hall",
- protagonistState: "Taryn is still moving under Renn's first warning.",
- goal: "Reach the annexe.",
- conflict: "The archive is already compromised.",
- }), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- ]);
- await state.saveChapterIndex(bookId, [
- {
- number: 1,
- title: "First",
- status: "ready-for-review",
- wordCount: countChapterLength("Opening chapter.", "en_words"),
- createdAt: "2026-03-19T00:00:00.000Z",
- updatedAt: "2026-03-19T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- },
- {
- number: 2,
- title: "Test Chapter",
- status: "audit-failed",
- wordCount: countChapterLength(originalBody, "en_words"),
- createdAt: "2026-03-19T00:00:00.000Z",
- updatedAt: "2026-03-19T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- },
- ]);
- const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [{
- severity: "warning",
- category: "Pacing Check",
- description: "The beat needs a firmer end stop.",
- suggestion: "Tighten the closing move.",
- }],
- summary: "needs revision",
- }),
- )
- .mockImplementationOnce(async (_bookDir, _chapterContent, chapterNumber, _genre, options) => {
- const overrideState = (options as { truthFileOverrides?: { currentState?: string } } | undefined)
- ?.truthFileOverrides?.currentState;
- if (chapterNumber === 2 && overrideState?.includes("| Current Chapter | 2 |")) {
- return createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- });
- }
- return createAuditResult({
- passed: false,
- issues: [{
- severity: "critical",
- category: "Chronicle Drift Check",
- description: "The chapter is presented as 'chapter 2', but the supplied Current State Card still lists 'Current Chapter | 1'.",
- suggestion: "Sync the state card before re-audit.",
- }],
- summary: "stale state card",
- });
- });
- vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: revisedBody,
- wordCount: countChapterLength(revisedBody, "en_words"),
- fixedIssues: ["- Synced the annexe beat and tightened the ending."],
- updatedState: createStateCard({
- chapter: 2,
- location: "East annexe corridor",
- protagonistState: "Taryn is pressed against the annexe door with the true key in hand.",
- goal: "Open the annexe before the cart clears the court.",
- conflict: "A forged key and rival searchers have turned lawful access into a trap.",
- }),
- updatedHooks: "# Pending Hooks\n",
- }),
- );
- try {
- const result = await runner.reviseDraft(bookId, 2);
- const savedIndex = await state.loadChapterIndex(bookId);
- expect(auditChapter).toHaveBeenCalledTimes(2);
- expect(result.applied).toBe(true);
- expect(result.status).toBe("ready-for-review");
- expect(savedIndex[1]?.status).toBe("ready-for-review");
- expect(savedIndex[1]?.auditIssues).toEqual([]);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("uses chapter length telemetry target for manual revise when available", async () => {
- const { root, runner, state, bookId } = await createRunnerFixture();
- const storyDir = join(state.bookDir(bookId), "story");
- const chaptersDir = join(state.bookDir(bookId), "chapters");
- const originalBody = "Tarin waited by the crooked berth marker and counted the missing lines twice.";
- const revisedBody = `${originalBody}\n\nHe did not move until the second bell rang across the water.`;
- await state.saveBookConfig(bookId, {
- ...(await state.loadBookConfig(bookId)),
- platform: "other",
- genre: "progression",
- language: "en",
- chapterWordCount: 1800,
- });
- await Promise.all([
- writeFile(join(chaptersDir, "0001_Test_Chapter.md"), `# Chapter 1: Test Chapter\n\n${originalBody}`, "utf-8"),
- writeFile(join(storyDir, "current_state.md"), createStateCard({
- chapter: 1,
- location: "Dock Nine",
- protagonistState: "Tarin still carries the sealed packet.",
- goal: "Find Captain Voss.",
- conflict: "The berth is wrong and the crew is missing.",
- }), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
- ]);
- await state.saveChapterIndex(bookId, [{
- number: 1,
- title: "Test Chapter",
- status: "audit-failed",
- wordCount: countChapterLength(originalBody, "en_words"),
- createdAt: "2026-03-19T00:00:00.000Z",
- updatedAt: "2026-03-19T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- lengthTelemetry: {
- target: 900,
- softMin: 778,
- softMax: 1022,
- hardMin: 655,
- hardMax: 1145,
- countingMode: "en_words",
- writerCount: countChapterLength(originalBody, "en_words"),
- postWriterNormalizeCount: countChapterLength(originalBody, "en_words"),
- postReviseCount: 0,
- finalCount: countChapterLength(originalBody, "en_words"),
- normalizeApplied: false,
- lengthWarning: false,
- },
- }]);
- vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
- .mockResolvedValueOnce(
- createAuditResult({
- passed: false,
- issues: [CRITICAL_ISSUE],
- summary: "needs revision",
- }),
- )
- .mockResolvedValueOnce(
- createAuditResult({
- passed: true,
- issues: [],
- summary: "clean",
- }),
- );
- const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
- createReviseOutput({
- revisedContent: revisedBody,
- wordCount: countChapterLength(revisedBody, "en_words"),
- fixedIssues: ["- Tightened the berth discovery beat."],
- updatedState: createStateCard({
- chapter: 1,
- location: "Dock Nine",
- protagonistState: "Tarin still carries the sealed packet.",
- goal: "Find Captain Voss.",
- conflict: "The berth is wrong and the crew is missing.",
- }),
- updatedHooks: "# Pending Hooks\n",
- }),
- );
- try {
- await runner.reviseDraft(bookId, 1, "polish");
- expect(reviseChapter).toHaveBeenCalledTimes(1);
- expect(reviseChapter.mock.calls[0]?.[6]?.lengthSpec).toMatchObject({
- target: 900,
- countingMode: "en_words",
- });
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- });
|