pipeline-runner.test.ts 174 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { createRequire } from "node:module";
  3. import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
  4. import { tmpdir } from "node:os";
  5. import { join } from "node:path";
  6. import { PipelineRunner } from "../pipeline/runner.js";
  7. import * as llmProvider from "../llm/provider.js";
  8. import { StateManager } from "../state/manager.js";
  9. import { ArchitectAgent } from "../agents/architect.js";
  10. import { PlannerAgent } from "../agents/planner.js";
  11. import { ComposerAgent } from "../agents/composer.js";
  12. import { WriterAgent, type WriteChapterOutput } from "../agents/writer.js";
  13. import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
  14. import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/continuity.js";
  15. import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
  16. import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
  17. import { StateValidatorAgent } from "../agents/state-validator.js";
  18. import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
  19. import type { BookConfig } from "../models/book.js";
  20. import type { ChapterMeta } from "../models/chapter.js";
  21. import { MemoryDB } from "../state/memory-db.js";
  22. import * as memoryDbModule from "../state/memory-db.js";
  23. import { countChapterLength } from "../utils/length-metrics.js";
  24. const require = createRequire(import.meta.url);
  25. const hasNodeSqlite = (() => {
  26. try {
  27. require("node:sqlite");
  28. return true;
  29. } catch {
  30. return false;
  31. }
  32. })();
  33. const sqliteIt = hasNodeSqlite ? it : it.skip;
  34. const ZERO_USAGE = {
  35. promptTokens: 0,
  36. completionTokens: 0,
  37. totalTokens: 0,
  38. } as const;
  39. const CRITICAL_ISSUE: AuditIssue = {
  40. severity: "critical",
  41. category: "continuity",
  42. description: "Fix the chapter state",
  43. suggestion: "Repair the contradiction",
  44. };
  45. function createAuditResult(overrides: Partial<AuditResult>): AuditResult {
  46. return {
  47. passed: true,
  48. issues: [],
  49. summary: "ok",
  50. tokenUsage: ZERO_USAGE,
  51. ...overrides,
  52. };
  53. }
  54. function createWriterOutput(overrides: Partial<WriteChapterOutput> = {}): WriteChapterOutput {
  55. return {
  56. chapterNumber: 1,
  57. title: "Test Chapter",
  58. content: "Original chapter body.",
  59. wordCount: "Original chapter body.".length,
  60. preWriteCheck: "check",
  61. postSettlement: "settled",
  62. updatedState: "writer state",
  63. updatedLedger: "writer ledger",
  64. updatedHooks: "writer hooks",
  65. chapterSummary: "| 1 | Original summary |",
  66. updatedSubplots: "writer subplots",
  67. updatedEmotionalArcs: "writer emotions",
  68. updatedCharacterMatrix: "writer matrix",
  69. postWriteErrors: [],
  70. postWriteWarnings: [],
  71. tokenUsage: ZERO_USAGE,
  72. ...overrides,
  73. };
  74. }
  75. function createReviseOutput(overrides: Partial<ReviseOutput> = {}): ReviseOutput {
  76. return {
  77. revisedContent: "Revised chapter body.",
  78. wordCount: "Revised chapter body.".length,
  79. fixedIssues: ["fixed"],
  80. updatedState: "revised state",
  81. updatedLedger: "revised ledger",
  82. updatedHooks: "revised hooks",
  83. tokenUsage: ZERO_USAGE,
  84. ...overrides,
  85. };
  86. }
  87. function createAnalyzedOutput(overrides: Partial<WriteChapterOutput> = {}): WriteChapterOutput {
  88. return createWriterOutput({
  89. content: "Analyzed final chapter body.",
  90. wordCount: "Analyzed final chapter body.".length,
  91. updatedState: "analyzed state",
  92. updatedLedger: "analyzed ledger",
  93. updatedHooks: "analyzed hooks",
  94. chapterSummary: "| 1 | Revised summary |",
  95. updatedSubplots: "analyzed subplots",
  96. updatedEmotionalArcs: "analyzed emotions",
  97. updatedCharacterMatrix: "analyzed matrix",
  98. ...overrides,
  99. });
  100. }
  101. function createStateCard(params: {
  102. readonly chapter: number;
  103. readonly location: string;
  104. readonly protagonistState: string;
  105. readonly goal: string;
  106. readonly conflict: string;
  107. }): string {
  108. return [
  109. "# Current State",
  110. "",
  111. "| Field | Value |",
  112. "| --- | --- |",
  113. `| Current Chapter | ${params.chapter} |`,
  114. `| Current Location | ${params.location} |`,
  115. `| Protagonist State | ${params.protagonistState} |`,
  116. `| Current Goal | ${params.goal} |`,
  117. "| Current Constraint | The city gates are watched. |",
  118. "| Current Alliances | Mentor allies are scattered. |",
  119. `| Current Conflict | ${params.conflict} |`,
  120. "",
  121. ].join("\n");
  122. }
  123. function createCaptureLogger() {
  124. const infos: string[] = [];
  125. const warnings: string[] = [];
  126. const logger = {
  127. debug() {},
  128. info(message: string) {
  129. infos.push(message);
  130. },
  131. warn(message: string) {
  132. warnings.push(message);
  133. },
  134. error() {},
  135. child() {
  136. return logger;
  137. },
  138. };
  139. return { logger, infos, warnings };
  140. }
  141. async function createRunnerFixture(
  142. configOverrides: Partial<ConstructorParameters<typeof PipelineRunner>[0]> = {},
  143. ): Promise<{
  144. root: string;
  145. runner: PipelineRunner;
  146. state: StateManager;
  147. bookId: string;
  148. }> {
  149. const root = await mkdtemp(join(tmpdir(), "inkos-runner-test-"));
  150. const state = new StateManager(root);
  151. const bookId = "test-book";
  152. const now = "2026-03-19T00:00:00.000Z";
  153. const book: BookConfig = {
  154. id: bookId,
  155. title: "Test Book",
  156. platform: "tomato",
  157. genre: "xuanhuan",
  158. status: "active",
  159. targetChapters: 10,
  160. chapterWordCount: 3000,
  161. createdAt: now,
  162. updatedAt: now,
  163. };
  164. await state.saveBookConfig(bookId, book);
  165. await mkdir(join(state.bookDir(bookId), "story"), { recursive: true });
  166. await mkdir(join(state.bookDir(bookId), "chapters"), { recursive: true });
  167. const runner = new PipelineRunner({
  168. client: {
  169. provider: "openai",
  170. apiFormat: "chat",
  171. stream: false,
  172. defaults: {
  173. temperature: 0.7,
  174. maxTokens: 4096,
  175. thinkingBudget: 0, maxTokensCap: null,
  176. },
  177. } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
  178. model: "test-model",
  179. projectRoot: root,
  180. ...configOverrides,
  181. });
  182. return { root, runner, state, bookId };
  183. }
  184. describe("PipelineRunner", () => {
  185. beforeEach(() => {
  186. vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
  187. passed: true,
  188. totalScore: 85,
  189. dimensions: [],
  190. overallFeedback: "auto-pass for test",
  191. });
  192. vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(
  193. async ({ chapterContent, lengthSpec }) => ({
  194. normalizedContent: chapterContent,
  195. finalCount: countChapterLength(chapterContent, lengthSpec.countingMode),
  196. applied: false,
  197. mode: "none",
  198. tokenUsage: ZERO_USAGE,
  199. }),
  200. );
  201. vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({
  202. warnings: [],
  203. passed: true,
  204. });
  205. });
  206. afterEach(() => {
  207. vi.restoreAllMocks();
  208. });
  209. it("does not reuse override clients when credential sources differ", () => {
  210. const previousKeyA = process.env.TEST_KEY_A;
  211. const previousKeyB = process.env.TEST_KEY_B;
  212. process.env.TEST_KEY_A = "key-a";
  213. process.env.TEST_KEY_B = "key-b";
  214. try {
  215. const runner = new PipelineRunner({
  216. client: {
  217. provider: "openai",
  218. apiFormat: "chat",
  219. stream: false,
  220. defaults: {
  221. temperature: 0.7,
  222. maxTokens: 4096,
  223. thinkingBudget: 0, maxTokensCap: null,
  224. },
  225. } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
  226. model: "base-model",
  227. projectRoot: process.cwd(),
  228. defaultLLMConfig: {
  229. provider: "custom",
  230. service: "custom",
  231. baseUrl: "https://base.example/v1",
  232. apiKey: "base-key",
  233. model: "base-model",
  234. temperature: 0.7,
  235. maxTokens: 4096,
  236. thinkingBudget: 0,
  237. apiFormat: "chat",
  238. stream: false,
  239. },
  240. modelOverrides: {
  241. writer: {
  242. model: "writer-model",
  243. provider: "custom",
  244. baseUrl: "https://shared.example/v1",
  245. apiKeyEnv: "TEST_KEY_A",
  246. },
  247. auditor: {
  248. model: "auditor-model",
  249. provider: "custom",
  250. baseUrl: "https://shared.example/v1",
  251. apiKeyEnv: "TEST_KEY_B",
  252. },
  253. },
  254. });
  255. const resolveOverride = (
  256. runner as unknown as {
  257. resolveOverride: (agent: string) => { model: string; client: unknown };
  258. }
  259. ).resolveOverride.bind(runner);
  260. const writerOverride = resolveOverride("writer");
  261. const auditorOverride = resolveOverride("auditor");
  262. expect(writerOverride.client).not.toBe(auditorOverride.client);
  263. } finally {
  264. if (previousKeyA === undefined) delete process.env.TEST_KEY_A;
  265. else process.env.TEST_KEY_A = previousKeyA;
  266. if (previousKeyB === undefined) delete process.env.TEST_KEY_B;
  267. else process.env.TEST_KEY_B = previousKeyB;
  268. }
  269. });
  270. it("initializes control documents during book creation", async () => {
  271. const root = await mkdtemp(join(tmpdir(), "inkos-init-book-test-"));
  272. const bookId = "bootstrap-book";
  273. const brief = "# Author Intent\n\nKeep the narrative centered on mentor conflict.\n";
  274. const now = "2026-03-22T00:00:00.000Z";
  275. const book: BookConfig = {
  276. id: bookId,
  277. title: "Bootstrap Book",
  278. platform: "tomato",
  279. genre: "xuanhuan",
  280. status: "outlining",
  281. targetChapters: 10,
  282. chapterWordCount: 3000,
  283. createdAt: now,
  284. updatedAt: now,
  285. };
  286. const runner = new PipelineRunner({
  287. client: {
  288. provider: "openai",
  289. apiFormat: "chat",
  290. stream: false,
  291. defaults: {
  292. temperature: 0.7,
  293. maxTokens: 4096,
  294. thinkingBudget: 0, maxTokensCap: null,
  295. },
  296. } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
  297. model: "test-model",
  298. projectRoot: root,
  299. externalContext: brief,
  300. });
  301. vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
  302. storyBible: "# Story Bible\n",
  303. volumeOutline: "# Volume Outline\n",
  304. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
  305. currentState: "# Current State\n",
  306. pendingHooks: "# Pending Hooks\n",
  307. });
  308. try {
  309. await runner.initBook(book);
  310. const storyDir = join(root, "books", bookId, "story");
  311. const authorIntent = await readFile(join(storyDir, "author_intent.md"), "utf-8");
  312. const currentFocus = await readFile(join(storyDir, "current_focus.md"), "utf-8");
  313. const runtimeDir = await stat(join(storyDir, "runtime"));
  314. expect(authorIntent).toContain("mentor conflict");
  315. expect(currentFocus).toContain("当前聚焦");
  316. expect(runtimeDir.isDirectory()).toBe(true);
  317. } finally {
  318. await rm(root, { recursive: true, force: true });
  319. }
  320. });
  321. it("applies creation-draft overrides while initializing a book", async () => {
  322. const root = await mkdtemp(join(tmpdir(), "inkos-init-book-overrides-"));
  323. const bookId = "override-book";
  324. const book: BookConfig = {
  325. id: bookId,
  326. title: "Override Book",
  327. platform: "tomato",
  328. genre: "xuanhuan",
  329. status: "outlining",
  330. targetChapters: 20,
  331. chapterWordCount: 2800,
  332. createdAt: "2026-04-13T00:00:00.000Z",
  333. updatedAt: "2026-04-13T00:00:00.000Z",
  334. };
  335. const runner = new PipelineRunner({
  336. client: {
  337. provider: "openai",
  338. apiFormat: "chat",
  339. stream: false,
  340. defaults: {
  341. temperature: 0.7,
  342. maxTokens: 4096,
  343. thinkingBudget: 0, maxTokensCap: null,
  344. },
  345. } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
  346. model: "test-model",
  347. projectRoot: root,
  348. });
  349. const generateFoundationSpy = vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
  350. storyBible: "# Story Bible\n",
  351. volumeOutline: "# Volume Outline\n",
  352. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
  353. currentState: "# Current State\n",
  354. pendingHooks: "# Pending Hooks\n",
  355. });
  356. try {
  357. await runner.initBook(book, {
  358. externalContext: "世界观重点:近未来港口城,账本与旧案牵出多方势力。",
  359. authorIntent: "# 作者意图\n\n写成冷硬、克制、利益驱动的商战悬疑。\n",
  360. currentFocus: "# 当前聚焦\n\n先把旧账线和港口势力网立住。\n",
  361. });
  362. expect(generateFoundationSpy).toHaveBeenCalledWith(
  363. book,
  364. expect.stringContaining("近未来港口城"),
  365. undefined,
  366. );
  367. const storyDir = join(root, "books", bookId, "story");
  368. await expect(readFile(join(storyDir, "author_intent.md"), "utf-8"))
  369. .resolves.toContain("冷硬、克制、利益驱动");
  370. await expect(readFile(join(storyDir, "current_focus.md"), "utf-8"))
  371. .resolves.toContain("旧账线和港口势力网");
  372. } finally {
  373. await rm(root, { recursive: true, force: true });
  374. }
  375. });
  376. it("feeds foundation review feedback into the regeneration call after a rejection", async () => {
  377. const { root, runner, bookId } = await createRunnerFixture();
  378. const reviewer = new FoundationReviewerAgent({
  379. client: {
  380. provider: "openai",
  381. apiFormat: "chat",
  382. stream: false,
  383. defaults: {
  384. temperature: 0.7,
  385. maxTokens: 4096,
  386. thinkingBudget: 0, maxTokensCap: null,
  387. },
  388. } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
  389. model: "test-model",
  390. projectRoot: root,
  391. bookId,
  392. });
  393. const foundation = {
  394. storyBible: "# Story Bible",
  395. volumeOutline: "# Volume Outline",
  396. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules",
  397. currentState: "# Current State",
  398. pendingHooks: "# Pending Hooks",
  399. };
  400. const generate = vi.fn(async (_reviewFeedback?: string) => foundation);
  401. const reviewMock = vi.mocked(FoundationReviewerAgent.prototype.review);
  402. reviewMock.mockReset();
  403. reviewMock
  404. .mockResolvedValueOnce({
  405. passed: false,
  406. totalScore: 68,
  407. dimensions: [
  408. {
  409. name: "核心冲突",
  410. score: 58,
  411. feedback: "核心冲突不够集中,主线悬念没有站稳。",
  412. },
  413. {
  414. name: "开篇节奏",
  415. score: 76,
  416. feedback: "前五章起势偏慢,爆点不够前置。",
  417. },
  418. ],
  419. overallFeedback: "请把冲突收紧,并在更早的位置建立爆点。",
  420. })
  421. .mockResolvedValueOnce({
  422. passed: true,
  423. totalScore: 88,
  424. dimensions: [],
  425. overallFeedback: "通过",
  426. });
  427. try {
  428. const result = await (runner as unknown as {
  429. generateAndReviewFoundation: (params: {
  430. readonly generate: (reviewFeedback?: string) => Promise<typeof foundation>;
  431. readonly reviewer: FoundationReviewerAgent;
  432. readonly mode: "original";
  433. readonly language: "zh";
  434. readonly stageLanguage: "zh";
  435. readonly maxRetries: number;
  436. }) => Promise<typeof foundation>;
  437. }).generateAndReviewFoundation({
  438. generate,
  439. reviewer,
  440. mode: "original",
  441. language: "zh",
  442. stageLanguage: "zh",
  443. maxRetries: 2,
  444. });
  445. expect(result).toEqual(foundation);
  446. expect(generate).toHaveBeenCalledTimes(2);
  447. expect(generate.mock.calls[0]?.[0]).toBeUndefined();
  448. expect(generate.mock.calls[1]?.[0]).toContain("请把冲突收紧,并在更早的位置建立爆点。");
  449. expect(generate.mock.calls[1]?.[0]).toContain("核心冲突");
  450. expect(generate.mock.calls[1]?.[0]).toContain("核心冲突不够集中");
  451. expect(generate.mock.calls[1]?.[0]).toContain("开篇节奏");
  452. } finally {
  453. await rm(root, { recursive: true, force: true });
  454. }
  455. });
  456. it("bootstraps missing control documents for legacy books before writing", async () => {
  457. const { root, runner, bookId } = await createRunnerFixture();
  458. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  459. createWriterOutput({
  460. chapterNumber: 1,
  461. content: "Legacy chapter body.",
  462. wordCount: "Legacy chapter body.".length,
  463. }),
  464. );
  465. try {
  466. await runner.writeDraft(bookId);
  467. const storyDir = join(root, "books", bookId, "story");
  468. const authorIntent = await readFile(join(storyDir, "author_intent.md"), "utf-8");
  469. const currentFocus = await readFile(join(storyDir, "current_focus.md"), "utf-8");
  470. const runtimeDir = await stat(join(storyDir, "runtime"));
  471. expect(authorIntent).toContain("Author Intent");
  472. expect(currentFocus).toContain("Current Focus");
  473. expect(runtimeDir.isDirectory()).toBe(true);
  474. } finally {
  475. await rm(root, { recursive: true, force: true });
  476. }
  477. });
  478. it("cleans staged files when initBook fails before foundation is complete", async () => {
  479. const root = await mkdtemp(join(tmpdir(), "inkos-init-rollback-"));
  480. const runner = new PipelineRunner({
  481. client: {
  482. provider: "openai",
  483. apiFormat: "chat",
  484. stream: false,
  485. defaults: {
  486. temperature: 0.7,
  487. maxTokens: 4096,
  488. thinkingBudget: 0, maxTokensCap: null,
  489. },
  490. } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
  491. model: "test-model",
  492. projectRoot: root,
  493. });
  494. const now = "2026-03-29T00:00:00.000Z";
  495. const book: BookConfig = {
  496. id: "atomic-book",
  497. title: "Atomic Book",
  498. platform: "tomato",
  499. genre: "xuanhuan",
  500. status: "outlining",
  501. targetChapters: 12,
  502. chapterWordCount: 2200,
  503. createdAt: now,
  504. updatedAt: now,
  505. };
  506. vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockRejectedValue(
  507. new Error("missing book_rules section"),
  508. );
  509. try {
  510. await expect(runner.initBook(book)).rejects.toThrow("missing book_rules section");
  511. await expect(stat(join(root, "books", "atomic-book"))).rejects.toThrow();
  512. } finally {
  513. await rm(root, { recursive: true, force: true });
  514. }
  515. });
  516. it("routes writeDraft through planner and composer in v2 mode", async () => {
  517. const { root, runner, state, bookId } = await createRunnerFixture({
  518. inputGovernanceMode: "v2",
  519. });
  520. await Promise.all([
  521. writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
  522. writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
  523. writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  524. writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  525. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  526. ]);
  527. const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
  528. const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
  529. const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  530. createWriterOutput({
  531. chapterNumber: 1,
  532. content: "Governed draft body.",
  533. wordCount: "Governed draft body.".length,
  534. }),
  535. );
  536. try {
  537. await runner.writeDraft(bookId, "Ignore the guild chase and bring focus back to mentor conflict.");
  538. expect(planChapter).toHaveBeenCalledTimes(1);
  539. expect(composeChapter).toHaveBeenCalledTimes(1);
  540. const writeInput = writeChapter.mock.calls[0]?.[0];
  541. expect(writeInput?.externalContext).toBeUndefined();
  542. expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
  543. expect(writeInput?.contextPackage?.selectedContext.length).toBeGreaterThan(0);
  544. expect(writeInput?.ruleStack?.activeOverrides).toHaveLength(1);
  545. const runtimeDir = join(state.bookDir(bookId), "story", "runtime");
  546. await expect(stat(join(runtimeDir, "chapter-0001.intent.md"))).resolves.toBeTruthy();
  547. await expect(stat(join(runtimeDir, "chapter-0001.context.json"))).resolves.toBeTruthy();
  548. await expect(stat(join(runtimeDir, "chapter-0001.rule-stack.yaml"))).resolves.toBeTruthy();
  549. await expect(stat(join(runtimeDir, "chapter-0001.trace.json"))).resolves.toBeTruthy();
  550. } finally {
  551. await rm(root, { recursive: true, force: true });
  552. }
  553. });
  554. it("reuses an existing planned intent for draft when no new context is provided in v2 mode", async () => {
  555. const { root, runner, state, bookId } = await createRunnerFixture({
  556. inputGovernanceMode: "v2",
  557. });
  558. await Promise.all([
  559. mkdir(join(state.bookDir(bookId), "story", "runtime"), { recursive: true }),
  560. writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nTrack the merchant guild trail.\n", "utf-8"),
  561. writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
  562. writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  563. writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  564. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  565. writeFile(
  566. join(state.bookDir(bookId), "story", "runtime", "chapter-0001.intent.md"),
  567. [
  568. "# Chapter Intent",
  569. "",
  570. "## Goal",
  571. "Bring the focus back to the mentor conflict.",
  572. "",
  573. "## Outline Node",
  574. "Track the merchant guild trail.",
  575. "",
  576. "## Must Keep",
  577. "- Lin Yue still hides the broken oath token.",
  578. "",
  579. "## Must Avoid",
  580. "- Do not reveal the mastermind",
  581. "",
  582. "## Style Emphasis",
  583. "- Keep the narrative emotionally close to the mentor conflict.",
  584. "",
  585. "## Conflicts",
  586. "- outline_vs_request: allow local outline deferral",
  587. "",
  588. "## Pending Hooks Snapshot",
  589. "- none",
  590. "",
  591. "## Chapter Summaries Snapshot",
  592. "- none",
  593. "",
  594. ].join("\n"),
  595. "utf-8",
  596. ),
  597. ]);
  598. const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
  599. const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  600. createWriterOutput({
  601. chapterNumber: 1,
  602. content: "Governed draft body.",
  603. wordCount: "Governed draft body.".length,
  604. }),
  605. );
  606. try {
  607. await runner.writeDraft(bookId);
  608. expect(planChapter).not.toHaveBeenCalled();
  609. const writeInput = writeChapter.mock.calls[0]?.[0];
  610. expect(writeInput?.chapterIntent).toContain("Bring the focus back to the mentor conflict.");
  611. expect(writeInput?.ruleStack?.activeOverrides).toHaveLength(1);
  612. } finally {
  613. await rm(root, { recursive: true, force: true });
  614. }
  615. });
  616. sqliteIt("syncs current-state facts into memory.db after drafting a chapter", async () => {
  617. const { root, runner, state, bookId } = await createRunnerFixture();
  618. const chapterOneState = createStateCard({
  619. chapter: 1,
  620. location: "Ashen ferry crossing",
  621. protagonistState: "Lin Yue hides the broken oath token.",
  622. goal: "Find the vanished mentor before dawn.",
  623. conflict: "Mentor debt blocks every choice.",
  624. });
  625. await Promise.all([
  626. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  627. writeFile(
  628. join(state.bookDir(bookId), "story", "current_state.md"),
  629. createStateCard({
  630. chapter: 0,
  631. location: "Shrine outskirts",
  632. protagonistState: "Lin Yue begins with the oath token hidden.",
  633. goal: "Reach the trial city.",
  634. conflict: "The trial deadline is closing in.",
  635. }),
  636. "utf-8",
  637. ),
  638. ]);
  639. await state.snapshotState(bookId, 0);
  640. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  641. createWriterOutput({
  642. chapterNumber: 1,
  643. content: "Draft body.",
  644. wordCount: "Draft body.".length,
  645. updatedState: chapterOneState,
  646. updatedHooks: [
  647. "# Pending Hooks",
  648. "",
  649. "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
  650. "| --- | --- | --- | --- | --- | --- | --- |",
  651. "| mentor-debt | 1 | relationship | open | 1 | 6 | The mentor debt remains unresolved |",
  652. "",
  653. ].join("\n"),
  654. chapterSummary: [
  655. "| 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 |",
  656. ].join("\n"),
  657. }),
  658. );
  659. try {
  660. await runner.writeDraft(bookId);
  661. const memoryDb = new MemoryDB(state.bookDir(bookId));
  662. try {
  663. expect(memoryDb.getCurrentFacts()).toEqual(
  664. expect.arrayContaining([
  665. expect.objectContaining({
  666. predicate: "Current Conflict",
  667. object: "Mentor debt blocks every choice.",
  668. validFromChapter: 1,
  669. sourceChapter: 1,
  670. }),
  671. ]),
  672. );
  673. expect(memoryDb.getChapterCount()).toBe(1);
  674. expect(memoryDb.getActiveHooks()).toEqual(
  675. expect.arrayContaining([
  676. expect.objectContaining({
  677. hookId: "mentor-debt",
  678. status: "open",
  679. }),
  680. ]),
  681. );
  682. } finally {
  683. memoryDb.close();
  684. }
  685. } finally {
  686. await rm(root, { recursive: true, force: true });
  687. }
  688. });
  689. sqliteIt("syncs narrative memory from structured runtime state instead of stale markdown projections", async () => {
  690. const { root, runner, state, bookId } = await createRunnerFixture();
  691. const storyDir = join(state.bookDir(bookId), "story");
  692. const stateDir = join(storyDir, "state");
  693. const chaptersDir = join(state.bookDir(bookId), "chapters");
  694. await mkdir(stateDir, { recursive: true });
  695. await writeFile(
  696. join(chaptersDir, "index.json"),
  697. JSON.stringify([
  698. { number: 1, title: "Ch1", status: "approved" },
  699. { number: 2, title: "Ch2", status: "approved" },
  700. { number: 3, title: "Ch3", status: "approved" },
  701. ]),
  702. "utf-8",
  703. );
  704. await Promise.all([
  705. writeFile(
  706. join(storyDir, "chapter_summaries.md"),
  707. [
  708. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  709. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  710. "| 1 | Markdown Summary | Lin Yue | Old markdown event | Old markdown state | markdown-hook advanced | tense | fallback |",
  711. "",
  712. ].join("\n"),
  713. "utf-8",
  714. ),
  715. writeFile(
  716. join(storyDir, "pending_hooks.md"),
  717. [
  718. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  719. "| --- | --- | --- | --- | --- | --- | --- |",
  720. "| markdown-hook | 1 | mystery | open | 1 | 4 | Old markdown hook |",
  721. "",
  722. ].join("\n"),
  723. "utf-8",
  724. ),
  725. writeFile(join(stateDir, "manifest.json"), JSON.stringify({
  726. schemaVersion: 2,
  727. language: "en",
  728. lastAppliedChapter: 3,
  729. projectionVersion: 1,
  730. migrationWarnings: [],
  731. }, null, 2), "utf-8"),
  732. writeFile(join(stateDir, "current_state.json"), JSON.stringify({
  733. chapter: 3,
  734. facts: [],
  735. }, null, 2), "utf-8"),
  736. writeFile(join(stateDir, "hooks.json"), JSON.stringify({
  737. hooks: [
  738. {
  739. hookId: "structured-hook",
  740. startChapter: 2,
  741. type: "relationship",
  742. status: "progressing",
  743. lastAdvancedChapter: 3,
  744. expectedPayoff: "Reveal the mentor ledger.",
  745. notes: "Structured hook should win.",
  746. },
  747. ],
  748. }, null, 2), "utf-8"),
  749. writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
  750. rows: [
  751. {
  752. chapter: 3,
  753. title: "Structured Summary",
  754. characters: "Lin Yue",
  755. events: "Structured runtime state event.",
  756. stateChanges: "Structured runtime state shift.",
  757. hookActivity: "structured-hook advanced",
  758. mood: "grim",
  759. chapterType: "mainline",
  760. },
  761. ],
  762. }, null, 2), "utf-8"),
  763. ]);
  764. try {
  765. await (runner as unknown as {
  766. syncNarrativeMemoryIndex: (targetBookId: string) => Promise<void>;
  767. }).syncNarrativeMemoryIndex(bookId);
  768. const memoryDb = new MemoryDB(state.bookDir(bookId));
  769. try {
  770. expect(memoryDb.getSummaries(1, 10)).toEqual([
  771. expect.objectContaining({
  772. chapter: 3,
  773. title: "Structured Summary",
  774. events: "Structured runtime state event.",
  775. }),
  776. ]);
  777. expect(memoryDb.getActiveHooks()).toEqual([
  778. expect.objectContaining({
  779. hookId: "structured-hook",
  780. status: "progressing",
  781. }),
  782. ]);
  783. } finally {
  784. memoryDb.close();
  785. }
  786. } finally {
  787. await rm(root, { recursive: true, force: true });
  788. }
  789. });
  790. it("uses a friendly fallback warning when sqlite memory indexing is unavailable", async () => {
  791. const { logger, warnings } = createCaptureLogger();
  792. const { root, runner, state, bookId } = await createRunnerFixture({
  793. logger,
  794. });
  795. await Promise.all([
  796. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  797. writeFile(
  798. join(state.bookDir(bookId), "story", "current_state.md"),
  799. createStateCard({
  800. chapter: 0,
  801. location: "Shrine outskirts",
  802. protagonistState: "Lin Yue begins with the oath token hidden.",
  803. goal: "Reach the trial city.",
  804. conflict: "The trial deadline is closing in.",
  805. }),
  806. "utf-8",
  807. ),
  808. ]);
  809. vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation(() => {
  810. const error = new Error("No such built-in module: node:sqlite");
  811. (error as Error & { code?: string }).code = "ERR_UNKNOWN_BUILTIN_MODULE";
  812. throw error;
  813. });
  814. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  815. createWriterOutput({
  816. chapterNumber: 1,
  817. content: "Draft body.",
  818. wordCount: "Draft body.".length,
  819. }),
  820. );
  821. try {
  822. const result = await runner.writeDraft(bookId);
  823. expect(result.chapterNumber).toBe(1);
  824. console.log("DEBUG warnings:", JSON.stringify(warnings, null, 2));
  825. expect(warnings).toContain(
  826. "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
  827. );
  828. expect(warnings.join("\n")).not.toContain("node:sqlite");
  829. expect(warnings.join("\n")).not.toContain("ERR_UNKNOWN_BUILTIN_MODULE");
  830. expect(warnings.join("\n")).not.toContain("状态事实同步已跳过:");
  831. } finally {
  832. await rm(root, { recursive: true, force: true });
  833. }
  834. });
  835. it("does not misclassify generic runtime errors as sqlite-unavailable fallback", async () => {
  836. const { logger, warnings } = createCaptureLogger();
  837. const { root, runner, state, bookId } = await createRunnerFixture({
  838. logger,
  839. });
  840. await Promise.all([
  841. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  842. writeFile(
  843. join(state.bookDir(bookId), "story", "current_state.md"),
  844. createStateCard({
  845. chapter: 0,
  846. location: "Shrine outskirts",
  847. protagonistState: "Lin Yue begins with the oath token hidden.",
  848. goal: "Reach the trial city.",
  849. conflict: "The trial deadline is closing in.",
  850. }),
  851. "utf-8",
  852. ),
  853. ]);
  854. vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation(() => {
  855. throw new Error("sync failed while handling cached node:sqlite telemetry text");
  856. });
  857. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  858. createWriterOutput({
  859. chapterNumber: 1,
  860. content: "Draft body.",
  861. wordCount: "Draft body.".length,
  862. }),
  863. );
  864. try {
  865. const result = await runner.writeDraft(bookId);
  866. expect(result.chapterNumber).toBe(1);
  867. expect(warnings.join("\n")).toContain("叙事记忆同步已跳过:");
  868. expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引");
  869. } finally {
  870. await rm(root, { recursive: true, force: true });
  871. }
  872. });
  873. sqliteIt("recovers when sqlite-unavailable signature is transient and probe succeeds", async () => {
  874. const { logger, warnings } = createCaptureLogger();
  875. const { root, runner, state, bookId } = await createRunnerFixture({
  876. logger,
  877. inputGovernanceMode: "legacy",
  878. });
  879. await Promise.all([
  880. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  881. writeFile(
  882. join(state.bookDir(bookId), "story", "current_state.md"),
  883. createStateCard({
  884. chapter: 0,
  885. location: "Shrine outskirts",
  886. protagonistState: "Lin Yue begins with the oath token hidden.",
  887. goal: "Reach the trial city.",
  888. conflict: "The trial deadline is closing in.",
  889. }),
  890. "utf-8",
  891. ),
  892. ]);
  893. const RealMemoryDB = memoryDbModule.MemoryDB;
  894. let constructorCalls = 0;
  895. vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation((...args: ConstructorParameters<typeof memoryDbModule.MemoryDB>) => {
  896. if (constructorCalls === 0) {
  897. constructorCalls += 1;
  898. const error = new Error("No such built-in module: node:sqlite");
  899. (error as Error & { code?: string }).code = "ERR_UNKNOWN_BUILTIN_MODULE";
  900. throw error;
  901. }
  902. constructorCalls += 1;
  903. return new RealMemoryDB(...args);
  904. });
  905. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  906. createWriterOutput({
  907. chapterNumber: 1,
  908. content: "Draft body.",
  909. wordCount: "Draft body.".length,
  910. chapterSummary: "| 1 | Draft summary | Lin Yue | Draft event | Draft shift | hook advanced | tense | transition |",
  911. updatedHooks: [
  912. "# Pending Hooks",
  913. "",
  914. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  915. "| --- | --- | --- | --- | --- | --- | --- |",
  916. "| mentor-debt | 1 | relationship | open | 1 | 3 | Draft hook |",
  917. ].join("\n"),
  918. }),
  919. );
  920. try {
  921. const result = await runner.writeDraft(bookId);
  922. expect(result.chapterNumber).toBe(1);
  923. expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引");
  924. expect(warnings.join("\n")).not.toContain("叙事记忆同步已跳过");
  925. const memoryDb = new MemoryDB(state.bookDir(bookId));
  926. try {
  927. expect(memoryDb.getChapterCount()).toBe(1);
  928. expect(memoryDb.getActiveHooks()).toEqual(
  929. expect.arrayContaining([
  930. expect.objectContaining({
  931. hookId: "mentor-debt",
  932. status: "open",
  933. }),
  934. ]),
  935. );
  936. } finally {
  937. memoryDb.close();
  938. }
  939. } finally {
  940. await rm(root, { recursive: true, force: true });
  941. }
  942. });
  943. sqliteIt("retries transient sqlite busy errors during narrative memory sync", async () => {
  944. const { logger, warnings } = createCaptureLogger();
  945. const { root, runner, state, bookId } = await createRunnerFixture({
  946. logger,
  947. inputGovernanceMode: "legacy",
  948. });
  949. await Promise.all([
  950. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  951. writeFile(
  952. join(state.bookDir(bookId), "story", "current_state.md"),
  953. createStateCard({
  954. chapter: 0,
  955. location: "Shrine outskirts",
  956. protagonistState: "Lin Yue begins with the oath token hidden.",
  957. goal: "Reach the trial city.",
  958. conflict: "The trial deadline is closing in.",
  959. }),
  960. "utf-8",
  961. ),
  962. ]);
  963. const RealMemoryDB = memoryDbModule.MemoryDB;
  964. let constructorCalls = 0;
  965. vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation((...args: ConstructorParameters<typeof memoryDbModule.MemoryDB>) => {
  966. if (constructorCalls === 0) {
  967. constructorCalls += 1;
  968. const error = new Error("database is locked");
  969. (error as Error & { code?: string }).code = "SQLITE_BUSY";
  970. throw error;
  971. }
  972. constructorCalls += 1;
  973. return new RealMemoryDB(...args);
  974. });
  975. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  976. createWriterOutput({
  977. chapterNumber: 1,
  978. content: "Draft body.",
  979. wordCount: "Draft body.".length,
  980. chapterSummary: "| 1 | Draft summary | Lin Yue | Draft event | Draft shift | hook advanced | tense | transition |",
  981. updatedHooks: [
  982. "# Pending Hooks",
  983. "",
  984. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  985. "| --- | --- | --- | --- | --- | --- | --- |",
  986. "| mentor-debt | 1 | relationship | open | 1 | 3 | Draft hook |",
  987. ].join("\n"),
  988. }),
  989. );
  990. try {
  991. const result = await runner.writeDraft(bookId);
  992. expect(result.chapterNumber).toBe(1);
  993. expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引");
  994. expect(warnings.join("\n")).not.toContain("叙事记忆同步已跳过");
  995. const memoryDb = new MemoryDB(state.bookDir(bookId));
  996. try {
  997. expect(memoryDb.getChapterCount()).toBe(1);
  998. expect(memoryDb.getActiveHooks()).toEqual(
  999. expect.arrayContaining([
  1000. expect.objectContaining({
  1001. hookId: "mentor-debt",
  1002. status: "open",
  1003. }),
  1004. ]),
  1005. );
  1006. } finally {
  1007. memoryDb.close();
  1008. }
  1009. } finally {
  1010. await rm(root, { recursive: true, force: true });
  1011. }
  1012. });
  1013. it("logs explicit stage messages during book initialization", async () => {
  1014. const { logger, infos } = createCaptureLogger();
  1015. const { root, runner, state, bookId } = await createRunnerFixture({ logger });
  1016. const book = await state.loadBookConfig(bookId);
  1017. vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
  1018. storyBible: "# Story Bible\n",
  1019. volumeOutline: "# Volume Outline\n",
  1020. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
  1021. currentState: createStateCard({
  1022. chapter: 0,
  1023. location: "Ashen ferry crossing",
  1024. protagonistState: "Lin Yue still hides the oath token.",
  1025. goal: "Find the vanished mentor.",
  1026. conflict: "The mentor debt is still personal.",
  1027. }),
  1028. pendingHooks: "# Pending Hooks\n",
  1029. });
  1030. try {
  1031. await runner.initBook(book);
  1032. expect(infos).toEqual(expect.arrayContaining([
  1033. "阶段:保存书籍配置",
  1034. "阶段:生成基础设定",
  1035. "阶段:写入基础设定文件",
  1036. "阶段:初始化控制文档",
  1037. "阶段:创建初始快照",
  1038. ]));
  1039. } finally {
  1040. await rm(root, { recursive: true, force: true });
  1041. }
  1042. });
  1043. it("marks an outlining book as active after drafting the first chapter", async () => {
  1044. const { root, runner, state, bookId } = await createRunnerFixture();
  1045. const book = await state.loadBookConfig(bookId);
  1046. await state.saveBookConfig(bookId, { ...book, status: "outlining" });
  1047. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1048. createWriterOutput({
  1049. chapterNumber: 1,
  1050. content: "Draft body.",
  1051. wordCount: "Draft body.".length,
  1052. }),
  1053. );
  1054. try {
  1055. await runner.writeDraft(bookId);
  1056. const book = await state.loadBookConfig(bookId);
  1057. expect(book.status).toBe("active");
  1058. } finally {
  1059. await rm(root, { recursive: true, force: true });
  1060. }
  1061. });
  1062. it("routes writeNextChapter through planner and composer in v2 mode", async () => {
  1063. const { root, runner, state, bookId } = await createRunnerFixture({
  1064. inputGovernanceMode: "v2",
  1065. });
  1066. await Promise.all([
  1067. writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
  1068. writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
  1069. writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  1070. writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  1071. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  1072. ]);
  1073. const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
  1074. const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
  1075. const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1076. createWriterOutput({
  1077. chapterNumber: 1,
  1078. content: "Governed pipeline draft.",
  1079. wordCount: "Governed pipeline draft.".length,
  1080. }),
  1081. );
  1082. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  1083. createAuditResult({
  1084. passed: true,
  1085. issues: [],
  1086. summary: "clean",
  1087. }),
  1088. );
  1089. try {
  1090. await runner.writeNextChapter(bookId, 220);
  1091. expect(planChapter).toHaveBeenCalledTimes(1);
  1092. expect(composeChapter).toHaveBeenCalledTimes(1);
  1093. const writeInput = writeChapter.mock.calls[0]?.[0];
  1094. expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
  1095. expect(writeInput?.contextPackage?.selectedContext.length).toBeGreaterThan(0);
  1096. } finally {
  1097. await rm(root, { recursive: true, force: true });
  1098. }
  1099. });
  1100. it("re-plans instead of reusing a persisted invalid intent artifact in v2 mode", async () => {
  1101. const { root, runner, state, bookId } = await createRunnerFixture({
  1102. inputGovernanceMode: "v2",
  1103. });
  1104. const storyDir = join(state.bookDir(bookId), "story");
  1105. const runtimeDir = join(storyDir, "runtime");
  1106. await mkdir(runtimeDir, { recursive: true });
  1107. await Promise.all([
  1108. writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
  1109. writeFile(
  1110. join(storyDir, "volume_outline.md"),
  1111. [
  1112. "# Volume Outline",
  1113. "",
  1114. "### Golden First Three Chapters Rule",
  1115. "",
  1116. "**Chapter 1:**",
  1117. "Track the merchant guild trail.",
  1118. "",
  1119. ].join("\n"),
  1120. "utf-8",
  1121. ),
  1122. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  1123. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  1124. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  1125. writeFile(
  1126. join(runtimeDir, "chapter-0001.intent.md"),
  1127. [
  1128. "# Chapter Intent",
  1129. "",
  1130. "## Goal",
  1131. "**",
  1132. "",
  1133. "## Outline Node",
  1134. "**",
  1135. "",
  1136. "## Must Keep",
  1137. "- none",
  1138. "",
  1139. "## Must Avoid",
  1140. "- none",
  1141. "",
  1142. "## Style Emphasis",
  1143. "- none",
  1144. "",
  1145. "## Conflicts",
  1146. "- none",
  1147. "",
  1148. ].join("\n"),
  1149. "utf-8",
  1150. ),
  1151. ]);
  1152. const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
  1153. const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1154. createWriterOutput({
  1155. chapterNumber: 1,
  1156. content: "Governed pipeline draft.",
  1157. wordCount: "Governed pipeline draft.".length,
  1158. }),
  1159. );
  1160. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  1161. createAuditResult({
  1162. passed: true,
  1163. issues: [],
  1164. summary: "clean",
  1165. }),
  1166. );
  1167. try {
  1168. await runner.writeNextChapter(bookId, 220);
  1169. expect(planChapter).toHaveBeenCalledTimes(1);
  1170. const writeInput = writeChapter.mock.calls[0]?.[0];
  1171. expect(writeInput?.chapterIntent).toContain("Track the merchant guild trail.");
  1172. expect(writeInput?.chapterIntent).not.toContain("\n**\n");
  1173. } finally {
  1174. await rm(root, { recursive: true, force: true });
  1175. }
  1176. });
  1177. it("logs explicit stage messages during writeNextChapter", async () => {
  1178. const { logger, infos } = createCaptureLogger();
  1179. const { root, runner, state, bookId } = await createRunnerFixture({
  1180. inputGovernanceMode: "v2",
  1181. logger,
  1182. });
  1183. await Promise.all([
  1184. writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
  1185. writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
  1186. writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  1187. writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  1188. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  1189. ]);
  1190. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1191. createWriterOutput({
  1192. chapterNumber: 1,
  1193. content: "Governed pipeline draft.",
  1194. wordCount: "Governed pipeline draft.".length,
  1195. }),
  1196. );
  1197. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  1198. createAuditResult({
  1199. passed: true,
  1200. issues: [],
  1201. summary: "clean",
  1202. }),
  1203. );
  1204. try {
  1205. await runner.writeNextChapter(bookId, 220);
  1206. expect(infos).toEqual(expect.arrayContaining([
  1207. "阶段:准备章节输入",
  1208. "阶段:撰写章节草稿",
  1209. "阶段:审计草稿",
  1210. "阶段:落盘最终章节",
  1211. "阶段:生成最终真相文件",
  1212. "阶段:校验真相文件变更",
  1213. "阶段:同步记忆索引",
  1214. "阶段:更新章节索引与快照",
  1215. ]));
  1216. } finally {
  1217. await rm(root, { recursive: true, force: true });
  1218. }
  1219. });
  1220. it("logs English stage messages during writeNextChapter for English books", async () => {
  1221. const { logger, infos } = createCaptureLogger();
  1222. const { root, runner, state, bookId } = await createRunnerFixture({
  1223. inputGovernanceMode: "v2",
  1224. logger,
  1225. });
  1226. const englishBook = {
  1227. ...(await state.loadBookConfig(bookId)),
  1228. genre: "other",
  1229. language: "en" as const,
  1230. chapterWordCount: 220,
  1231. };
  1232. await state.saveBookConfig(bookId, englishBook);
  1233. await Promise.all([
  1234. writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
  1235. writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
  1236. writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  1237. writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  1238. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  1239. ]);
  1240. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1241. createWriterOutput({
  1242. chapterNumber: 1,
  1243. content: "Governed pipeline draft.",
  1244. wordCount: countChapterLength("Governed pipeline draft.", "en_words"),
  1245. }),
  1246. );
  1247. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  1248. createAuditResult({
  1249. passed: true,
  1250. issues: [],
  1251. summary: "clean",
  1252. }),
  1253. );
  1254. try {
  1255. await runner.writeNextChapter(bookId, 220);
  1256. expect(infos).toEqual(expect.arrayContaining([
  1257. "Stage: preparing chapter inputs",
  1258. "Stage: writing chapter draft",
  1259. "Stage: auditing draft",
  1260. "Stage: persisting final chapter",
  1261. "Stage: rebuilding final truth files",
  1262. "Stage: validating truth file updates",
  1263. "Stage: syncing memory indexes",
  1264. "Stage: updating chapter index and snapshots",
  1265. ]));
  1266. expect(infos.join("\n")).not.toContain("阶段:");
  1267. } finally {
  1268. await rm(root, { recursive: true, force: true });
  1269. }
  1270. });
  1271. it("writes English audit drift guidance into a dedicated file without polluting current_state", async () => {
  1272. const { root, runner, state, bookId } = await createRunnerFixture();
  1273. const englishBook = {
  1274. ...(await state.loadBookConfig(bookId)),
  1275. genre: "other",
  1276. language: "en" as const,
  1277. chapterWordCount: 220,
  1278. };
  1279. await state.saveBookConfig(bookId, englishBook);
  1280. await Promise.all([
  1281. writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nKeep the pressure on the harbor debt.\n", "utf-8"),
  1282. writeFile(join(state.bookDir(bookId), "story", "current_state.md"), createStateCard({
  1283. chapter: 0,
  1284. location: "Harbor gate",
  1285. protagonistState: "Lin Yue is tracking the vanished mentor.",
  1286. goal: "Reach the sealed berth.",
  1287. conflict: "The harbor debt keeps pulling him sideways.",
  1288. }), "utf-8"),
  1289. writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The harbor seal cannot be forged.\n", "utf-8"),
  1290. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- The vanished mentor still owes a debt.\n", "utf-8"),
  1291. ]);
  1292. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1293. createWriterOutput({
  1294. chapterNumber: 1,
  1295. content: "Lin Yue reached the sealed berth before dawn.",
  1296. wordCount: countChapterLength("Lin Yue reached the sealed berth before dawn.", "en_words"),
  1297. updatedState: createStateCard({
  1298. chapter: 1,
  1299. location: "Sealed berth",
  1300. protagonistState: "Lin Yue is winded but focused.",
  1301. goal: "Inspect the berth before the guild arrives.",
  1302. conflict: "The harbor debt is still active.",
  1303. }),
  1304. updatedHooks: "# Pending Hooks\n\n- The vanished mentor still owes a debt.\n",
  1305. }),
  1306. );
  1307. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  1308. createAuditResult({
  1309. passed: true,
  1310. issues: [{
  1311. severity: "warning",
  1312. category: "continuity",
  1313. description: "Keep the berth timing precise in the next chapter.",
  1314. suggestion: "Avoid skipping the dawn transition.",
  1315. }],
  1316. summary: "warning only",
  1317. }),
  1318. );
  1319. try {
  1320. await runner.writeNextChapter(bookId, 220);
  1321. const driftFile = await readFile(join(state.bookDir(bookId), "story", "audit_drift.md"), "utf-8");
  1322. const currentState = await readFile(join(state.bookDir(bookId), "story", "current_state.md"), "utf-8");
  1323. expect(driftFile).toContain("## Audit Drift Correction");
  1324. expect(driftFile).toContain("> Chapter 1 audit found the following issues");
  1325. expect(driftFile).not.toContain("## 审计纠偏");
  1326. expect(driftFile).not.toContain("下一章写作前参照");
  1327. expect(currentState).not.toContain("Audit Drift Correction");
  1328. } finally {
  1329. await rm(root, { recursive: true, force: true });
  1330. }
  1331. });
  1332. it("passes reduced control inputs into auditor and reviser in v2 mode", async () => {
  1333. const { root, runner, state, bookId } = await createRunnerFixture({
  1334. inputGovernanceMode: "v2",
  1335. });
  1336. await Promise.all([
  1337. writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
  1338. writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
  1339. writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  1340. writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  1341. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  1342. ]);
  1343. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1344. createWriterOutput({
  1345. chapterNumber: 1,
  1346. content: "Needs governed revision.",
  1347. wordCount: "Needs governed revision.".length,
  1348. }),
  1349. );
  1350. const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  1351. createAuditResult({
  1352. passed: false,
  1353. issues: [CRITICAL_ISSUE],
  1354. summary: "needs revision",
  1355. }),
  1356. );
  1357. const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  1358. createReviseOutput({
  1359. revisedContent: "Governed revised content.",
  1360. wordCount: "Governed revised content.".length,
  1361. }),
  1362. );
  1363. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  1364. createAnalyzedOutput({
  1365. content: "Governed revised content.",
  1366. wordCount: "Governed revised content.".length,
  1367. }),
  1368. );
  1369. try {
  1370. await runner.writeNextChapter(bookId, 220);
  1371. expect(auditChapter.mock.calls[0]?.[4]).toMatchObject({
  1372. chapterIntent: expect.stringContaining("# Chapter Intent"),
  1373. contextPackage: expect.objectContaining({
  1374. selectedContext: expect.any(Array),
  1375. }),
  1376. ruleStack: expect.objectContaining({
  1377. activeOverrides: expect.any(Array),
  1378. }),
  1379. });
  1380. expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
  1381. chapterIntent: expect.stringContaining("# Chapter Intent"),
  1382. contextPackage: expect.objectContaining({
  1383. selectedContext: expect.any(Array),
  1384. }),
  1385. ruleStack: expect.objectContaining({
  1386. activeOverrides: expect.any(Array),
  1387. }),
  1388. lengthSpec: expect.objectContaining({
  1389. target: 220,
  1390. softMin: 190,
  1391. softMax: 250,
  1392. }),
  1393. });
  1394. } finally {
  1395. await rm(root, { recursive: true, force: true });
  1396. }
  1397. });
  1398. it("passes governed control inputs into final truth rebuild in v2 mode", async () => {
  1399. const { root, runner, state, bookId } = await createRunnerFixture({
  1400. inputGovernanceMode: "v2",
  1401. });
  1402. await Promise.all([
  1403. writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
  1404. writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
  1405. writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  1406. writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  1407. writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  1408. ]);
  1409. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1410. createWriterOutput({
  1411. chapterNumber: 1,
  1412. content: "Original draft body.",
  1413. wordCount: "Original draft body.".length,
  1414. }),
  1415. );
  1416. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  1417. .mockResolvedValueOnce(
  1418. createAuditResult({
  1419. passed: false,
  1420. issues: [CRITICAL_ISSUE],
  1421. summary: "needs revision",
  1422. }),
  1423. )
  1424. .mockResolvedValueOnce(
  1425. createAuditResult({
  1426. passed: true,
  1427. issues: [],
  1428. summary: "clean",
  1429. }),
  1430. );
  1431. vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  1432. createReviseOutput({
  1433. revisedContent: "Governed revised body.",
  1434. wordCount: "Governed revised body.".length,
  1435. }),
  1436. );
  1437. const analyzeChapter = vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  1438. createAnalyzedOutput({
  1439. content: "Governed revised body.",
  1440. wordCount: "Governed revised body.".length,
  1441. }),
  1442. );
  1443. try {
  1444. await runner.writeNextChapter(bookId, 220);
  1445. expect(analyzeChapter.mock.calls[0]?.[0]).toMatchObject({
  1446. chapterIntent: expect.stringContaining("# Chapter Intent"),
  1447. contextPackage: expect.objectContaining({
  1448. selectedContext: expect.any(Array),
  1449. }),
  1450. ruleStack: expect.objectContaining({
  1451. activeOverrides: expect.any(Array),
  1452. }),
  1453. });
  1454. } finally {
  1455. await rm(root, { recursive: true, force: true });
  1456. }
  1457. });
  1458. it("normalizes revised output once before re-audit when it leaves the target band", async () => {
  1459. const { root, runner, bookId } = await createRunnerFixture();
  1460. const writerDraft = "中段正文。".repeat(40);
  1461. const overlongRevision = "修订后正文。".repeat(60);
  1462. const normalizedRevision = "归一正文。".repeat(40);
  1463. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1464. createWriterOutput({
  1465. content: writerDraft,
  1466. wordCount: writerDraft.length,
  1467. }),
  1468. );
  1469. const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  1470. .mockResolvedValueOnce(
  1471. createAuditResult({
  1472. passed: false,
  1473. issues: [CRITICAL_ISSUE],
  1474. summary: "needs revision",
  1475. }),
  1476. )
  1477. .mockResolvedValueOnce(
  1478. createAuditResult({
  1479. passed: true,
  1480. issues: [],
  1481. summary: "clean",
  1482. }),
  1483. );
  1484. const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  1485. createReviseOutput({
  1486. revisedContent: overlongRevision,
  1487. wordCount: overlongRevision.length,
  1488. }),
  1489. );
  1490. const normalizeChapter = vi.mocked(
  1491. LengthNormalizerAgent.prototype.normalizeChapter,
  1492. ).mockResolvedValue({
  1493. normalizedContent: normalizedRevision,
  1494. finalCount: normalizedRevision.length,
  1495. applied: true,
  1496. mode: "compress",
  1497. tokenUsage: ZERO_USAGE,
  1498. });
  1499. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  1500. createAnalyzedOutput({
  1501. content: normalizedRevision,
  1502. wordCount: normalizedRevision.length,
  1503. }),
  1504. );
  1505. try {
  1506. await runner.writeNextChapter(bookId, 220);
  1507. expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
  1508. lengthSpec: expect.objectContaining({
  1509. target: 220,
  1510. softMin: 190,
  1511. softMax: 250,
  1512. }),
  1513. });
  1514. expect(normalizeChapter).toHaveBeenCalledTimes(1);
  1515. expect(normalizeChapter.mock.calls[0]?.[0]).toMatchObject({
  1516. chapterContent: overlongRevision,
  1517. lengthSpec: expect.objectContaining({
  1518. target: 220,
  1519. }),
  1520. });
  1521. expect(auditChapter.mock.calls[1]?.[1]).toBe(normalizedRevision);
  1522. } finally {
  1523. await rm(root, { recursive: true, force: true });
  1524. }
  1525. });
  1526. it("normalizes overlong writer output once before audit", async () => {
  1527. const { root, runner, bookId } = await createRunnerFixture();
  1528. const overlongDraft = "冗余句子。".repeat(60);
  1529. const normalizedDraft = "压缩后的正文。".repeat(12);
  1530. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1531. createWriterOutput({
  1532. chapterNumber: 1,
  1533. content: overlongDraft,
  1534. wordCount: overlongDraft.length,
  1535. }),
  1536. );
  1537. const normalizeChapter = vi.mocked(
  1538. LengthNormalizerAgent.prototype.normalizeChapter,
  1539. ).mockResolvedValue({
  1540. normalizedContent: normalizedDraft,
  1541. finalCount: normalizedDraft.length,
  1542. applied: true,
  1543. mode: "compress",
  1544. tokenUsage: ZERO_USAGE,
  1545. });
  1546. const auditChapter = vi.spyOn(
  1547. ContinuityAuditor.prototype,
  1548. "auditChapter",
  1549. ).mockResolvedValue(
  1550. createAuditResult({
  1551. passed: true,
  1552. issues: [],
  1553. summary: "clean",
  1554. }),
  1555. );
  1556. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  1557. createAnalyzedOutput({
  1558. content: normalizedDraft,
  1559. wordCount: normalizedDraft.length,
  1560. }),
  1561. );
  1562. try {
  1563. await runner.writeNextChapter(bookId, 220);
  1564. expect(normalizeChapter).toHaveBeenCalledTimes(1);
  1565. expect(auditChapter.mock.calls[0]?.[1]).toBe(normalizedDraft);
  1566. } finally {
  1567. await rm(root, { recursive: true, force: true });
  1568. }
  1569. });
  1570. it("normalizes short writer output once before audit", async () => {
  1571. const { root, runner, bookId } = await createRunnerFixture();
  1572. const shortDraft = "短句。".repeat(20);
  1573. const normalizedDraft = "补足后的正文。".repeat(15);
  1574. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1575. createWriterOutput({
  1576. chapterNumber: 1,
  1577. content: shortDraft,
  1578. wordCount: shortDraft.length,
  1579. }),
  1580. );
  1581. const normalizeChapter = vi.mocked(
  1582. LengthNormalizerAgent.prototype.normalizeChapter,
  1583. ).mockResolvedValue({
  1584. normalizedContent: normalizedDraft,
  1585. finalCount: normalizedDraft.length,
  1586. applied: true,
  1587. mode: "expand",
  1588. tokenUsage: ZERO_USAGE,
  1589. });
  1590. const auditChapter = vi.spyOn(
  1591. ContinuityAuditor.prototype,
  1592. "auditChapter",
  1593. ).mockResolvedValue(
  1594. createAuditResult({
  1595. passed: true,
  1596. issues: [],
  1597. summary: "clean",
  1598. }),
  1599. );
  1600. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  1601. createAnalyzedOutput({
  1602. content: normalizedDraft,
  1603. wordCount: normalizedDraft.length,
  1604. }),
  1605. );
  1606. try {
  1607. await runner.writeNextChapter(bookId, 220);
  1608. expect(normalizeChapter).toHaveBeenCalledTimes(1);
  1609. expect(normalizeChapter.mock.calls[0]?.[0]).toMatchObject({
  1610. chapterContent: shortDraft,
  1611. lengthSpec: expect.objectContaining({
  1612. target: 220,
  1613. softMin: 190,
  1614. softMax: 250,
  1615. }),
  1616. });
  1617. expect(auditChapter.mock.calls[0]?.[1]).toBe(normalizedDraft);
  1618. } finally {
  1619. await rm(root, { recursive: true, force: true });
  1620. }
  1621. });
  1622. it("records a length warning when a single normalize pass still misses the hard range", async () => {
  1623. const { root, runner, state, bookId } = await createRunnerFixture();
  1624. const overlongDraft = "冗余句子。".repeat(60);
  1625. const stillOverHard = "仍然过长。".repeat(70);
  1626. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1627. createWriterOutput({
  1628. chapterNumber: 1,
  1629. content: overlongDraft,
  1630. wordCount: overlongDraft.length,
  1631. }),
  1632. );
  1633. const normalizeChapter = vi.mocked(
  1634. LengthNormalizerAgent.prototype.normalizeChapter,
  1635. ).mockResolvedValue({
  1636. normalizedContent: stillOverHard,
  1637. finalCount: stillOverHard.length,
  1638. applied: true,
  1639. mode: "compress",
  1640. tokenUsage: ZERO_USAGE,
  1641. });
  1642. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  1643. createAuditResult({
  1644. passed: true,
  1645. issues: [],
  1646. summary: "clean",
  1647. }),
  1648. );
  1649. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  1650. createAnalyzedOutput({
  1651. content: stillOverHard,
  1652. wordCount: stillOverHard.length,
  1653. }),
  1654. );
  1655. try {
  1656. const result = await runner.writeNextChapter(bookId, 220);
  1657. const chapterIndex = await state.loadChapterIndex(bookId);
  1658. const chapterMeta = chapterIndex.find((entry) => entry.number === 1);
  1659. expect(normalizeChapter).toHaveBeenCalledTimes(1);
  1660. expect((result as { lengthWarnings?: ReadonlyArray<string> }).lengthWarnings?.[0]).toContain(
  1661. "超出硬区间",
  1662. );
  1663. expect((result as { lengthTelemetry?: { finalCount: number } }).lengthTelemetry?.finalCount).toBe(
  1664. stillOverHard.length,
  1665. );
  1666. expect(chapterMeta?.lengthWarnings?.[0]).toContain("超出硬区间");
  1667. expect(chapterMeta?.lengthTelemetry?.lengthWarning).toBe(true);
  1668. } finally {
  1669. await rm(root, { recursive: true, force: true });
  1670. }
  1671. });
  1672. it("keeps the last actionable audit issues when re-audit returns failed with no issues", async () => {
  1673. const { root, runner, state, bookId } = await createRunnerFixture({
  1674. inputGovernanceMode: "legacy",
  1675. });
  1676. const storyDir = join(state.bookDir(bookId), "story");
  1677. const draftBody = "甲".repeat(210);
  1678. const revisedBody = "乙".repeat(215);
  1679. await Promise.all([
  1680. writeFile(join(storyDir, "current_state.md"), createStateCard({
  1681. chapter: 0,
  1682. location: "Ashen ferry crossing",
  1683. protagonistState: "Lin Yue still hides the oath token.",
  1684. goal: "Find the vanished mentor.",
  1685. conflict: "The mentor debt is still personal.",
  1686. }), "utf-8"),
  1687. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  1688. ]);
  1689. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1690. createWriterOutput({
  1691. content: draftBody,
  1692. wordCount: draftBody.length,
  1693. }),
  1694. );
  1695. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  1696. .mockResolvedValueOnce(
  1697. createAuditResult({
  1698. passed: false,
  1699. issues: [CRITICAL_ISSUE],
  1700. summary: "needs revision",
  1701. }),
  1702. )
  1703. .mockResolvedValueOnce(
  1704. createAuditResult({
  1705. passed: false,
  1706. issues: [],
  1707. summary: "",
  1708. }),
  1709. );
  1710. vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  1711. createReviseOutput({
  1712. revisedContent: revisedBody,
  1713. wordCount: revisedBody.length,
  1714. fixedIssues: ["- tightened continuity."],
  1715. }),
  1716. );
  1717. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  1718. createAnalyzedOutput({
  1719. content: revisedBody,
  1720. wordCount: revisedBody.length,
  1721. }),
  1722. );
  1723. try {
  1724. const result = await runner.writeNextChapter(bookId, 220);
  1725. const savedIndex = await state.loadChapterIndex(bookId);
  1726. expect(result.status).toBe("audit-failed");
  1727. expect(result.auditResult.summary).toBe("needs revision");
  1728. expect(result.auditResult.issues).toEqual([CRITICAL_ISSUE]);
  1729. expect(savedIndex[0]?.auditIssues).toEqual([
  1730. `[critical] ${CRITICAL_ISSUE.description}`,
  1731. ]);
  1732. } finally {
  1733. await rm(root, { recursive: true, force: true });
  1734. }
  1735. });
  1736. it("preserves the legacy fallback when input governance mode is legacy", async () => {
  1737. const { root, runner, bookId } = await createRunnerFixture({
  1738. inputGovernanceMode: "legacy",
  1739. externalContext: "Legacy focus only.",
  1740. });
  1741. const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
  1742. const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
  1743. const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1744. createWriterOutput({
  1745. chapterNumber: 1,
  1746. content: "Legacy draft body.",
  1747. wordCount: "Legacy draft body.".length,
  1748. }),
  1749. );
  1750. try {
  1751. await runner.writeDraft(bookId);
  1752. expect(planChapter).not.toHaveBeenCalled();
  1753. expect(composeChapter).not.toHaveBeenCalled();
  1754. const writeInput = writeChapter.mock.calls[0]?.[0];
  1755. expect(writeInput?.externalContext).toBe("Legacy focus only.");
  1756. expect(writeInput?.chapterIntent).toBeUndefined();
  1757. expect(writeInput?.contextPackage).toBeUndefined();
  1758. expect(writeInput?.ruleStack).toBeUndefined();
  1759. } finally {
  1760. await rm(root, { recursive: true, force: true });
  1761. }
  1762. });
  1763. it("uses the latest revised content as the input for follow-up spot-fix revisions", async () => {
  1764. const { root, runner, bookId } = await createRunnerFixture();
  1765. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1766. createWriterOutput({
  1767. content: "Original draft body.",
  1768. wordCount: "Original draft body.".length,
  1769. postWriteErrors: [
  1770. {
  1771. severity: "error",
  1772. rule: "post-write",
  1773. description: "Needs a deterministic fix",
  1774. suggestion: "Repair the line",
  1775. },
  1776. ],
  1777. }),
  1778. );
  1779. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  1780. .mockResolvedValueOnce(createAuditResult({
  1781. passed: false,
  1782. issues: [CRITICAL_ISSUE],
  1783. summary: "needs another revision",
  1784. }))
  1785. .mockResolvedValueOnce(createAuditResult({
  1786. passed: true,
  1787. issues: [],
  1788. summary: "clean",
  1789. }));
  1790. const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter")
  1791. .mockResolvedValueOnce(createReviseOutput({
  1792. revisedContent: "After first fix.",
  1793. wordCount: "After first fix.".length,
  1794. }))
  1795. .mockResolvedValueOnce(createReviseOutput({
  1796. revisedContent: "After second fix.",
  1797. wordCount: "After second fix.".length,
  1798. }));
  1799. vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
  1800. vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
  1801. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  1802. createAnalyzedOutput({
  1803. content: "After second fix.",
  1804. wordCount: "After second fix.".length,
  1805. }),
  1806. );
  1807. await runner.writeNextChapter(bookId);
  1808. expect(reviseChapter).toHaveBeenCalledTimes(2);
  1809. expect(reviseChapter.mock.calls[1]?.[1]).toBe("After first fix.");
  1810. await rm(root, { recursive: true, force: true });
  1811. });
  1812. it("persists truth files derived from the final revised chapter", async () => {
  1813. const { root, runner, state, bookId } = await createRunnerFixture();
  1814. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1815. createWriterOutput({
  1816. content: "Original draft body.",
  1817. wordCount: "Original draft body.".length,
  1818. updatedState: "original state",
  1819. updatedLedger: "original ledger",
  1820. updatedHooks: "original hooks",
  1821. chapterSummary: "| 1 | Original summary |",
  1822. updatedSubplots: "original subplots",
  1823. updatedEmotionalArcs: "original emotions",
  1824. updatedCharacterMatrix: "original matrix",
  1825. postWriteErrors: [
  1826. {
  1827. severity: "error",
  1828. rule: "post-write",
  1829. description: "Needs a deterministic fix",
  1830. suggestion: "Repair the line",
  1831. },
  1832. ],
  1833. }),
  1834. );
  1835. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  1836. createAuditResult({
  1837. passed: true,
  1838. issues: [],
  1839. summary: "clean",
  1840. }),
  1841. );
  1842. vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  1843. createReviseOutput({
  1844. revisedContent: "Final revised body.",
  1845. wordCount: "Final revised body.".length,
  1846. }),
  1847. );
  1848. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  1849. createAnalyzedOutput({
  1850. content: "Final revised body.",
  1851. wordCount: "Final revised body.".length,
  1852. updatedState: "final analyzed state",
  1853. updatedLedger: "final analyzed ledger",
  1854. updatedHooks: "final analyzed hooks",
  1855. chapterSummary: "| 1 | Final analyzed summary |",
  1856. updatedSubplots: "final analyzed subplots",
  1857. updatedEmotionalArcs: "final analyzed emotions",
  1858. updatedCharacterMatrix: "final analyzed matrix",
  1859. }),
  1860. );
  1861. await runner.writeNextChapter(bookId);
  1862. const storyDir = join(state.bookDir(bookId), "story");
  1863. await expect(readFile(join(storyDir, "current_state.md"), "utf-8"))
  1864. .resolves.toContain("final analyzed state");
  1865. await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8"))
  1866. .resolves.toContain("final analyzed hooks");
  1867. await expect(readFile(join(storyDir, "particle_ledger.md"), "utf-8"))
  1868. .resolves.toContain("final analyzed ledger");
  1869. await expect(readFile(join(storyDir, "chapter_summaries.md"), "utf-8"))
  1870. .resolves.toContain("Final analyzed summary");
  1871. await expect(readFile(join(storyDir, "subplot_board.md"), "utf-8"))
  1872. .resolves.toContain("final analyzed subplots");
  1873. await expect(readFile(join(storyDir, "emotional_arcs.md"), "utf-8"))
  1874. .resolves.toContain("final analyzed emotions");
  1875. await expect(readFile(join(storyDir, "character_matrix.md"), "utf-8"))
  1876. .resolves.toContain("final analyzed matrix");
  1877. await rm(root, { recursive: true, force: true });
  1878. });
  1879. it("persists structured runtime state and rendered projections from writer delta output", async () => {
  1880. const { root, runner, state, bookId } = await createRunnerFixture({
  1881. inputGovernanceMode: "legacy",
  1882. });
  1883. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1884. createWriterOutput({
  1885. content: "Lin Yue follows the debt into the river-port ledger.",
  1886. wordCount: countChapterLength("Lin Yue follows the debt into the river-port ledger.", "en_words"),
  1887. postWriteErrors: [],
  1888. postWriteWarnings: [],
  1889. runtimeStateDelta: {
  1890. chapter: 1,
  1891. currentStatePatch: {
  1892. currentGoal: "Follow the debt through the river-port ledger.",
  1893. currentConflict: "Guild pressure keeps pulling against the debt trail.",
  1894. },
  1895. hookOps: {
  1896. upsert: [
  1897. {
  1898. hookId: "mentor-debt",
  1899. startChapter: 1,
  1900. type: "relationship",
  1901. status: "open",
  1902. lastAdvancedChapter: 1,
  1903. expectedPayoff: "Reveal why the mentor vanished.",
  1904. notes: "The river-port ledger sharpens the debt line.",
  1905. },
  1906. ],
  1907. mention: [],
  1908. resolve: [],
  1909. defer: [],
  1910. },
  1911. newHookCandidates: [],
  1912. chapterSummary: {
  1913. chapter: 1,
  1914. title: "River Ledger",
  1915. characters: "Lin Yue",
  1916. events: "Lin Yue follows the debt into the river-port ledger.",
  1917. stateChanges: "The debt line sharpens.",
  1918. hookActivity: "mentor-debt advanced",
  1919. mood: "tense",
  1920. chapterType: "investigation",
  1921. },
  1922. subplotOps: [],
  1923. emotionalArcOps: [],
  1924. characterMatrixOps: [],
  1925. notes: [],
  1926. },
  1927. }),
  1928. );
  1929. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  1930. createAuditResult({
  1931. passed: true,
  1932. issues: [],
  1933. summary: "clean",
  1934. }),
  1935. );
  1936. await runner.writeNextChapter(bookId);
  1937. const storyDir = join(state.bookDir(bookId), "story");
  1938. const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
  1939. const hooks = await readFile(join(storyDir, "pending_hooks.md"), "utf-8");
  1940. const summaries = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8");
  1941. const manifest = JSON.parse(await readFile(join(storyDir, "state", "manifest.json"), "utf-8"));
  1942. const stateCurrent = JSON.parse(await readFile(join(storyDir, "state", "current_state.json"), "utf-8"));
  1943. const stateHooks = JSON.parse(await readFile(join(storyDir, "state", "hooks.json"), "utf-8"));
  1944. const stateSummaries = JSON.parse(await readFile(join(storyDir, "state", "chapter_summaries.json"), "utf-8"));
  1945. expect(currentState).toContain("Follow the debt through the river-port ledger.");
  1946. expect(hooks).toContain("mentor-debt");
  1947. expect(summaries).toContain("River Ledger");
  1948. expect(manifest.lastAppliedChapter).toBe(1);
  1949. expect(stateCurrent.chapter).toBe(1);
  1950. expect(stateHooks.hooks[0]?.hookId).toBe("mentor-debt");
  1951. expect(stateSummaries.rows[0]?.title).toBe("River Ledger");
  1952. await rm(root, { recursive: true, force: true });
  1953. });
  1954. it("repairs chapter-number drift in writer delta before persisting runtime state", async () => {
  1955. const { root, runner, state, bookId } = await createRunnerFixture({
  1956. inputGovernanceMode: "legacy",
  1957. });
  1958. const storyDir = join(state.bookDir(bookId), "story");
  1959. await mkdir(join(storyDir, "state"), { recursive: true });
  1960. await Promise.all([
  1961. writeFile(join(storyDir, "current_state.md"), createStateCard({
  1962. chapter: 0,
  1963. location: "Ashen ferry crossing",
  1964. protagonistState: "Lin Yue still hides the oath token.",
  1965. goal: "Find the vanished mentor.",
  1966. conflict: "The mentor debt is still personal.",
  1967. }), "utf-8"),
  1968. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  1969. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  1970. writeFile(join(storyDir, "state", "manifest.json"), JSON.stringify({
  1971. schemaVersion: 2,
  1972. language: "en",
  1973. lastAppliedChapter: 0,
  1974. projectionVersion: 1,
  1975. migrationWarnings: [],
  1976. }, null, 2), "utf-8"),
  1977. writeFile(join(storyDir, "state", "current_state.json"), JSON.stringify({
  1978. chapter: 0,
  1979. facts: [],
  1980. }, null, 2), "utf-8"),
  1981. writeFile(join(storyDir, "state", "hooks.json"), JSON.stringify({
  1982. hooks: [],
  1983. }, null, 2), "utf-8"),
  1984. writeFile(join(storyDir, "state", "chapter_summaries.json"), JSON.stringify({
  1985. rows: [],
  1986. }, null, 2), "utf-8"),
  1987. ]);
  1988. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  1989. createWriterOutput({
  1990. content: "Broken chapter body.",
  1991. wordCount: countChapterLength("Broken chapter body.", "en_words"),
  1992. postWriteErrors: [],
  1993. postWriteWarnings: [],
  1994. runtimeStateDelta: {
  1995. chapter: 0,
  1996. hookOps: {
  1997. upsert: [],
  1998. resolve: [],
  1999. defer: [],
  2000. },
  2001. notes: [],
  2002. } as unknown as NonNullable<ReturnType<typeof createWriterOutput>["runtimeStateDelta"]>,
  2003. }),
  2004. );
  2005. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  2006. createAuditResult({
  2007. passed: true,
  2008. issues: [],
  2009. summary: "clean",
  2010. }),
  2011. );
  2012. const result = await runner.writeNextChapter(bookId);
  2013. expect(result.status).toBe("ready-for-review");
  2014. await expect(readFile(join(storyDir, "current_state.md"), "utf-8"))
  2015. .resolves.toMatch(/\|\s*(Current Chapter|当前章节)\s*\|\s*1\s*\|/);
  2016. await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8"))
  2017. .resolves.toContain("\"lastAppliedChapter\": 1");
  2018. await rm(root, { recursive: true, force: true });
  2019. });
  2020. it("rolls back persisted runtime state when writer delta contains natural-language numeric drift", async () => {
  2021. const { root, runner, state, bookId } = await createRunnerFixture({
  2022. inputGovernanceMode: "legacy",
  2023. });
  2024. const storyDir = join(state.bookDir(bookId), "story");
  2025. await mkdir(join(storyDir, "state"), { recursive: true });
  2026. await Promise.all([
  2027. writeFile(join(storyDir, "current_state.md"), createStateCard({
  2028. chapter: 0,
  2029. location: "Ashen ferry crossing",
  2030. protagonistState: "Lin Yue still hides the oath token.",
  2031. goal: "Find the vanished mentor.",
  2032. conflict: "The mentor debt is still personal.",
  2033. }), "utf-8"),
  2034. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  2035. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  2036. writeFile(join(storyDir, "state", "manifest.json"), JSON.stringify({
  2037. schemaVersion: 2,
  2038. language: "en",
  2039. lastAppliedChapter: 0,
  2040. projectionVersion: 1,
  2041. migrationWarnings: [],
  2042. }, null, 2), "utf-8"),
  2043. writeFile(join(storyDir, "state", "current_state.json"), JSON.stringify({
  2044. chapter: 0,
  2045. facts: [],
  2046. }, null, 2), "utf-8"),
  2047. writeFile(join(storyDir, "state", "hooks.json"), JSON.stringify({
  2048. hooks: [],
  2049. }, null, 2), "utf-8"),
  2050. writeFile(join(storyDir, "state", "chapter_summaries.json"), JSON.stringify({
  2051. rows: [],
  2052. }, null, 2), "utf-8"),
  2053. ]);
  2054. const beforeState = await readFile(join(storyDir, "current_state.md"), "utf-8");
  2055. const beforeManifest = await readFile(join(storyDir, "state", "manifest.json"), "utf-8");
  2056. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  2057. createWriterOutput({
  2058. content: "Broken chapter body.",
  2059. wordCount: countChapterLength("Broken chapter body.", "en_words"),
  2060. postWriteErrors: [],
  2061. postWriteWarnings: [],
  2062. runtimeStateDelta: {
  2063. chapter: 1,
  2064. hookOps: {
  2065. upsert: [
  2066. {
  2067. hookId: "mentor-debt",
  2068. startChapter: 1,
  2069. type: "relationship",
  2070. status: "open",
  2071. lastAdvancedChapter: "chapter one",
  2072. expectedPayoff: "Reveal the debt.",
  2073. notes: "Bad numeric drift.",
  2074. },
  2075. ],
  2076. resolve: [],
  2077. defer: [],
  2078. },
  2079. notes: [],
  2080. } as unknown as NonNullable<ReturnType<typeof createWriterOutput>["runtimeStateDelta"]>,
  2081. }),
  2082. );
  2083. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  2084. createAuditResult({
  2085. passed: true,
  2086. issues: [],
  2087. summary: "clean",
  2088. }),
  2089. );
  2090. await expect(runner.writeNextChapter(bookId)).rejects.toThrow();
  2091. await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe(beforeState);
  2092. await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8")).resolves.toBe(beforeManifest);
  2093. await rm(root, { recursive: true, force: true });
  2094. });
  2095. it("degrades to state-degraded when state validation errors instead of aborting", async () => {
  2096. const { root, runner, state, bookId } = await createRunnerFixture({
  2097. inputGovernanceMode: "legacy",
  2098. });
  2099. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  2100. createWriterOutput({
  2101. content: "Healthy chapter body.",
  2102. wordCount: "Healthy chapter body.".length,
  2103. }),
  2104. );
  2105. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  2106. createAuditResult({
  2107. passed: true,
  2108. issues: [],
  2109. summary: "clean",
  2110. }),
  2111. );
  2112. vi.spyOn(StateValidatorAgent.prototype, "validate").mockRejectedValue(
  2113. new Error("LLM returned empty response"),
  2114. );
  2115. const result = await runner.writeNextChapter(bookId);
  2116. expect(result.status).toBe("state-degraded");
  2117. // Chapter should be saved (content is fine, only truth files are degraded)
  2118. const index = await state.loadChapterIndex(bookId);
  2119. expect(index).toHaveLength(1);
  2120. expect(index[0]!.status).toBe("state-degraded");
  2121. await rm(root, { recursive: true, force: true });
  2122. });
  2123. it("retries settlement after state contradictions without rewriting the chapter body", async () => {
  2124. const { root, runner, state, bookId } = await createRunnerFixture({
  2125. inputGovernanceMode: "legacy",
  2126. });
  2127. const storyDir = join(state.bookDir(bookId), "story");
  2128. await Promise.all([
  2129. writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
  2130. writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
  2131. writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
  2132. ]);
  2133. const writeSpy = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  2134. createWriterOutput({
  2135. content: "Healthy chapter body with the copper token in his coat.",
  2136. wordCount: "Healthy chapter body with the copper token in his coat.".length,
  2137. updatedState: "broken state",
  2138. updatedHooks: "broken hooks",
  2139. updatedLedger: "broken ledger",
  2140. }),
  2141. );
  2142. const settleSpy = vi.spyOn(
  2143. WriterAgent.prototype as unknown as {
  2144. settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
  2145. },
  2146. "settleChapterState",
  2147. ).mockResolvedValue(
  2148. createWriterOutput({
  2149. content: "Healthy chapter body with the copper token in his coat.",
  2150. wordCount: "Healthy chapter body with the copper token in his coat.".length,
  2151. updatedState: "fixed state",
  2152. updatedHooks: "fixed hooks",
  2153. updatedLedger: "fixed ledger",
  2154. }),
  2155. );
  2156. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  2157. createAuditResult({
  2158. passed: true,
  2159. issues: [],
  2160. summary: "clean",
  2161. }),
  2162. );
  2163. vi.spyOn(StateValidatorAgent.prototype, "validate")
  2164. .mockResolvedValueOnce({
  2165. passed: false,
  2166. warnings: [{
  2167. category: "unsupported_change",
  2168. description: "状态写成铜牌未带在身上,但正文明确写了怀里的铜牌。",
  2169. }],
  2170. })
  2171. .mockResolvedValueOnce({
  2172. passed: true,
  2173. warnings: [],
  2174. });
  2175. const result = await runner.writeNextChapter(bookId);
  2176. expect(result.status).toBe("ready-for-review");
  2177. expect(writeSpy).toHaveBeenCalledTimes(1);
  2178. expect(settleSpy).toHaveBeenCalledTimes(1);
  2179. expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
  2180. chapterNumber: 1,
  2181. title: "Test Chapter",
  2182. content: "Healthy chapter body with the copper token in his coat.",
  2183. validationFeedback: expect.stringContaining("怀里的铜牌"),
  2184. }));
  2185. await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("fixed state");
  2186. await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("fixed hooks");
  2187. await rm(root, { recursive: true, force: true });
  2188. });
  2189. it("persists a state-degraded chapter without advancing truth files when settlement retry still contradicts the body", async () => {
  2190. const { root, runner, state, bookId } = await createRunnerFixture({
  2191. inputGovernanceMode: "legacy",
  2192. });
  2193. const bookDir = state.bookDir(bookId);
  2194. const storyDir = join(bookDir, "story");
  2195. const chaptersDir = join(bookDir, "chapters");
  2196. await Promise.all([
  2197. writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
  2198. writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
  2199. writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
  2200. ]);
  2201. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  2202. createWriterOutput({
  2203. content: "Healthy chapter body with the copper token in his coat.",
  2204. wordCount: "Healthy chapter body with the copper token in his coat.".length,
  2205. updatedState: "broken state",
  2206. updatedHooks: "broken hooks",
  2207. updatedLedger: "broken ledger",
  2208. }),
  2209. );
  2210. vi.spyOn(
  2211. WriterAgent.prototype as unknown as {
  2212. settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
  2213. },
  2214. "settleChapterState",
  2215. ).mockResolvedValue(
  2216. createWriterOutput({
  2217. content: "Healthy chapter body with the copper token in his coat.",
  2218. wordCount: "Healthy chapter body with the copper token in his coat.".length,
  2219. updatedState: "still broken state",
  2220. updatedHooks: "still broken hooks",
  2221. updatedLedger: "still broken ledger",
  2222. }),
  2223. );
  2224. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  2225. createAuditResult({
  2226. passed: true,
  2227. issues: [],
  2228. summary: "clean",
  2229. }),
  2230. );
  2231. vi.spyOn(StateValidatorAgent.prototype, "validate")
  2232. .mockResolvedValueOnce({
  2233. passed: false,
  2234. warnings: [{
  2235. category: "unsupported_change",
  2236. description: "settler 把铜牌写没了,但正文仍然明确带在身上。",
  2237. }],
  2238. })
  2239. .mockResolvedValueOnce({
  2240. passed: false,
  2241. warnings: [{
  2242. category: "unsupported_change",
  2243. description: "重试后仍然把铜牌写没了。",
  2244. }],
  2245. });
  2246. const result = await runner.writeNextChapter(bookId);
  2247. const savedIndex = await state.loadChapterIndex(bookId);
  2248. expect(result.status).toBe("state-degraded");
  2249. expect(savedIndex[0]?.status).toBe("state-degraded");
  2250. expect(savedIndex[0]?.auditIssues).toContain("[warning] 重试后仍然把铜牌写没了。");
  2251. await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("stable state");
  2252. await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("stable hooks");
  2253. await expect(readFile(join(storyDir, "particle_ledger.md"), "utf-8")).resolves.toBe("stable ledger");
  2254. await expect(readdir(chaptersDir)).resolves.toContain("0001_Test_Chapter.md");
  2255. await expect(stat(join(storyDir, "snapshots", "1"))).rejects.toThrow();
  2256. await rm(root, { recursive: true, force: true });
  2257. });
  2258. it("blocks writing a new chapter when the latest persisted chapter is state-degraded", async () => {
  2259. const { root, runner, state, bookId } = await createRunnerFixture({
  2260. inputGovernanceMode: "legacy",
  2261. });
  2262. const now = "2026-03-19T00:00:00.000Z";
  2263. const storyDir = join(state.bookDir(bookId), "story");
  2264. await Promise.all([
  2265. writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
  2266. writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
  2267. state.saveChapterIndex(bookId, [{
  2268. number: 1,
  2269. title: "Broken Persistence",
  2270. status: "state-degraded" as ChapterMeta["status"],
  2271. wordCount: 1234,
  2272. createdAt: now,
  2273. updatedAt: now,
  2274. auditIssues: ["[warning] state validation degraded"],
  2275. lengthWarnings: [],
  2276. }]),
  2277. writeFile(join(state.bookDir(bookId), "chapters", "0001_Broken_Persistence.md"), "# 第1章 Broken Persistence\n\nbody", "utf-8"),
  2278. ]);
  2279. await expect(runner.writeNextChapter(bookId)).rejects.toThrow(/state-degraded/i);
  2280. await rm(root, { recursive: true, force: true });
  2281. });
  2282. it("repairs the latest state-degraded chapter from persisted body without rewriting it", async () => {
  2283. const { root, runner, state, bookId } = await createRunnerFixture({
  2284. inputGovernanceMode: "legacy",
  2285. });
  2286. const now = "2026-03-19T00:00:00.000Z";
  2287. const bookDir = state.bookDir(bookId);
  2288. const storyDir = join(bookDir, "story");
  2289. await Promise.all([
  2290. writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
  2291. writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
  2292. writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
  2293. writeFile(
  2294. join(bookDir, "chapters", "0001_Broken_Persistence.md"),
  2295. "# 第1章 Broken Persistence\n\nHealthy chapter body with the copper token in his coat.",
  2296. "utf-8",
  2297. ),
  2298. state.saveChapterIndex(bookId, [{
  2299. number: 1,
  2300. title: "Broken Persistence",
  2301. status: "state-degraded" as ChapterMeta["status"],
  2302. wordCount: 55,
  2303. createdAt: now,
  2304. updatedAt: now,
  2305. auditIssues: ["[warning] 重试后仍然把铜牌写没了。"],
  2306. lengthWarnings: [],
  2307. reviewNote: JSON.stringify({
  2308. kind: "state-degraded",
  2309. baseStatus: "ready-for-review",
  2310. injectedIssues: ["[warning] 重试后仍然把铜牌写没了。"],
  2311. }),
  2312. }]),
  2313. ]);
  2314. const settleSpy = vi.spyOn(
  2315. WriterAgent.prototype as unknown as {
  2316. settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
  2317. },
  2318. "settleChapterState",
  2319. ).mockResolvedValue(
  2320. createWriterOutput({
  2321. chapterNumber: 1,
  2322. title: "Broken Persistence",
  2323. content: "Healthy chapter body with the copper token in his coat.",
  2324. wordCount: "Healthy chapter body with the copper token in his coat.".length,
  2325. updatedState: "fixed state",
  2326. updatedHooks: "fixed hooks",
  2327. updatedLedger: "fixed ledger",
  2328. }),
  2329. );
  2330. vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({
  2331. passed: true,
  2332. warnings: [],
  2333. });
  2334. const result = await (
  2335. runner as unknown as {
  2336. repairChapterState: (bookId: string, chapterNumber?: number) => Promise<{
  2337. status: string;
  2338. chapterNumber: number;
  2339. }>;
  2340. }
  2341. ).repairChapterState(bookId, 1);
  2342. const savedIndex = await state.loadChapterIndex(bookId);
  2343. expect(result.status).toBe("ready-for-review");
  2344. expect(result.chapterNumber).toBe(1);
  2345. expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
  2346. allowReapply: true,
  2347. }));
  2348. await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("fixed state");
  2349. await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("fixed hooks");
  2350. expect(savedIndex[0]?.status).toBe("ready-for-review");
  2351. expect(savedIndex[0]?.auditIssues).toEqual([]);
  2352. expect(savedIndex[0]?.reviewNote).toBeUndefined();
  2353. await rm(root, { recursive: true, force: true });
  2354. });
  2355. it("syncs the latest edited chapter body back into truth files without requiring state-degraded status", async () => {
  2356. const { root, runner, state, bookId } = await createRunnerFixture({
  2357. inputGovernanceMode: "v2",
  2358. externalContext: "把注意力收回师债主线。",
  2359. });
  2360. const now = "2026-03-19T00:00:00.000Z";
  2361. const bookDir = state.bookDir(bookId);
  2362. const storyDir = join(bookDir, "story");
  2363. await Promise.all([
  2364. writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n商会路线优先。\n", "utf-8"),
  2365. writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n\n## 第1章\n先处理商会路线噪音。\n", "utf-8"),
  2366. writeFile(join(storyDir, "story_bible.md"), "# 世界观设定\n\n- 誓令碎片不可伪造。\n", "utf-8"),
  2367. writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
  2368. writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
  2369. writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
  2370. writeFile(join(storyDir, "chapter_summaries.md"), [
  2371. "# 章节摘要",
  2372. "",
  2373. "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
  2374. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  2375. "| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
  2376. "",
  2377. ].join("\n"), "utf-8"),
  2378. writeFile(
  2379. join(bookDir, "chapters", "0001_夜灯.md"),
  2380. "# 第1章 夜灯\n\n林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
  2381. "utf-8",
  2382. ),
  2383. state.saveChapterIndex(bookId, [{
  2384. number: 1,
  2385. title: "夜灯",
  2386. status: "approved" as ChapterMeta["status"],
  2387. wordCount: 55,
  2388. createdAt: now,
  2389. updatedAt: now,
  2390. auditIssues: [],
  2391. lengthWarnings: [],
  2392. }]),
  2393. ]);
  2394. const settleSpy = vi.spyOn(
  2395. WriterAgent.prototype as unknown as {
  2396. settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
  2397. },
  2398. "settleChapterState",
  2399. ).mockResolvedValue(
  2400. createWriterOutput({
  2401. chapterNumber: 1,
  2402. title: "夜灯",
  2403. content: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
  2404. wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
  2405. updatedState: "synced state",
  2406. updatedHooks: "synced hooks",
  2407. updatedLedger: "synced ledger",
  2408. }),
  2409. );
  2410. vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({
  2411. passed: true,
  2412. warnings: [],
  2413. });
  2414. const result = await (
  2415. runner as unknown as {
  2416. resyncChapterArtifacts: (bookId: string, chapterNumber?: number) => Promise<{
  2417. status: string;
  2418. chapterNumber: number;
  2419. }>;
  2420. }
  2421. ).resyncChapterArtifacts(bookId, 1);
  2422. const savedIndex = await state.loadChapterIndex(bookId);
  2423. expect(result.status).toBe("ready-for-review");
  2424. expect(result.chapterNumber).toBe(1);
  2425. expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
  2426. allowReapply: true,
  2427. chapterIntent: expect.stringContaining("把注意力收回师债主线"),
  2428. }));
  2429. await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("synced state");
  2430. await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("synced hooks");
  2431. expect(savedIndex[0]?.status).toBe("ready-for-review");
  2432. await rm(root, { recursive: true, force: true });
  2433. });
  2434. it("still persists the chapter when the state validator appends markdown after a valid JSON verdict", async () => {
  2435. vi.restoreAllMocks();
  2436. vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(
  2437. async ({ chapterContent, lengthSpec }) => ({
  2438. normalizedContent: chapterContent,
  2439. finalCount: countChapterLength(chapterContent, lengthSpec.countingMode),
  2440. applied: false,
  2441. mode: "none",
  2442. tokenUsage: ZERO_USAGE,
  2443. }),
  2444. );
  2445. const { root, runner, state, bookId } = await createRunnerFixture({
  2446. inputGovernanceMode: "legacy",
  2447. });
  2448. const chaptersDir = join(state.bookDir(bookId), "chapters");
  2449. const finalBody = "Validated chapter body that should still persist.";
  2450. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  2451. createWriterOutput({
  2452. content: finalBody,
  2453. wordCount: finalBody.length,
  2454. }),
  2455. );
  2456. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  2457. createAuditResult({
  2458. passed: true,
  2459. issues: [],
  2460. summary: "clean",
  2461. }),
  2462. );
  2463. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  2464. createAnalyzedOutput({
  2465. content: finalBody,
  2466. wordCount: finalBody.length,
  2467. }),
  2468. );
  2469. vi.spyOn(
  2470. StateValidatorAgent.prototype as unknown as {
  2471. chat: (...args: unknown[]) => Promise<{ content: string; usage: typeof ZERO_USAGE }>;
  2472. },
  2473. "chat",
  2474. ).mockResolvedValue({
  2475. content: [
  2476. "{\"warnings\":[],\"passed\":true}",
  2477. "",
  2478. "## Notes",
  2479. "Trailing markdown can include } braces and should not abort persistence.",
  2480. ].join("\n"),
  2481. usage: ZERO_USAGE,
  2482. });
  2483. const result = await runner.writeNextChapter(bookId);
  2484. expect(result.chapterNumber).toBe(1);
  2485. await expect(readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8"))
  2486. .resolves.toContain(finalBody);
  2487. await expect(state.loadChapterIndex(bookId)).resolves.toEqual([
  2488. expect.objectContaining({
  2489. number: 1,
  2490. title: "Test Chapter",
  2491. }),
  2492. ]);
  2493. await rm(root, { recursive: true, force: true });
  2494. });
  2495. it("preserves the revised chapter content when final truth rebuild omits CHAPTER_CONTENT", async () => {
  2496. const { root, runner, state, bookId } = await createRunnerFixture({
  2497. inputGovernanceMode: "legacy",
  2498. });
  2499. const chaptersDir = join(state.bookDir(bookId), "chapters");
  2500. const revisedBody = "Final revised body that should never be replaced by an empty chapter.";
  2501. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  2502. createWriterOutput({
  2503. content: "Original draft body.",
  2504. wordCount: "Original draft body.".length,
  2505. }),
  2506. );
  2507. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  2508. .mockResolvedValueOnce(
  2509. createAuditResult({
  2510. passed: false,
  2511. issues: [CRITICAL_ISSUE],
  2512. summary: "needs revision",
  2513. }),
  2514. )
  2515. .mockResolvedValueOnce(
  2516. createAuditResult({
  2517. passed: true,
  2518. issues: [],
  2519. summary: "clean",
  2520. }),
  2521. );
  2522. vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  2523. createReviseOutput({
  2524. revisedContent: revisedBody,
  2525. wordCount: revisedBody.length,
  2526. }),
  2527. );
  2528. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  2529. createAnalyzedOutput({
  2530. content: "",
  2531. wordCount: 0,
  2532. }),
  2533. );
  2534. const result = await runner.writeNextChapter(bookId);
  2535. const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
  2536. const savedIndex = await state.loadChapterIndex(bookId);
  2537. const expectedCount = countChapterLength(revisedBody, "zh_chars");
  2538. expect(result.wordCount).toBe(expectedCount);
  2539. expect(savedChapter).toContain(revisedBody);
  2540. expect(savedIndex[0]?.wordCount).toBe(expectedCount);
  2541. expect(savedIndex[0]?.status).toBe("ready-for-review");
  2542. await rm(root, { recursive: true, force: true });
  2543. });
  2544. it("reports only resumed chapters in import results", async () => {
  2545. const { root, runner, state, bookId } = await createRunnerFixture();
  2546. const now = "2026-03-19T00:00:00.000Z";
  2547. const existingIndex: ChapterMeta[] = [
  2548. {
  2549. number: 1,
  2550. title: "One",
  2551. status: "imported",
  2552. wordCount: 10,
  2553. createdAt: now,
  2554. updatedAt: now,
  2555. auditIssues: [],
  2556. lengthWarnings: [],
  2557. },
  2558. {
  2559. number: 2,
  2560. title: "Two",
  2561. status: "imported",
  2562. wordCount: 20,
  2563. createdAt: now,
  2564. updatedAt: now,
  2565. auditIssues: [],
  2566. lengthWarnings: [],
  2567. },
  2568. ];
  2569. await state.saveChapterIndex(bookId, existingIndex);
  2570. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockImplementation(async (input) =>
  2571. createAnalyzedOutput({
  2572. chapterNumber: input.chapterNumber,
  2573. title: input.chapterTitle ?? `Chapter ${input.chapterNumber}`,
  2574. content: input.chapterContent,
  2575. wordCount: input.chapterContent.length,
  2576. }),
  2577. );
  2578. vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
  2579. vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
  2580. const result = await runner.importChapters({
  2581. bookId,
  2582. resumeFrom: 3,
  2583. chapters: [
  2584. { title: "One", content: "1111111111" },
  2585. { title: "Two", content: "22222222222222222222" },
  2586. { title: "Three", content: "333333333333333" },
  2587. { title: "Four", content: "4444444444444444444444444" },
  2588. ],
  2589. });
  2590. expect(result.importedCount).toBe(2);
  2591. expect(result.totalWords).toBe("333333333333333".length + "4444444444444444444444444".length);
  2592. expect(result.nextChapter).toBe(5);
  2593. await rm(root, { recursive: true, force: true });
  2594. });
  2595. it("keeps fanfic initialization running when style guide extraction fails", async () => {
  2596. const { root, runner, state } = await createRunnerFixture();
  2597. const bookId = "fanfic-style-fallback";
  2598. const now = "2026-03-19T00:00:00.000Z";
  2599. const book: BookConfig = {
  2600. id: bookId,
  2601. title: "Fanfic Fallback",
  2602. platform: "tomato",
  2603. genre: "xuanhuan",
  2604. status: "active",
  2605. targetChapters: 10,
  2606. chapterWordCount: 3000,
  2607. createdAt: now,
  2608. updatedAt: now,
  2609. };
  2610. vi.spyOn(runner, "importFanficCanon").mockImplementation(async (targetBookId) => {
  2611. const storyDir = join(state.bookDir(targetBookId), "story");
  2612. await mkdir(storyDir, { recursive: true });
  2613. await writeFile(join(storyDir, "fanfic_canon.md"), "# Fanfic Canon\n", "utf-8");
  2614. return "# Fanfic Canon\n";
  2615. });
  2616. vi.spyOn(ArchitectAgent.prototype, "generateFanficFoundation").mockResolvedValue({
  2617. storyBible: "# Story Bible\n",
  2618. volumeOutline: "# Volume Outline\n",
  2619. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
  2620. currentState: createStateCard({
  2621. chapter: 0,
  2622. location: "Lantern quay",
  2623. protagonistState: "Lin Yue enters the fanfic timeline with a hidden debt.",
  2624. goal: "Find the canon fissure.",
  2625. conflict: "The old faction watches every move.",
  2626. }),
  2627. pendingHooks: "# Pending Hooks\n",
  2628. });
  2629. vi.spyOn(runner, "generateStyleGuide").mockRejectedValue(new Error("style failed"));
  2630. try {
  2631. await expect(runner.initFanficBook(book, "A".repeat(600), "canon.txt", "canon")).resolves.toBeUndefined();
  2632. expect(await state.loadChapterIndex(bookId)).toEqual([]);
  2633. await expect(readFile(join(state.bookDir(bookId), "story", "fanfic_canon.md"), "utf-8")).resolves.toContain("Fanfic Canon");
  2634. await expect(stat(join(state.bookDir(bookId), "story", "snapshots", "0"))).resolves.toBeTruthy();
  2635. } finally {
  2636. await rm(root, { recursive: true, force: true });
  2637. }
  2638. });
  2639. it("keeps canon import running when style guide extraction fails", async () => {
  2640. const { root, runner, state, bookId } = await createRunnerFixture();
  2641. const parentBookId = "parent-book";
  2642. const now = "2026-03-19T00:00:00.000Z";
  2643. const parentBook: BookConfig = {
  2644. id: parentBookId,
  2645. title: "Parent Book",
  2646. platform: "tomato",
  2647. genre: "xuanhuan",
  2648. status: "active",
  2649. targetChapters: 10,
  2650. chapterWordCount: 3000,
  2651. createdAt: now,
  2652. updatedAt: now,
  2653. };
  2654. const parentStoryDir = join(state.bookDir(parentBookId), "story");
  2655. const parentChaptersDir = join(state.bookDir(parentBookId), "chapters");
  2656. await state.saveBookConfig(parentBookId, parentBook);
  2657. await mkdir(parentStoryDir, { recursive: true });
  2658. await mkdir(parentChaptersDir, { recursive: true });
  2659. await Promise.all([
  2660. writeFile(join(parentStoryDir, "story_bible.md"), "# Story Bible\n", "utf-8"),
  2661. writeFile(join(parentStoryDir, "current_state.md"), createStateCard({
  2662. chapter: 3,
  2663. location: "North watchtower",
  2664. protagonistState: "The mentor debt is no longer secret.",
  2665. goal: "Protect the watchtower archive.",
  2666. conflict: "Guild spies are already inside the archive.",
  2667. }), "utf-8"),
  2668. writeFile(join(parentStoryDir, "particle_ledger.md"), "# Ledger\n", "utf-8"),
  2669. writeFile(join(parentStoryDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  2670. writeFile(join(parentStoryDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  2671. writeFile(join(parentChaptersDir, "0001_Parent.md"), `# Chapter 1\n\n${"Parent text. ".repeat(60)}`, "utf-8"),
  2672. ]);
  2673. vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
  2674. content: "# Parent Canon\n\nImported canon body.",
  2675. } as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
  2676. vi.spyOn(runner, "generateStyleGuide").mockRejectedValue(new Error("style failed"));
  2677. try {
  2678. const canon = await runner.importCanon(bookId, parentBookId);
  2679. expect(canon).toContain("# Parent Canon");
  2680. await expect(readFile(join(state.bookDir(bookId), "story", "parent_canon.md"), "utf-8")).resolves.toContain("Imported canon body.");
  2681. } finally {
  2682. await rm(root, { recursive: true, force: true });
  2683. }
  2684. });
  2685. it("keeps chapter import running when style guide extraction fails", async () => {
  2686. const { root, runner, state, bookId } = await createRunnerFixture();
  2687. const chapterContent = "章节正文。".repeat(120);
  2688. vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
  2689. storyBible: "# Story Bible\n",
  2690. volumeOutline: "# Volume Outline\n",
  2691. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
  2692. currentState: createStateCard({
  2693. chapter: 0,
  2694. location: "Ashen ferry crossing",
  2695. protagonistState: "Lin Yue still hides the oath token.",
  2696. goal: "Find the vanished mentor.",
  2697. conflict: "The mentor debt is still personal.",
  2698. }),
  2699. pendingHooks: "# Pending Hooks\n",
  2700. });
  2701. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  2702. createAnalyzedOutput({
  2703. chapterNumber: 1,
  2704. title: "Prelude",
  2705. content: chapterContent,
  2706. wordCount: chapterContent.length,
  2707. }),
  2708. );
  2709. vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
  2710. vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
  2711. vi.spyOn(runner, "generateStyleGuide").mockRejectedValue(new Error("style failed"));
  2712. try {
  2713. const result = await runner.importChapters({
  2714. bookId,
  2715. chapters: [
  2716. { title: "Prelude", content: chapterContent },
  2717. ],
  2718. });
  2719. expect(result.importedCount).toBe(1);
  2720. expect((await state.loadChapterIndex(bookId))[0]?.status).toBe("imported");
  2721. await expect(readFile(join(state.bookDir(bookId), "story", "story_bible.md"), "utf-8")).resolves.toContain("# Story Bible");
  2722. } finally {
  2723. await rm(root, { recursive: true, force: true });
  2724. }
  2725. });
  2726. sqliteIt("rebuilds fact history from imported chapter snapshots", async () => {
  2727. const { root, runner, state, bookId } = await createRunnerFixture();
  2728. vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
  2729. storyBible: "# Story Bible\n",
  2730. volumeOutline: "# Volume Outline\n",
  2731. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
  2732. currentState: createStateCard({
  2733. chapter: 0,
  2734. location: "Shrine outskirts",
  2735. protagonistState: "Lin Yue begins with the oath token hidden.",
  2736. goal: "Reach the trial city.",
  2737. conflict: "The trial deadline is closing in.",
  2738. }),
  2739. pendingHooks: "# Pending Hooks\n",
  2740. });
  2741. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter")
  2742. .mockResolvedValueOnce(createAnalyzedOutput({
  2743. chapterNumber: 1,
  2744. title: "One",
  2745. content: "One body.",
  2746. wordCount: "One body.".length,
  2747. updatedState: createStateCard({
  2748. chapter: 1,
  2749. location: "Ashen ferry crossing",
  2750. protagonistState: "Lin Yue still hides the oath token.",
  2751. goal: "Find the vanished mentor.",
  2752. conflict: "The mentor debt is still personal.",
  2753. }),
  2754. updatedHooks: "# Pending Hooks\n",
  2755. }))
  2756. .mockResolvedValueOnce(createAnalyzedOutput({
  2757. chapterNumber: 2,
  2758. title: "Two",
  2759. content: "Two body.",
  2760. wordCount: "Two body.".length,
  2761. updatedState: createStateCard({
  2762. chapter: 2,
  2763. location: "North watchtower",
  2764. protagonistState: "Lin Yue finally shows the oath token.",
  2765. goal: "Reach the watchtower before the guild.",
  2766. conflict: "The merchant guild now contests the mentor trail.",
  2767. }),
  2768. updatedHooks: "# Pending Hooks\n",
  2769. }));
  2770. try {
  2771. await runner.importChapters({
  2772. bookId,
  2773. chapters: [
  2774. { title: "One", content: "One body." },
  2775. { title: "Two", content: "Two body." },
  2776. ],
  2777. });
  2778. const memoryDb = new MemoryDB(state.bookDir(bookId));
  2779. try {
  2780. const chapterOneFacts = memoryDb.getFactsAt("protagonist", 1);
  2781. const currentFacts = memoryDb.getCurrentFacts();
  2782. expect(chapterOneFacts).toEqual(
  2783. expect.arrayContaining([
  2784. expect.objectContaining({
  2785. predicate: "Current Conflict",
  2786. object: "The mentor debt is still personal.",
  2787. }),
  2788. ]),
  2789. );
  2790. expect(currentFacts).toEqual(
  2791. expect.arrayContaining([
  2792. expect.objectContaining({
  2793. predicate: "Current Conflict",
  2794. object: "The merchant guild now contests the mentor trail.",
  2795. validFromChapter: 2,
  2796. sourceChapter: 2,
  2797. }),
  2798. ]),
  2799. );
  2800. } finally {
  2801. memoryDb.close();
  2802. }
  2803. } finally {
  2804. await rm(root, { recursive: true, force: true });
  2805. }
  2806. });
  2807. sqliteIt("rebuilds fact history from structured snapshot state instead of stale markdown snapshots", async () => {
  2808. const { root, runner, state, bookId } = await createRunnerFixture();
  2809. const storyDir = join(state.bookDir(bookId), "story");
  2810. const snapshotOneDir = join(storyDir, "snapshots", "1");
  2811. const snapshotOneStateDir = join(snapshotOneDir, "state");
  2812. await mkdir(snapshotOneStateDir, { recursive: true });
  2813. await Promise.all([
  2814. writeFile(
  2815. join(snapshotOneDir, "current_state.md"),
  2816. createStateCard({
  2817. chapter: 1,
  2818. location: "Old markdown ferry crossing",
  2819. protagonistState: "Markdown state still hides the oath token.",
  2820. goal: "Follow the markdown trail.",
  2821. conflict: "Old markdown conflict.",
  2822. }),
  2823. "utf-8",
  2824. ),
  2825. writeFile(join(snapshotOneStateDir, "current_state.json"), JSON.stringify({
  2826. chapter: 1,
  2827. facts: [
  2828. {
  2829. subject: "current",
  2830. predicate: "Current Location",
  2831. object: "Structured watchtower",
  2832. validFromChapter: 1,
  2833. validUntilChapter: null,
  2834. sourceChapter: 1,
  2835. },
  2836. {
  2837. subject: "protagonist",
  2838. predicate: "Current Conflict",
  2839. object: "Structured conflict replaces markdown drift.",
  2840. validFromChapter: 1,
  2841. validUntilChapter: null,
  2842. sourceChapter: 1,
  2843. },
  2844. ],
  2845. }, null, 2), "utf-8"),
  2846. ]);
  2847. try {
  2848. await (runner as unknown as {
  2849. syncCurrentStateFactHistory: (targetBookId: string, uptoChapter: number) => Promise<void>;
  2850. }).syncCurrentStateFactHistory(bookId, 1);
  2851. const memoryDb = new MemoryDB(state.bookDir(bookId));
  2852. try {
  2853. expect(memoryDb.getCurrentFacts()).toEqual(
  2854. expect.arrayContaining([
  2855. expect.objectContaining({
  2856. predicate: "Current Location",
  2857. object: "Structured watchtower",
  2858. validFromChapter: 1,
  2859. }),
  2860. expect.objectContaining({
  2861. predicate: "Current Conflict",
  2862. object: "Structured conflict replaces markdown drift.",
  2863. validFromChapter: 1,
  2864. }),
  2865. ]),
  2866. );
  2867. expect(memoryDb.getCurrentFacts()).not.toEqual(
  2868. expect.arrayContaining([
  2869. expect.objectContaining({
  2870. object: "Old markdown ferry crossing",
  2871. }),
  2872. expect.objectContaining({
  2873. object: "Old markdown conflict.",
  2874. }),
  2875. ]),
  2876. );
  2877. } finally {
  2878. memoryDb.close();
  2879. }
  2880. } finally {
  2881. await rm(root, { recursive: true, force: true });
  2882. }
  2883. });
  2884. it("tracks imported English chapters using word counts instead of characters", async () => {
  2885. const { root, runner, state, bookId } = await createRunnerFixture();
  2886. const englishBook = {
  2887. ...(await state.loadBookConfig(bookId)),
  2888. genre: "other",
  2889. language: "en" as const,
  2890. };
  2891. const now = "2026-03-19T00:00:00.000Z";
  2892. await state.saveBookConfig(bookId, englishBook);
  2893. await state.saveChapterIndex(bookId, [
  2894. {
  2895. number: 1,
  2896. title: "Prelude",
  2897. status: "imported",
  2898. wordCount: 3,
  2899. createdAt: now,
  2900. updatedAt: now,
  2901. auditIssues: [],
  2902. lengthWarnings: [],
  2903. },
  2904. {
  2905. number: 2,
  2906. title: "Crossroads",
  2907. status: "imported",
  2908. wordCount: 2,
  2909. createdAt: now,
  2910. updatedAt: now,
  2911. auditIssues: [],
  2912. lengthWarnings: [],
  2913. },
  2914. ]);
  2915. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockImplementation(async (input) =>
  2916. createAnalyzedOutput({
  2917. chapterNumber: input.chapterNumber,
  2918. title: input.chapterTitle ?? `Chapter ${input.chapterNumber}`,
  2919. content: input.chapterContent,
  2920. wordCount: countChapterLength(input.chapterContent, "en_words"),
  2921. }),
  2922. );
  2923. vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
  2924. vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
  2925. const result = await runner.importChapters({
  2926. bookId,
  2927. resumeFrom: 3,
  2928. chapters: [
  2929. { title: "Prelude", content: "One two three" },
  2930. { title: "Crossroads", content: "Four five" },
  2931. { title: "The Watchtower", content: "The storm kept rolling west" },
  2932. { title: "Aftermath", content: "Lanterns dimmed before dawn broke" },
  2933. ],
  2934. });
  2935. const chapterIndex = await state.loadChapterIndex(bookId);
  2936. const chapterThree = chapterIndex.find((entry) => entry.number === 3);
  2937. const chapterFour = chapterIndex.find((entry) => entry.number === 4);
  2938. expect(result.importedCount).toBe(2);
  2939. expect(result.totalWords).toBe(10);
  2940. expect(chapterThree?.wordCount).toBe(5);
  2941. expect(chapterFour?.wordCount).toBe(5);
  2942. await rm(root, { recursive: true, force: true });
  2943. });
  2944. it("imports English chapters with English foundation seeds and persistence files", async () => {
  2945. const { root, runner, state, bookId } = await createRunnerFixture();
  2946. const englishBook = {
  2947. ...(await state.loadBookConfig(bookId)),
  2948. genre: "other",
  2949. language: "en" as const,
  2950. chapterWordCount: 2200,
  2951. };
  2952. await state.saveBookConfig(bookId, englishBook);
  2953. const foundation = vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
  2954. storyBible: "# Story Bible\n",
  2955. volumeOutline: "# Volume Outline\n",
  2956. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
  2957. currentState: createStateCard({
  2958. chapter: 0,
  2959. location: "Harbor gate",
  2960. protagonistState: "Mara arrives with a sealed letter.",
  2961. goal: "Find the missing captain before sunrise.",
  2962. conflict: "The harbor watch is searching every ship.",
  2963. }),
  2964. pendingHooks: "# Pending Hooks\n\n| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |\n| --- | --- | --- | --- | --- | --- | --- |\n",
  2965. });
  2966. const saveChapter = vi.spyOn(WriterAgent.prototype, "saveChapter");
  2967. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  2968. createAnalyzedOutput({
  2969. chapterNumber: 1,
  2970. title: "Prelude",
  2971. content: "A cold wind crossed the harbor.",
  2972. wordCount: countChapterLength("A cold wind crossed the harbor.", "en_words"),
  2973. updatedState: createStateCard({
  2974. chapter: 1,
  2975. location: "Harbor gate",
  2976. protagonistState: "Mara hides the sealed letter under her coat.",
  2977. goal: "Slip past the harbor watch.",
  2978. conflict: "The watch now searches for the missing captain's courier.",
  2979. }),
  2980. 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",
  2981. 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 |",
  2982. updatedSubplots: [
  2983. "# Subplot Board",
  2984. "",
  2985. "| Subplot | Status | Note |",
  2986. "| --- | --- | --- |",
  2987. "| Harbor search | Active | Mara begins the search for the missing captain. |",
  2988. "",
  2989. ].join("\n"),
  2990. updatedEmotionalArcs: "",
  2991. updatedCharacterMatrix: "",
  2992. }),
  2993. );
  2994. try {
  2995. await runner.importChapters({
  2996. bookId,
  2997. chapters: [
  2998. { title: "Prelude", content: "A cold wind crossed the harbor." },
  2999. ],
  3000. });
  3001. const storyDir = join(state.bookDir(bookId), "story");
  3002. const chapterPath = join(state.bookDir(bookId), "chapters", "0001_Prelude.md");
  3003. const chapterFile = await readFile(chapterPath, "utf-8");
  3004. const chapterSummaries = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8");
  3005. const subplotBoard = await readFile(join(storyDir, "subplot_board.md"), "utf-8");
  3006. expect(foundation.mock.calls[0]?.[1]).toContain("Chapter 1: Prelude");
  3007. expect(foundation.mock.calls[0]?.[1]).not.toContain("第1章");
  3008. expect(saveChapter.mock.calls[0]?.[3]).toBe("en");
  3009. expect(chapterFile).toContain("# Chapter 1: Prelude");
  3010. expect(chapterSummaries).toContain("# Chapter Summaries");
  3011. expect(chapterSummaries).not.toContain("# 章节摘要");
  3012. expect(subplotBoard).toContain("# Subplot Board");
  3013. expect(subplotBoard).not.toContain("# 支线进度板");
  3014. } finally {
  3015. await rm(root, { recursive: true, force: true });
  3016. }
  3017. });
  3018. it("logs localized replay progress during chapter import", async () => {
  3019. const { logger, infos } = createCaptureLogger();
  3020. const { root, runner, bookId } = await createRunnerFixture({ logger });
  3021. vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
  3022. storyBible: "# Story Bible\n",
  3023. volumeOutline: "# Volume Outline\n",
  3024. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
  3025. currentState: createStateCard({
  3026. chapter: 0,
  3027. location: "Ashen ferry crossing",
  3028. protagonistState: "Lin Yue still hides the oath token.",
  3029. goal: "Find the vanished mentor.",
  3030. conflict: "The mentor debt is still personal.",
  3031. }),
  3032. pendingHooks: "# Pending Hooks\n",
  3033. });
  3034. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  3035. createAnalyzedOutput({
  3036. chapterNumber: 1,
  3037. title: "Prelude",
  3038. content: "章节正文。",
  3039. wordCount: "章节正文。".length,
  3040. }),
  3041. );
  3042. vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
  3043. vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
  3044. try {
  3045. await runner.importChapters({
  3046. bookId,
  3047. chapters: [
  3048. { title: "第一章", content: "章节正文。" },
  3049. ],
  3050. });
  3051. expect(infos).toEqual(expect.arrayContaining([
  3052. "步骤 1:从 1 章生成基础设定...",
  3053. "基础设定已生成。",
  3054. "步骤 2:从第 1 章开始顺序回放...",
  3055. "分析章节 1/1:第一章...",
  3056. "完成。已导入 1 章,共 5字。下一章:2",
  3057. ]));
  3058. } finally {
  3059. await rm(root, { recursive: true, force: true });
  3060. }
  3061. });
  3062. it("passes governed control inputs into import replay analyzer in v2 mode", async () => {
  3063. const { root, runner, bookId } = await createRunnerFixture();
  3064. vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
  3065. storyBible: "# Story Bible\n\n- Keep the harbor search grounded in the missing captain thread.\n",
  3066. volumeOutline: "# Volume Outline\n\n## Volume 1\n- Chapter 1: Mara arrives at the harbor with the sealed letter.\n",
  3067. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n\n- Stay close to Mara's viewpoint.\n",
  3068. currentState: createStateCard({
  3069. chapter: 0,
  3070. location: "Harbor gate",
  3071. protagonistState: "Mara arrives carrying a sealed letter.",
  3072. goal: "Enter the harbor unnoticed.",
  3073. conflict: "The harbor watch is hunting the captain's courier.",
  3074. }),
  3075. pendingHooks: [
  3076. "# Pending Hooks",
  3077. "",
  3078. "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
  3079. "| --- | --- | --- | --- | --- | --- | --- |",
  3080. "| captain-letter | 1 | mystery | open | 0 | The captain's disappearance is explained. | The sealed letter points to the missing captain. |",
  3081. "",
  3082. ].join("\n"),
  3083. });
  3084. const analyzeChapter = vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  3085. createAnalyzedOutput({
  3086. chapterNumber: 1,
  3087. title: "Prelude",
  3088. content: "A cold wind crossed the harbor.",
  3089. wordCount: countChapterLength("A cold wind crossed the harbor.", "en_words"),
  3090. }),
  3091. );
  3092. vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
  3093. vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
  3094. try {
  3095. await runner.importChapters({
  3096. bookId,
  3097. chapters: [
  3098. { title: "Prelude", content: "A cold wind crossed the harbor." },
  3099. ],
  3100. });
  3101. expect(analyzeChapter.mock.calls[0]?.[0]).toMatchObject({
  3102. chapterIntent: expect.stringContaining("# Chapter Intent"),
  3103. contextPackage: expect.objectContaining({
  3104. selectedContext: expect.any(Array),
  3105. }),
  3106. ruleStack: expect.objectContaining({
  3107. activeOverrides: expect.any(Array),
  3108. }),
  3109. });
  3110. } finally {
  3111. await rm(root, { recursive: true, force: true });
  3112. }
  3113. });
  3114. it("does not leak imported future state into early replay chapters", async () => {
  3115. const { root, runner, state, bookId } = await createRunnerFixture();
  3116. const storyDir = join(state.bookDir(bookId), "story");
  3117. const englishBook = {
  3118. ...(await state.loadBookConfig(bookId)),
  3119. genre: "other",
  3120. language: "en" as const,
  3121. chapterWordCount: 2200,
  3122. };
  3123. await state.saveBookConfig(bookId, englishBook);
  3124. await mkdir(join(storyDir, "snapshots", "0"), { recursive: true });
  3125. await Promise.all([
  3126. writeFile(join(storyDir, "subplot_board.md"), "# Subplot Board\n\nFUTURE LEAK subplot\n", "utf-8"),
  3127. writeFile(join(storyDir, "emotional_arcs.md"), "# Emotional Arcs\n\nFUTURE LEAK emotion\n", "utf-8"),
  3128. writeFile(join(storyDir, "character_matrix.md"), "# Character Matrix\n\nFUTURE LEAK matrix\n", "utf-8"),
  3129. writeFile(
  3130. join(storyDir, "chapter_summaries.md"),
  3131. [
  3132. "# Chapter Summaries",
  3133. "",
  3134. "| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |",
  3135. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  3136. "| 99 | Future | Future Cast | FUTURE LEAK event | FUTURE LEAK state | FUTURE LEAK hook | grim | finale |",
  3137. "",
  3138. ].join("\n"),
  3139. "utf-8",
  3140. ),
  3141. writeFile(
  3142. join(storyDir, "snapshots", "0", "current_state.md"),
  3143. createStateCard({
  3144. chapter: 60,
  3145. location: "Chengdu court",
  3146. protagonistState: "FUTURE LEAK snapshot",
  3147. goal: "Secure the western kingdom.",
  3148. conflict: "Late-book imperial rivalry is now fully active.",
  3149. }),
  3150. "utf-8",
  3151. ),
  3152. writeFile(
  3153. join(storyDir, "snapshots", "0", "pending_hooks.md"),
  3154. [
  3155. "# Pending Hooks",
  3156. "",
  3157. "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
  3158. "| --- | --- | --- | --- | --- | --- | --- |",
  3159. "| future-hook | 60 | mystery | open | 60 | Future payoff | FUTURE LEAK |",
  3160. "",
  3161. ].join("\n"),
  3162. "utf-8",
  3163. ),
  3164. ]);
  3165. vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
  3166. storyBible: "# Story Bible\n",
  3167. volumeOutline: "# Volume Outline\n",
  3168. bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
  3169. currentState: createStateCard({
  3170. chapter: 60,
  3171. location: "Chengdu court",
  3172. protagonistState: "FUTURE LEAK: Liu Bei already holds Yizhou.",
  3173. goal: "Secure the western kingdom.",
  3174. conflict: "Late-book imperial rivalry is now fully active.",
  3175. }),
  3176. pendingHooks: [
  3177. "# Pending Hooks",
  3178. "",
  3179. "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
  3180. "| --- | --- | --- | --- | --- | --- | --- |",
  3181. "| future-hook | 60 | mystery | open | 60 | Future payoff | FUTURE LEAK |",
  3182. "",
  3183. ].join("\n"),
  3184. });
  3185. let stateSeenByFirstReplay = "";
  3186. let hooksSeenByFirstReplay = "";
  3187. let subplotSeenByFirstReplay = "";
  3188. let emotionalSeenByFirstReplay = "";
  3189. let matrixSeenByFirstReplay = "";
  3190. let summariesSeenByFirstReplay = "";
  3191. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockImplementationOnce(async (input) => {
  3192. stateSeenByFirstReplay = await readFile(join(input.bookDir, "story", "current_state.md"), "utf-8");
  3193. hooksSeenByFirstReplay = await readFile(join(input.bookDir, "story", "pending_hooks.md"), "utf-8");
  3194. subplotSeenByFirstReplay = await readFile(join(input.bookDir, "story", "subplot_board.md"), "utf-8").catch(() => "");
  3195. emotionalSeenByFirstReplay = await readFile(join(input.bookDir, "story", "emotional_arcs.md"), "utf-8").catch(() => "");
  3196. matrixSeenByFirstReplay = await readFile(join(input.bookDir, "story", "character_matrix.md"), "utf-8").catch(() => "");
  3197. summariesSeenByFirstReplay = await readFile(join(input.bookDir, "story", "chapter_summaries.md"), "utf-8").catch(() => "");
  3198. return createAnalyzedOutput({
  3199. chapterNumber: 1,
  3200. title: "Prelude",
  3201. content: "A cold wind crossed the harbor.",
  3202. wordCount: countChapterLength("A cold wind crossed the harbor.", "en_words"),
  3203. updatedState: createStateCard({
  3204. chapter: 1,
  3205. location: "Harbor gate",
  3206. protagonistState: "Mara hides the sealed letter under her coat.",
  3207. goal: "Slip past the harbor watch.",
  3208. conflict: "The watch now searches for the missing captain's courier.",
  3209. }),
  3210. updatedHooks: [
  3211. "# Pending Hooks",
  3212. "",
  3213. "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
  3214. "| --- | --- | --- | --- | --- | --- | --- |",
  3215. "| captain-letter | 1 | mystery | open | 1 | The captain's disappearance is explained. | The sealed letter points to the vanished captain. |",
  3216. "",
  3217. ].join("\n"),
  3218. });
  3219. });
  3220. try {
  3221. await runner.importChapters({
  3222. bookId,
  3223. chapters: [
  3224. { title: "Prelude", content: "A cold wind crossed the harbor." },
  3225. ],
  3226. });
  3227. expect(stateSeenByFirstReplay).toContain("| Current Chapter | 0 |");
  3228. expect(stateSeenByFirstReplay).not.toContain("FUTURE LEAK");
  3229. expect(hooksSeenByFirstReplay).toContain("# Pending Hooks");
  3230. expect(hooksSeenByFirstReplay).not.toContain("future-hook");
  3231. expect(hooksSeenByFirstReplay).not.toContain("FUTURE LEAK");
  3232. expect(subplotSeenByFirstReplay).not.toContain("FUTURE LEAK");
  3233. expect(emotionalSeenByFirstReplay).not.toContain("FUTURE LEAK");
  3234. expect(matrixSeenByFirstReplay).not.toContain("FUTURE LEAK");
  3235. expect(summariesSeenByFirstReplay).not.toContain("FUTURE LEAK");
  3236. const snapshotZeroState = await readFile(join(storyDir, "snapshots", "0", "current_state.md"), "utf-8");
  3237. const snapshotZeroHooks = await readFile(join(storyDir, "snapshots", "0", "pending_hooks.md"), "utf-8");
  3238. expect(snapshotZeroState).toContain("| Current Chapter | 0 |");
  3239. expect(snapshotZeroState).not.toContain("FUTURE LEAK");
  3240. expect(snapshotZeroHooks).toContain("# Pending Hooks");
  3241. expect(snapshotZeroHooks).not.toContain("future-hook");
  3242. expect(snapshotZeroHooks).not.toContain("FUTURE LEAK");
  3243. } finally {
  3244. await rm(root, { recursive: true, force: true });
  3245. }
  3246. });
  3247. sqliteIt("rebuilds current facts from the revised chapter snapshot", async () => {
  3248. const { root, runner, state, bookId } = await createRunnerFixture();
  3249. const storyDir = join(state.bookDir(bookId), "story");
  3250. const chaptersDir = join(state.bookDir(bookId), "chapters");
  3251. const oldState = createStateCard({
  3252. chapter: 1,
  3253. location: "Ashen ferry crossing",
  3254. protagonistState: "Lin Yue still hides the oath token.",
  3255. goal: "Find the vanished mentor.",
  3256. conflict: "The mentor debt is still personal.",
  3257. });
  3258. const revisedState = createStateCard({
  3259. chapter: 1,
  3260. location: "Ashen ferry crossing",
  3261. protagonistState: "Lin Yue no longer hides the oath token.",
  3262. goal: "Confront the vanished mentor.",
  3263. conflict: "The oath token is public now, forcing the confrontation.",
  3264. });
  3265. await Promise.all([
  3266. writeFile(join(chaptersDir, "0001_Test_Chapter.md"), "# 第1章 Test Chapter\n\nOriginal body.", "utf-8"),
  3267. writeFile(join(storyDir, "current_state.md"), oldState, "utf-8"),
  3268. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  3269. ]);
  3270. await state.saveChapterIndex(bookId, [{
  3271. number: 1,
  3272. title: "Test Chapter",
  3273. status: "audit-failed",
  3274. wordCount: "Original body.".length,
  3275. createdAt: "2026-03-19T00:00:00.000Z",
  3276. updatedAt: "2026-03-19T00:00:00.000Z",
  3277. auditIssues: [],
  3278. lengthWarnings: [],
  3279. }]);
  3280. await state.snapshotState(bookId, 1);
  3281. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  3282. .mockResolvedValueOnce(
  3283. createAuditResult({
  3284. passed: false,
  3285. issues: [CRITICAL_ISSUE],
  3286. summary: "needs revision",
  3287. }),
  3288. )
  3289. .mockResolvedValueOnce(
  3290. createAuditResult({
  3291. passed: true,
  3292. issues: [],
  3293. summary: "clean",
  3294. }),
  3295. );
  3296. vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  3297. createReviseOutput({
  3298. revisedContent: "Revised body.",
  3299. wordCount: "Revised body.".length,
  3300. updatedState: revisedState,
  3301. updatedHooks: "# Pending Hooks\n",
  3302. }),
  3303. );
  3304. try {
  3305. await runner.reviseDraft(bookId, 1);
  3306. const memoryDb = new MemoryDB(state.bookDir(bookId));
  3307. try {
  3308. expect(memoryDb.getCurrentFacts()).toEqual(
  3309. expect.arrayContaining([
  3310. expect.objectContaining({
  3311. predicate: "Current Conflict",
  3312. object: "The oath token is public now, forcing the confrontation.",
  3313. validFromChapter: 1,
  3314. sourceChapter: 1,
  3315. }),
  3316. ]),
  3317. );
  3318. } finally {
  3319. memoryDb.close();
  3320. }
  3321. } finally {
  3322. await rm(root, { recursive: true, force: true });
  3323. }
  3324. });
  3325. it("feeds long-span fatigue warnings back into pipeline audit and dedicated drift guidance", async () => {
  3326. const { root, runner, state, bookId } = await createRunnerFixture();
  3327. const storyDir = join(state.bookDir(bookId), "story");
  3328. const now = "2026-03-19T00:00:00.000Z";
  3329. await Promise.all([
  3330. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3331. chapter: 2,
  3332. location: "Ashen ferry crossing",
  3333. protagonistState: "Lin Yue still hides the oath token.",
  3334. goal: "Find the vanished mentor.",
  3335. conflict: "The debt trail keeps narrowing.",
  3336. }), "utf-8"),
  3337. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  3338. writeFile(
  3339. join(storyDir, "chapter_summaries.md"),
  3340. [
  3341. "# 章节摘要",
  3342. "",
  3343. "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
  3344. "|------|------|----------|----------|----------|----------|----------|----------|",
  3345. "| 1 | 旧路 | 林越 | 进城 | 潜伏开始 | 债印未解 | 克制 | 布局 |",
  3346. "| 2 | 暗巷 | 林越 | 试探 | 目标未变 | 债印未解 | 克制 | 布局 |",
  3347. ].join("\n"),
  3348. "utf-8",
  3349. ),
  3350. writeFile(join(state.bookDir(bookId), "chapters", "0001_旧路.md"), "# 第1章 旧路\n\n城门在晨雾里半开。林越顺着石阶慢慢往里走。巷口那盏灯一直没有灭。", "utf-8"),
  3351. writeFile(join(state.bookDir(bookId), "chapters", "0002_暗巷.md"), "# 第2章 暗巷\n\n午后的风掠过墙头。林越没有回头,只是沿着阴影继续向前。墙后的铃声很轻。", "utf-8"),
  3352. ]);
  3353. await state.saveChapterIndex(bookId, [
  3354. {
  3355. number: 1,
  3356. title: "旧路",
  3357. status: "ready-for-review",
  3358. wordCount: 36,
  3359. createdAt: now,
  3360. updatedAt: now,
  3361. auditIssues: [],
  3362. lengthWarnings: [],
  3363. },
  3364. {
  3365. number: 2,
  3366. title: "暗巷",
  3367. status: "ready-for-review",
  3368. wordCount: 36,
  3369. createdAt: now,
  3370. updatedAt: now,
  3371. auditIssues: [],
  3372. lengthWarnings: [],
  3373. },
  3374. ]);
  3375. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  3376. createWriterOutput({
  3377. chapterNumber: 3,
  3378. title: "回声",
  3379. content: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。风从更深的巷子里吹了出来。",
  3380. wordCount: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。风从更深的巷子里吹了出来。".length,
  3381. updatedState: createStateCard({
  3382. chapter: 3,
  3383. location: "Ashen ferry crossing",
  3384. protagonistState: "Lin Yue still hides the oath token.",
  3385. goal: "Find the vanished mentor.",
  3386. conflict: "The debt trail keeps narrowing.",
  3387. }),
  3388. updatedLedger: "",
  3389. updatedHooks: "# Pending Hooks\n",
  3390. chapterSummary: "| 3 | 回声 | 林越 | 继续潜伏 | 目标未变 | 债印未解 | 克制 | 布局 |",
  3391. }),
  3392. );
  3393. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  3394. createAuditResult({
  3395. passed: true,
  3396. issues: [],
  3397. summary: "ok",
  3398. }),
  3399. );
  3400. try {
  3401. const result = await runner.writeNextChapter(bookId);
  3402. const driftFile = await readFile(join(storyDir, "audit_drift.md"), "utf-8");
  3403. const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
  3404. expect(result.auditResult.issues.some((issue) => issue.category === "节奏单调")).toBe(true);
  3405. expect(driftFile).toContain("节奏单调");
  3406. expect(currentState).not.toContain("节奏单调");
  3407. } finally {
  3408. await rm(root, { recursive: true, force: true });
  3409. }
  3410. });
  3411. it("feeds hook health warnings back into pipeline audit and dedicated drift guidance", async () => {
  3412. const { root, runner, state, bookId } = await createRunnerFixture();
  3413. const storyDir = join(state.bookDir(bookId), "story");
  3414. await Promise.all([
  3415. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3416. chapter: 2,
  3417. location: "Ashen ferry crossing",
  3418. protagonistState: "Lin Yue still hides the oath token.",
  3419. goal: "Find the vanished mentor.",
  3420. conflict: "The debt trail keeps narrowing.",
  3421. }), "utf-8"),
  3422. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  3423. writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
  3424. ]);
  3425. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  3426. createWriterOutput({
  3427. chapterNumber: 3,
  3428. title: "回声",
  3429. content: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。",
  3430. wordCount: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。".length,
  3431. updatedState: createStateCard({
  3432. chapter: 3,
  3433. location: "Ashen ferry crossing",
  3434. protagonistState: "Lin Yue still hides the oath token.",
  3435. goal: "Find the vanished mentor.",
  3436. conflict: "The debt trail keeps narrowing.",
  3437. }),
  3438. updatedLedger: "",
  3439. updatedHooks: "# Pending Hooks\n",
  3440. chapterSummary: "| 3 | 回声 | 林越 | 继续潜伏 | 目标未变 | 债印未解 | 克制 | 布局 |",
  3441. hookHealthIssues: [{
  3442. severity: "warning",
  3443. category: "伏笔债务",
  3444. description: "活跃伏笔过多,且本章没有处理陈旧债务。",
  3445. suggestion: "下一章优先推进或延后至少一个僵死伏笔。",
  3446. }],
  3447. }),
  3448. );
  3449. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  3450. createAuditResult({
  3451. passed: true,
  3452. issues: [],
  3453. summary: "ok",
  3454. }),
  3455. );
  3456. try {
  3457. const result = await runner.writeNextChapter(bookId);
  3458. const driftFile = await readFile(join(storyDir, "audit_drift.md"), "utf-8");
  3459. const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
  3460. const savedIndex = await state.loadChapterIndex(bookId);
  3461. expect(result.auditResult.issues.some((issue) => issue.category === "伏笔债务")).toBe(true);
  3462. expect(driftFile).toContain("伏笔债务");
  3463. expect(currentState).not.toContain("伏笔债务");
  3464. expect(savedIndex[0]?.auditIssues).toEqual(
  3465. expect.arrayContaining([
  3466. expect.stringContaining("活跃伏笔过多"),
  3467. ]),
  3468. );
  3469. } finally {
  3470. await rm(root, { recursive: true, force: true });
  3471. }
  3472. });
  3473. it("adds final paragraph fragmentation warnings from revised content before persist", async () => {
  3474. const { root, runner, state, bookId } = await createRunnerFixture();
  3475. const storyDir = join(state.bookDir(bookId), "story");
  3476. const draftBody = "林越先把门推开一条缝,再侧耳去听墙后的动静。屋里的灯没有亮,但桌角还有没散的热气,说明人刚离开不久。";
  3477. const revisedBody = [
  3478. "门开了。",
  3479. "他没进去。",
  3480. "先听了一下。",
  3481. "里面没有声响。",
  3482. "他这才抬脚。",
  3483. "屋里很冷。",
  3484. ].join("\n\n");
  3485. await Promise.all([
  3486. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3487. chapter: 0,
  3488. location: "Ashen ferry crossing",
  3489. protagonistState: "Lin Yue still hides the oath token.",
  3490. goal: "Find the vanished mentor.",
  3491. conflict: "The debt trail keeps narrowing.",
  3492. }), "utf-8"),
  3493. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  3494. ]);
  3495. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  3496. createWriterOutput({
  3497. chapterNumber: 1,
  3498. title: "雾线",
  3499. content: draftBody,
  3500. wordCount: draftBody.length,
  3501. }),
  3502. );
  3503. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  3504. .mockResolvedValueOnce(
  3505. createAuditResult({
  3506. passed: false,
  3507. issues: [CRITICAL_ISSUE],
  3508. summary: "needs revision",
  3509. }),
  3510. )
  3511. .mockResolvedValueOnce(
  3512. createAuditResult({
  3513. passed: true,
  3514. issues: [],
  3515. summary: "clean",
  3516. }),
  3517. );
  3518. vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  3519. createReviseOutput({
  3520. revisedContent: revisedBody,
  3521. wordCount: revisedBody.length,
  3522. updatedState: createStateCard({
  3523. chapter: 1,
  3524. location: "Ashen ferry crossing",
  3525. protagonistState: "Lin Yue still hides the oath token.",
  3526. goal: "Find the vanished mentor.",
  3527. conflict: "He steps into the empty room.",
  3528. }),
  3529. updatedHooks: "# Pending Hooks\n",
  3530. }),
  3531. );
  3532. vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
  3533. createAnalyzedOutput({
  3534. title: "雾线",
  3535. content: revisedBody,
  3536. wordCount: revisedBody.length,
  3537. chapterSummary: "| 1 | 雾线 | 林越 | 进入空屋 | 状态推进 | 无 | 紧绷 | 过渡 |",
  3538. }),
  3539. );
  3540. try {
  3541. const result = await runner.writeNextChapter(bookId, 120);
  3542. expect(result.auditResult.issues).toEqual(
  3543. expect.arrayContaining([
  3544. expect.objectContaining({
  3545. category: "paragraph-shape",
  3546. description: expect.stringContaining("段落被切得过碎"),
  3547. }),
  3548. expect.objectContaining({
  3549. category: "paragraph-shape",
  3550. description: expect.stringContaining("连续出现"),
  3551. }),
  3552. ]),
  3553. );
  3554. } finally {
  3555. await rm(root, { recursive: true, force: true });
  3556. }
  3557. });
  3558. it("resolves duplicate chapter titles before persist", async () => {
  3559. const { root, runner, state, bookId } = await createRunnerFixture();
  3560. const storyDir = join(state.bookDir(bookId), "story");
  3561. const chaptersDir = join(state.bookDir(bookId), "chapters");
  3562. const now = "2026-03-19T00:00:00.000Z";
  3563. await Promise.all([
  3564. writeFile(join(chaptersDir, "0001_回声.md"), "# 第1章 回声\n\n旧章节。", "utf-8"),
  3565. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3566. chapter: 1,
  3567. location: "Ashen ferry crossing",
  3568. protagonistState: "Lin Yue still hides the oath token.",
  3569. goal: "Find the vanished mentor.",
  3570. conflict: "The debt trail keeps narrowing.",
  3571. }), "utf-8"),
  3572. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  3573. ]);
  3574. await state.saveChapterIndex(bookId, [{
  3575. number: 1,
  3576. title: "回声",
  3577. status: "ready-for-review",
  3578. wordCount: 12,
  3579. createdAt: now,
  3580. updatedAt: now,
  3581. auditIssues: [],
  3582. lengthWarnings: [],
  3583. }]);
  3584. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  3585. createWriterOutput({
  3586. chapterNumber: 2,
  3587. title: "回声",
  3588. content: "啊。",
  3589. wordCount: "啊。".length,
  3590. }),
  3591. );
  3592. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  3593. createAuditResult({
  3594. passed: true,
  3595. issues: [],
  3596. summary: "clean",
  3597. }),
  3598. );
  3599. try {
  3600. const result = await runner.writeNextChapter(bookId, 120);
  3601. const index = await state.loadChapterIndex(bookId);
  3602. expect(result.title).toBe("回声(2)");
  3603. expect(index.at(-1)?.title).toBe("回声(2)");
  3604. } finally {
  3605. await rm(root, { recursive: true, force: true });
  3606. }
  3607. });
  3608. it("regenerates duplicate chapter titles before falling back to numeric suffixes", async () => {
  3609. const { root, runner, state, bookId } = await createRunnerFixture();
  3610. const storyDir = join(state.bookDir(bookId), "story");
  3611. const chaptersDir = join(state.bookDir(bookId), "chapters");
  3612. const now = "2026-03-19T00:00:00.000Z";
  3613. await Promise.all([
  3614. writeFile(join(chaptersDir, "0001_回声.md"), "# 第1章 回声\n\n旧章节。", "utf-8"),
  3615. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3616. chapter: 1,
  3617. location: "Ashen ferry crossing",
  3618. protagonistState: "Lin Yue still hides the oath token.",
  3619. goal: "Find the vanished mentor.",
  3620. conflict: "The debt trail keeps narrowing.",
  3621. }), "utf-8"),
  3622. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  3623. ]);
  3624. await state.saveChapterIndex(bookId, [{
  3625. number: 1,
  3626. title: "回声",
  3627. status: "ready-for-review",
  3628. wordCount: 12,
  3629. createdAt: now,
  3630. updatedAt: now,
  3631. auditIssues: [],
  3632. lengthWarnings: [],
  3633. }]);
  3634. vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
  3635. createWriterOutput({
  3636. chapterNumber: 2,
  3637. title: "回声",
  3638. content: "塔楼里的铜铃只响了一声,风从缺口灌进来,守夜人没有回头。",
  3639. wordCount: "塔楼里的铜铃只响了一声,风从缺口灌进来,守夜人没有回头。".length,
  3640. }),
  3641. );
  3642. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  3643. createAuditResult({
  3644. passed: true,
  3645. issues: [],
  3646. summary: "clean",
  3647. }),
  3648. );
  3649. try {
  3650. const result = await runner.writeNextChapter(bookId, 120);
  3651. const index = await state.loadChapterIndex(bookId);
  3652. expect(result.title).toContain("塔楼");
  3653. expect(result.title).not.toBe("回声(2)");
  3654. expect(index.at(-1)?.title).toBe(result.title);
  3655. } finally {
  3656. await rm(root, { recursive: true, force: true });
  3657. }
  3658. });
  3659. it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => {
  3660. const { root, runner, state, bookId } = await createRunnerFixture();
  3661. const storyDir = join(state.bookDir(bookId), "story");
  3662. const chaptersDir = join(state.bookDir(bookId), "chapters");
  3663. await Promise.all([
  3664. writeFile(join(chaptersDir, "0001_Test_Chapter.md"), "# 第1章 Test Chapter\n\nOriginal body.", "utf-8"),
  3665. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3666. chapter: 1,
  3667. location: "Ashen ferry crossing",
  3668. protagonistState: "Lin Yue still hides the oath token.",
  3669. goal: "Find the vanished mentor.",
  3670. conflict: "The mentor debt is still personal.",
  3671. }), "utf-8"),
  3672. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  3673. ]);
  3674. await state.saveChapterIndex(bookId, [{
  3675. number: 1,
  3676. title: "Test Chapter",
  3677. status: "audit-failed",
  3678. wordCount: "Original body.".length,
  3679. createdAt: "2026-03-19T00:00:00.000Z",
  3680. updatedAt: "2026-03-19T00:00:00.000Z",
  3681. auditIssues: [],
  3682. lengthWarnings: [],
  3683. }]);
  3684. vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
  3685. createAuditResult({
  3686. passed: false,
  3687. issues: [CRITICAL_ISSUE],
  3688. summary: "needs revision",
  3689. }),
  3690. );
  3691. const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  3692. createReviseOutput({
  3693. revisedContent: "Spot-fixed body.",
  3694. wordCount: "Spot-fixed body.".length,
  3695. updatedState: createStateCard({
  3696. chapter: 1,
  3697. location: "Ashen ferry crossing",
  3698. protagonistState: "Lin Yue still hides the oath token.",
  3699. goal: "Find the vanished mentor.",
  3700. conflict: "The mentor debt is repaired.",
  3701. }),
  3702. updatedHooks: "# Pending Hooks\n",
  3703. }),
  3704. );
  3705. try {
  3706. await runner.reviseDraft(bookId, 1);
  3707. expect(reviseChapter).toHaveBeenCalledTimes(1);
  3708. expect(reviseChapter.mock.calls[0]?.[4]).toBe("spot-fix");
  3709. } finally {
  3710. await rm(root, { recursive: true, force: true });
  3711. }
  3712. });
  3713. it("passes governed control inputs into manual revise in v2 mode", async () => {
  3714. const { root, runner, state, bookId } = await createRunnerFixture({
  3715. inputGovernanceMode: "v2",
  3716. });
  3717. const storyDir = join(state.bookDir(bookId), "story");
  3718. const chaptersDir = join(state.bookDir(bookId), "chapters");
  3719. const originalBody = "林越推门进去,先看见柜台后那盏没关的灯。";
  3720. await Promise.all([
  3721. writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n把注意力收回师债主线。\n", "utf-8"),
  3722. writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n\n## 第1章\n先处理商会路线噪音。\n", "utf-8"),
  3723. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3724. chapter: 1,
  3725. location: "旧港便利店",
  3726. protagonistState: "林越仍在追查师债。",
  3727. goal: "把注意力拉回师债线索。",
  3728. conflict: "商会路线仍在分散注意力。",
  3729. }), "utf-8"),
  3730. writeFile(join(storyDir, "story_bible.md"), "# 世界观设定\n\n- 誓令碎片不可伪造。\n", "utf-8"),
  3731. writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n\n- 师债线索仍未回收。\n", "utf-8"),
  3732. writeFile(join(storyDir, "chapter_summaries.md"), [
  3733. "# 章节摘要",
  3734. "",
  3735. "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
  3736. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  3737. "| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
  3738. "",
  3739. ].join("\n"), "utf-8"),
  3740. writeFile(join(chaptersDir, "0001_夜灯.md"), `# 第1章 夜灯\n\n${originalBody}`, "utf-8"),
  3741. ]);
  3742. await state.saveChapterIndex(bookId, [{
  3743. number: 1,
  3744. title: "夜灯",
  3745. status: "audit-failed",
  3746. wordCount: originalBody.length,
  3747. createdAt: "2026-03-19T00:00:00.000Z",
  3748. updatedAt: "2026-03-19T00:00:00.000Z",
  3749. auditIssues: [],
  3750. lengthWarnings: [],
  3751. }]);
  3752. const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  3753. .mockResolvedValueOnce(
  3754. createAuditResult({
  3755. passed: false,
  3756. issues: [CRITICAL_ISSUE],
  3757. summary: "needs revision",
  3758. }),
  3759. )
  3760. .mockResolvedValueOnce(
  3761. createAuditResult({
  3762. passed: true,
  3763. issues: [],
  3764. summary: "clean",
  3765. }),
  3766. );
  3767. const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  3768. createReviseOutput({
  3769. revisedContent: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
  3770. wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
  3771. fixedIssues: ["- 收紧了主线焦点。"],
  3772. updatedState: createStateCard({
  3773. chapter: 1,
  3774. location: "旧港便利店",
  3775. protagonistState: "林越把注意力重新拉回师债。",
  3776. goal: "继续追查师债。",
  3777. conflict: "商会路线暂时退居背景。",
  3778. }),
  3779. updatedHooks: "# 伏笔池\n\n- 师债线索仍未回收。\n",
  3780. }),
  3781. );
  3782. try {
  3783. await runner.reviseDraft(bookId, 1);
  3784. expect(auditChapter.mock.calls[0]?.[4]).toMatchObject({
  3785. chapterIntent: expect.stringContaining("# Chapter Intent"),
  3786. contextPackage: expect.objectContaining({
  3787. selectedContext: expect.any(Array),
  3788. }),
  3789. ruleStack: expect.objectContaining({
  3790. activeOverrides: expect.any(Array),
  3791. }),
  3792. });
  3793. expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
  3794. chapterIntent: expect.stringContaining("# Chapter Intent"),
  3795. contextPackage: expect.objectContaining({
  3796. selectedContext: expect.any(Array),
  3797. }),
  3798. ruleStack: expect.objectContaining({
  3799. activeOverrides: expect.any(Array),
  3800. }),
  3801. lengthSpec: expect.objectContaining({
  3802. target: 3000,
  3803. }),
  3804. });
  3805. } finally {
  3806. await rm(root, { recursive: true, force: true });
  3807. }
  3808. });
  3809. it("passes one-off external brief into manual revise in v2 mode", async () => {
  3810. const { root, runner, state, bookId } = await createRunnerFixture({
  3811. inputGovernanceMode: "v2",
  3812. externalContext: "把注意力收回师债主线,并强调柜台后的异常灯光。",
  3813. });
  3814. const storyDir = join(state.bookDir(bookId), "story");
  3815. const chaptersDir = join(state.bookDir(bookId), "chapters");
  3816. const originalBody = "林越推门进去,先看见柜台后那盏没关的灯。";
  3817. await Promise.all([
  3818. writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n商会路线优先。\n", "utf-8"),
  3819. writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n\n## 第1章\n先处理商会路线噪音。\n", "utf-8"),
  3820. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3821. chapter: 1,
  3822. location: "旧港便利店",
  3823. protagonistState: "林越仍在追查师债。",
  3824. goal: "把注意力拉回师债线索。",
  3825. conflict: "商会路线仍在分散注意力。",
  3826. }), "utf-8"),
  3827. writeFile(join(storyDir, "story_bible.md"), "# 世界观设定\n\n- 誓令碎片不可伪造。\n", "utf-8"),
  3828. writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n\n- 师债线索仍未回收。\n", "utf-8"),
  3829. writeFile(join(storyDir, "chapter_summaries.md"), [
  3830. "# 章节摘要",
  3831. "",
  3832. "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
  3833. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  3834. "| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
  3835. "",
  3836. ].join("\n"), "utf-8"),
  3837. writeFile(join(chaptersDir, "0001_夜灯.md"), `# 第1章 夜灯\n\n${originalBody}`, "utf-8"),
  3838. ]);
  3839. await state.saveChapterIndex(bookId, [{
  3840. number: 1,
  3841. title: "夜灯",
  3842. status: "audit-failed",
  3843. wordCount: originalBody.length,
  3844. createdAt: "2026-03-19T00:00:00.000Z",
  3845. updatedAt: "2026-03-19T00:00:00.000Z",
  3846. auditIssues: [],
  3847. lengthWarnings: [],
  3848. }]);
  3849. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  3850. .mockResolvedValueOnce(
  3851. createAuditResult({
  3852. passed: false,
  3853. issues: [CRITICAL_ISSUE],
  3854. summary: "needs revision",
  3855. }),
  3856. )
  3857. .mockResolvedValueOnce(
  3858. createAuditResult({
  3859. passed: true,
  3860. issues: [],
  3861. summary: "clean",
  3862. }),
  3863. );
  3864. const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  3865. createReviseOutput({
  3866. revisedContent: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
  3867. wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
  3868. fixedIssues: ["- 收紧了主线焦点。"],
  3869. }),
  3870. );
  3871. try {
  3872. await runner.reviseDraft(bookId, 1);
  3873. expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
  3874. chapterIntent: expect.stringContaining("把注意力收回师债主线"),
  3875. });
  3876. expect(reviseChapter.mock.calls[0]?.[6]).not.toMatchObject({
  3877. chapterIntent: expect.stringContaining("商会路线优先"),
  3878. });
  3879. } finally {
  3880. await rm(root, { recursive: true, force: true });
  3881. }
  3882. });
  3883. it("passes merged AI-tell issues into manual revise and rejects no-improvement revisions", async () => {
  3884. const { root, runner, state, bookId } = await createRunnerFixture();
  3885. const storyDir = join(state.bookDir(bookId), "story");
  3886. const chaptersDir = join(state.bookDir(bookId), "chapters");
  3887. const originalBody = "林越抬手。林越停步。林越转身。林越侧耳。";
  3888. await Promise.all([
  3889. writeFile(join(chaptersDir, "0001_Test_Chapter.md"), `# 第1章 Test Chapter\n\n${originalBody}`, "utf-8"),
  3890. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3891. chapter: 1,
  3892. location: "Ashen ferry crossing",
  3893. protagonistState: "Lin Yue still hides the oath token.",
  3894. goal: "Find the vanished mentor.",
  3895. conflict: "The mentor debt is still personal.",
  3896. }), "utf-8"),
  3897. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  3898. ]);
  3899. await state.saveChapterIndex(bookId, [{
  3900. number: 1,
  3901. title: "Test Chapter",
  3902. status: "audit-failed",
  3903. wordCount: originalBody.length,
  3904. createdAt: "2026-03-19T00:00:00.000Z",
  3905. updatedAt: "2026-03-19T00:00:00.000Z",
  3906. auditIssues: [],
  3907. lengthWarnings: [],
  3908. }]);
  3909. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  3910. .mockResolvedValueOnce(
  3911. createAuditResult({
  3912. passed: false,
  3913. issues: [{
  3914. severity: "warning",
  3915. category: "节奏",
  3916. description: "结尾解释略多。",
  3917. suggestion: "压缩一行解释。",
  3918. }],
  3919. summary: "needs revision",
  3920. }),
  3921. )
  3922. .mockResolvedValueOnce(
  3923. createAuditResult({
  3924. passed: false,
  3925. issues: [{
  3926. severity: "warning",
  3927. category: "节奏",
  3928. description: "结尾解释略多。",
  3929. suggestion: "压缩一行解释。",
  3930. }],
  3931. summary: "still weak",
  3932. }),
  3933. );
  3934. const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  3935. createReviseOutput({
  3936. revisedContent: `${originalBody}\n\n修订后收束更利落。`,
  3937. wordCount: `${originalBody}\n\n修订后收束更利落。`.length,
  3938. fixedIssues: ["- 压缩了结尾解释。"],
  3939. }),
  3940. );
  3941. try {
  3942. const result = await runner.reviseDraft(bookId, 1);
  3943. const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
  3944. const savedIndex = await state.loadChapterIndex(bookId);
  3945. expect(reviseChapter).toHaveBeenCalledTimes(1);
  3946. expect(reviseChapter.mock.calls[0]?.[3]).toEqual(
  3947. expect.arrayContaining([
  3948. expect.objectContaining({ category: "节奏" }),
  3949. expect.objectContaining({ category: "列表式结构" }),
  3950. ]),
  3951. );
  3952. expect(result.applied).toBe(false);
  3953. expect(result.status).toBe("unchanged");
  3954. expect(result.skippedReason).toContain("did not improve");
  3955. expect(savedChapter).toContain(originalBody);
  3956. expect(savedChapter).not.toContain("修订后收束更利落");
  3957. expect(savedIndex[0]?.status).toBe("audit-failed");
  3958. } finally {
  3959. await rm(root, { recursive: true, force: true });
  3960. }
  3961. });
  3962. it("persists manual revisions only when merged audit improves", async () => {
  3963. const { root, runner, state, bookId } = await createRunnerFixture();
  3964. const storyDir = join(state.bookDir(bookId), "story");
  3965. const chaptersDir = join(state.bookDir(bookId), "chapters");
  3966. const originalBody = "林越抬手。林越停步。林越转身。林越侧耳。";
  3967. const revisedBody = "门被风顶开,林越先停在门槛前。\n\n他侧过身,听见墙后那道更轻的呼吸。";
  3968. await Promise.all([
  3969. writeFile(join(chaptersDir, "0001_Test_Chapter.md"), `# 第1章 Test Chapter\n\n${originalBody}`, "utf-8"),
  3970. writeFile(join(storyDir, "current_state.md"), createStateCard({
  3971. chapter: 1,
  3972. location: "Ashen ferry crossing",
  3973. protagonistState: "Lin Yue still hides the oath token.",
  3974. goal: "Find the vanished mentor.",
  3975. conflict: "The mentor debt is still personal.",
  3976. }), "utf-8"),
  3977. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  3978. ]);
  3979. await state.saveChapterIndex(bookId, [{
  3980. number: 1,
  3981. title: "Test Chapter",
  3982. status: "audit-failed",
  3983. wordCount: originalBody.length,
  3984. createdAt: "2026-03-19T00:00:00.000Z",
  3985. updatedAt: "2026-03-19T00:00:00.000Z",
  3986. auditIssues: [],
  3987. lengthWarnings: [],
  3988. }]);
  3989. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  3990. .mockResolvedValueOnce(
  3991. createAuditResult({
  3992. passed: false,
  3993. issues: [{
  3994. severity: "warning",
  3995. category: "节奏",
  3996. description: "结尾解释略多。",
  3997. suggestion: "压缩一行解释。",
  3998. }],
  3999. summary: "needs revision",
  4000. }),
  4001. )
  4002. .mockResolvedValueOnce(
  4003. createAuditResult({
  4004. passed: true,
  4005. issues: [],
  4006. summary: "clean",
  4007. }),
  4008. );
  4009. vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  4010. createReviseOutput({
  4011. revisedContent: revisedBody,
  4012. wordCount: revisedBody.length,
  4013. fixedIssues: ["- 收紧了结尾节奏。"],
  4014. updatedState: createStateCard({
  4015. chapter: 1,
  4016. location: "Ashen ferry crossing",
  4017. protagonistState: "Lin Yue still hides the oath token.",
  4018. goal: "Find the vanished mentor.",
  4019. conflict: "The mentor debt sharpens into a direct threat.",
  4020. }),
  4021. updatedHooks: "# Pending Hooks\n",
  4022. }),
  4023. );
  4024. try {
  4025. const result = await runner.reviseDraft(bookId, 1);
  4026. const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
  4027. const savedIndex = await state.loadChapterIndex(bookId);
  4028. expect(result.applied).toBe(true);
  4029. expect(result.status).toBe("ready-for-review");
  4030. expect(result.fixedIssues).toEqual(["- 收紧了结尾节奏。"]);
  4031. expect(savedChapter).toContain(revisedBody);
  4032. expect(savedIndex[0]?.status).toBe("ready-for-review");
  4033. expect(savedIndex[0]?.auditIssues).toEqual([]);
  4034. } finally {
  4035. await rm(root, { recursive: true, force: true });
  4036. }
  4037. });
  4038. it("re-audits revisions against updated state overrides instead of stale on-disk truth files", async () => {
  4039. const { root, runner, state, bookId } = await createRunnerFixture();
  4040. const storyDir = join(state.bookDir(bookId), "story");
  4041. const chaptersDir = join(state.bookDir(bookId), "chapters");
  4042. const originalBody = "Taryn kept one hand on the annexe key and listened at the door.";
  4043. const revisedBody = `${originalBody}\n\nHe checked the seal again before he moved.`;
  4044. await state.saveBookConfig(bookId, {
  4045. ...(await state.loadBookConfig(bookId)),
  4046. platform: "other",
  4047. genre: "progression",
  4048. language: "en",
  4049. chapterWordCount: 1800,
  4050. });
  4051. await Promise.all([
  4052. writeFile(join(chaptersDir, "0001_First.md"), `# Chapter 1: First\n\nOpening chapter.`, "utf-8"),
  4053. writeFile(join(chaptersDir, "0002_Test_Chapter.md"), `# Chapter 2: Test Chapter\n\n${originalBody}`, "utf-8"),
  4054. writeFile(join(storyDir, "current_state.md"), createStateCard({
  4055. chapter: 1,
  4056. location: "Orsden archive lower hall",
  4057. protagonistState: "Taryn is still moving under Renn's first warning.",
  4058. goal: "Reach the annexe.",
  4059. conflict: "The archive is already compromised.",
  4060. }), "utf-8"),
  4061. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  4062. ]);
  4063. await state.saveChapterIndex(bookId, [
  4064. {
  4065. number: 1,
  4066. title: "First",
  4067. status: "ready-for-review",
  4068. wordCount: countChapterLength("Opening chapter.", "en_words"),
  4069. createdAt: "2026-03-19T00:00:00.000Z",
  4070. updatedAt: "2026-03-19T00:00:00.000Z",
  4071. auditIssues: [],
  4072. lengthWarnings: [],
  4073. },
  4074. {
  4075. number: 2,
  4076. title: "Test Chapter",
  4077. status: "audit-failed",
  4078. wordCount: countChapterLength(originalBody, "en_words"),
  4079. createdAt: "2026-03-19T00:00:00.000Z",
  4080. updatedAt: "2026-03-19T00:00:00.000Z",
  4081. auditIssues: [],
  4082. lengthWarnings: [],
  4083. },
  4084. ]);
  4085. const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  4086. .mockResolvedValueOnce(
  4087. createAuditResult({
  4088. passed: false,
  4089. issues: [{
  4090. severity: "warning",
  4091. category: "Pacing Check",
  4092. description: "The beat needs a firmer end stop.",
  4093. suggestion: "Tighten the closing move.",
  4094. }],
  4095. summary: "needs revision",
  4096. }),
  4097. )
  4098. .mockImplementationOnce(async (_bookDir, _chapterContent, chapterNumber, _genre, options) => {
  4099. const overrideState = (options as { truthFileOverrides?: { currentState?: string } } | undefined)
  4100. ?.truthFileOverrides?.currentState;
  4101. if (chapterNumber === 2 && overrideState?.includes("| Current Chapter | 2 |")) {
  4102. return createAuditResult({
  4103. passed: true,
  4104. issues: [],
  4105. summary: "clean",
  4106. });
  4107. }
  4108. return createAuditResult({
  4109. passed: false,
  4110. issues: [{
  4111. severity: "critical",
  4112. category: "Chronicle Drift Check",
  4113. description: "The chapter is presented as 'chapter 2', but the supplied Current State Card still lists 'Current Chapter | 1'.",
  4114. suggestion: "Sync the state card before re-audit.",
  4115. }],
  4116. summary: "stale state card",
  4117. });
  4118. });
  4119. vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  4120. createReviseOutput({
  4121. revisedContent: revisedBody,
  4122. wordCount: countChapterLength(revisedBody, "en_words"),
  4123. fixedIssues: ["- Synced the annexe beat and tightened the ending."],
  4124. updatedState: createStateCard({
  4125. chapter: 2,
  4126. location: "East annexe corridor",
  4127. protagonistState: "Taryn is pressed against the annexe door with the true key in hand.",
  4128. goal: "Open the annexe before the cart clears the court.",
  4129. conflict: "A forged key and rival searchers have turned lawful access into a trap.",
  4130. }),
  4131. updatedHooks: "# Pending Hooks\n",
  4132. }),
  4133. );
  4134. try {
  4135. const result = await runner.reviseDraft(bookId, 2);
  4136. const savedIndex = await state.loadChapterIndex(bookId);
  4137. expect(auditChapter).toHaveBeenCalledTimes(2);
  4138. expect(result.applied).toBe(true);
  4139. expect(result.status).toBe("ready-for-review");
  4140. expect(savedIndex[1]?.status).toBe("ready-for-review");
  4141. expect(savedIndex[1]?.auditIssues).toEqual([]);
  4142. } finally {
  4143. await rm(root, { recursive: true, force: true });
  4144. }
  4145. });
  4146. it("excludes pure sequence-level fatigue from revision blocker counts", async () => {
  4147. const { root, runner, state, bookId } = await createRunnerFixture();
  4148. const bookDir = state.bookDir(bookId);
  4149. const storyDir = join(bookDir, "story");
  4150. const book = await state.loadBookConfig(bookId);
  4151. await writeFile(join(storyDir, "chapter_summaries.md"), [
  4152. "# 章节摘要",
  4153. "",
  4154. "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
  4155. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  4156. "| 1 | 旧门 | 林越 | 进入旧门 | 压力升高 | none | 冷峻 | 调查 |",
  4157. "| 2 | 灰灯 | 林越 | 检查灰灯 | 压力升高 | none | 冷峻 | 调查 |",
  4158. "| 3 | 纸页 | 林越 | 对照纸页 | 压力升高 | none | 冷峻 | 调查 |",
  4159. "",
  4160. ].join("\n"), "utf-8");
  4161. const result = await (
  4162. runner as unknown as {
  4163. evaluateMergedAudit: (params: {
  4164. auditor: Pick<ContinuityAuditor, "auditChapter">;
  4165. book: BookConfig;
  4166. bookDir: string;
  4167. chapterContent: string;
  4168. chapterNumber: number;
  4169. language: "zh" | "en";
  4170. }) => Promise<{
  4171. auditResult: AuditResult;
  4172. aiTellCount: number;
  4173. blockingCount: number;
  4174. criticalCount: number;
  4175. }>;
  4176. }
  4177. ).evaluateMergedAudit({
  4178. auditor: {
  4179. auditChapter: vi.fn().mockResolvedValue(
  4180. createAuditResult({
  4181. passed: true,
  4182. issues: [],
  4183. summary: "clean",
  4184. }),
  4185. ),
  4186. },
  4187. book,
  4188. bookDir,
  4189. chapterContent: "林越把纸页摊平,先看角上的水痕,再看最末那道被抹掉的签名。",
  4190. chapterNumber: 3,
  4191. language: "zh",
  4192. });
  4193. expect(result.auditResult.issues.some((issue) => issue.category === "节奏单调")).toBe(true);
  4194. expect(result.blockingCount).toBe(0);
  4195. expect(result.criticalCount).toBe(0);
  4196. await rm(root, { recursive: true, force: true });
  4197. });
  4198. it("keeps chapter-level blockers even when sequence-level fatigue shares the same category label", async () => {
  4199. const { root, runner, state, bookId } = await createRunnerFixture();
  4200. const bookDir = state.bookDir(bookId);
  4201. const storyDir = join(bookDir, "story");
  4202. const book = await state.loadBookConfig(bookId);
  4203. await writeFile(join(storyDir, "chapter_summaries.md"), [
  4204. "# 章节摘要",
  4205. "",
  4206. "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
  4207. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  4208. "| 1 | 旧门 | 林越 | 进入旧门 | 压力升高 | none | 冷峻 | 调查 |",
  4209. "| 2 | 灰灯 | 林越 | 检查灰灯 | 压力升高 | none | 冷峻 | 调查 |",
  4210. "| 3 | 纸页 | 林越 | 对照纸页 | 压力升高 | none | 冷峻 | 调查 |",
  4211. "",
  4212. ].join("\n"), "utf-8");
  4213. const result = await (
  4214. runner as unknown as {
  4215. evaluateMergedAudit: (params: {
  4216. auditor: Pick<ContinuityAuditor, "auditChapter">;
  4217. book: BookConfig;
  4218. bookDir: string;
  4219. chapterContent: string;
  4220. chapterNumber: number;
  4221. language: "zh" | "en";
  4222. }) => Promise<{
  4223. auditResult: AuditResult;
  4224. aiTellCount: number;
  4225. blockingCount: number;
  4226. criticalCount: number;
  4227. }>;
  4228. }
  4229. ).evaluateMergedAudit({
  4230. auditor: {
  4231. auditChapter: vi.fn().mockResolvedValue(
  4232. createAuditResult({
  4233. passed: false,
  4234. issues: [{
  4235. severity: "warning",
  4236. category: "节奏单调",
  4237. description: "这一章的推进依然原地打转,没有完成当前场景应有的落点。",
  4238. suggestion: "让当前章把既定动作落下,不要继续停在同一观察节拍。",
  4239. }],
  4240. summary: "needs revision",
  4241. }),
  4242. ),
  4243. },
  4244. book,
  4245. bookDir,
  4246. chapterContent: "林越把纸页摊平,先看角上的水痕,再看最末那道被抹掉的签名。",
  4247. chapterNumber: 3,
  4248. language: "zh",
  4249. });
  4250. expect(result.auditResult.issues.filter((issue) => issue.category === "节奏单调")).toHaveLength(2);
  4251. expect(result.blockingCount).toBe(1);
  4252. expect(result.criticalCount).toBe(0);
  4253. await rm(root, { recursive: true, force: true });
  4254. });
  4255. it("uses chapter length telemetry target for manual revise when available", async () => {
  4256. const { root, runner, state, bookId } = await createRunnerFixture();
  4257. const storyDir = join(state.bookDir(bookId), "story");
  4258. const chaptersDir = join(state.bookDir(bookId), "chapters");
  4259. const originalBody = "Tarin waited by the crooked berth marker and counted the missing lines twice.";
  4260. const revisedBody = `${originalBody}\n\nHe did not move until the second bell rang across the water.`;
  4261. await state.saveBookConfig(bookId, {
  4262. ...(await state.loadBookConfig(bookId)),
  4263. platform: "other",
  4264. genre: "progression",
  4265. language: "en",
  4266. chapterWordCount: 1800,
  4267. });
  4268. await Promise.all([
  4269. writeFile(join(chaptersDir, "0001_Test_Chapter.md"), `# Chapter 1: Test Chapter\n\n${originalBody}`, "utf-8"),
  4270. writeFile(join(storyDir, "current_state.md"), createStateCard({
  4271. chapter: 1,
  4272. location: "Dock Nine",
  4273. protagonistState: "Tarin still carries the sealed packet.",
  4274. goal: "Find Captain Voss.",
  4275. conflict: "The berth is wrong and the crew is missing.",
  4276. }), "utf-8"),
  4277. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
  4278. ]);
  4279. await state.saveChapterIndex(bookId, [{
  4280. number: 1,
  4281. title: "Test Chapter",
  4282. status: "audit-failed",
  4283. wordCount: countChapterLength(originalBody, "en_words"),
  4284. createdAt: "2026-03-19T00:00:00.000Z",
  4285. updatedAt: "2026-03-19T00:00:00.000Z",
  4286. auditIssues: [],
  4287. lengthWarnings: [],
  4288. lengthTelemetry: {
  4289. target: 900,
  4290. softMin: 778,
  4291. softMax: 1022,
  4292. hardMin: 655,
  4293. hardMax: 1145,
  4294. countingMode: "en_words",
  4295. writerCount: countChapterLength(originalBody, "en_words"),
  4296. postWriterNormalizeCount: countChapterLength(originalBody, "en_words"),
  4297. postReviseCount: 0,
  4298. finalCount: countChapterLength(originalBody, "en_words"),
  4299. normalizeApplied: false,
  4300. lengthWarning: false,
  4301. },
  4302. }]);
  4303. vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
  4304. .mockResolvedValueOnce(
  4305. createAuditResult({
  4306. passed: false,
  4307. issues: [CRITICAL_ISSUE],
  4308. summary: "needs revision",
  4309. }),
  4310. )
  4311. .mockResolvedValueOnce(
  4312. createAuditResult({
  4313. passed: true,
  4314. issues: [],
  4315. summary: "clean",
  4316. }),
  4317. );
  4318. const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
  4319. createReviseOutput({
  4320. revisedContent: revisedBody,
  4321. wordCount: countChapterLength(revisedBody, "en_words"),
  4322. fixedIssues: ["- Tightened the berth discovery beat."],
  4323. updatedState: createStateCard({
  4324. chapter: 1,
  4325. location: "Dock Nine",
  4326. protagonistState: "Tarin still carries the sealed packet.",
  4327. goal: "Find Captain Voss.",
  4328. conflict: "The berth is wrong and the crew is missing.",
  4329. }),
  4330. updatedHooks: "# Pending Hooks\n",
  4331. }),
  4332. );
  4333. try {
  4334. await runner.reviseDraft(bookId, 1, "polish");
  4335. expect(reviseChapter).toHaveBeenCalledTimes(1);
  4336. expect(reviseChapter.mock.calls[0]?.[6]?.lengthSpec).toMatchObject({
  4337. target: 900,
  4338. countingMode: "en_words",
  4339. });
  4340. } finally {
  4341. await rm(root, { recursive: true, force: true });
  4342. }
  4343. });
  4344. });